使用PHP从MP3中提取数据
前几天,我在看手机上的媒体播放器,然后看屏幕上显示的音乐波形。这只是整个音轨进度的简单指示,并且似乎显示了我正在听的音轨的安静且响亮的部分。这让我开始思考如何提取此信息,因为我正在观看的媒体播放器必须实时执行此操作才能真正播放数据,而不仅仅是呈现音乐的这种表示形式。
在对MP3文件格式进行了一些研究之后,我发现该文件格式很复杂,但是可以直接处理,因此我决定尝试使用PHP提取数据。我已经看到了一些使用PHP从MP3提取数据的技术,但是这些技术主要涉及ffmpeg之类的应用程序,用于将音频格式转换为WAV文件之类的东西,然后对其进行处理。
由于这是PHP,因此尚无法(实际上吗?!)实际播放声音,因此,这仅是一种将音频提取为数据流并将其表示为图像的方法。另外,MP3格式周围有很多复杂性,因此尽管本文将着重于提取文件格式中的某些信息,但我建议您对MP3进行自己的研究以更好地理解它。
MP3格式不是免费或开源的,它是一种商业控制的格式,因此您不会在其中找到许多免费和开源的MP3解码器。我敢肯定,这就是为什么我能找到的原因只是对格式帮助足够的信息,但还不足以真正弄清楚到底是怎么回事。如果要完全访问官方标准,则需要为音频压缩ISO标准文档支付一些费用。
MP3文件格式
MP3文件是二进制文件格式,这意味着您不能只将MP3文件放入普通的文本编辑器中并期望查看其内容。典型的MP3文件的结构是一个标头部分,其中可能包含一些元数据,然后是音轨的音频数据,二者之间由0位数据部分分隔。标头用于存储曲目名称,专辑名称,艺术家名称,曲目的编号,曲目的长度以及其他一些信息。为了避免混乱,MP3有几种不同的格式,这些格式的标签格式以及音频信息的存储方式不同。但是,文件的整体结构是相同的。
可以这样绘制MP3文件,其中标题和音频数据由0位数据段分隔。
-------------------- | Header | -------------------- 0000000000000000000000000000000 -------------------- | Audio data | --------------------
这是使用十六进制编辑器的MP3文件结构的真实示例。
MP3文件中的音频数据存储在一部分帧中,每个帧都有一个标头,用于描述诸如帧的长度,比特率,存在的采样数等内容。标头部分之后是实际的音频数据那个帧。
MP3上的Wikipedia页面上有很多关于MP3文件结构的详细信息,因此如果您想了解更多信息,我会读它,这是一个很好的起点。
考虑到结构,我们需要使用PHP打开文件。默认情况下,PHP将打开一个文件,并尝试将数据转换为某种形式的可读格式。在很多情况下,PHP可以将文本文件读入内存并使用它,这是很有意义的。要在PHP中打开像MP3文件这样的二进制文件,我们需要使用fopen()文件模式为'b'(表示二进制)的函数。例如,
$file = '04 One.mp3'; $fileHandle = fopen($file, 'rb');
现在,我们准备从文件中提取所需的数据。
提取标题数据
顺便说一句,我认为查看MP3文件的标头信息可能会很有趣。
要查看标题信息是否存在,您只需要从文件中读取数据的前3个字节,如果读取的是“ID3”,那么我们知道文件中存在标题标签。ID3标准理解起来可能非常复杂,并且具有许多可用的版本。要检测文件中正在使用的ID3版本,请读取ID3位之后的下两个字节。
在PHP中,我们可以通过打开文件并从文件中读取数据的前几个字节来读取ID3版本。
//将文件读入内存。 $fileHandle = fopen('02 Impulse Crush.mp3', 'rb'); $binary = fread($fileHandle, 5); //检测是否存在ID3信息。 if (substr($binary, 0, 3) == "ID3") { //检测到ID3标签。 $tags['FileName'] = $file; $tags['TAG'] = substr($binary, 0, 3); $tags['Version'] = hexdec(bin2hex(substr($binary, 3, 1))) . "." . hexdec(bin2hex(substr($binary, 4, 1))); }
标签数组现在包含以下数据。
Array ( [FileName] => 02 Impulse Crush.mp3 [TAG] => ID3 [Version] => 4.0 )
标头的接下来的几个字节包含有关正在使用哪个版本的ID3标头的信息,一个字节用于设置某些标志,而四个字节则用于显示标记的长度(以32位syncsafe整数表示)。这是标头中前10个字节的摘要。
3 bytes !, D and 3 1 byte for the version number (2, 3 or 4) 1 byte for the revision number 1 byte setting a number of flags - bit 5 is an experimental tag - bit 6 sets an extended header - bit 7 sets an unsynchronisation 4 bytes set the length of the tag itself. This is stored as a syncsafe integer.
同步安全整数(或同步安全整数)是一种存储整数值的方式,该方式意味着它不会干扰文件本身处理的正常操作。这样一来,无法读取ID3标头的播放器仍然可以处理文件的其余部分,而不会出现任何问题。在ID3标头的标头部分找不到音频帧标头(请参阅下文)。
从本质上讲,这意味着您需要忽略每个字节的最高有效位,并将每个字节向右移一位。
Synchsafe number : 00000000 00001110 00011101 01011010 real number : 00000000 00000011 10001110 11011010
为了找出标头的完整长度,我们可以将其封装在一个函数中,在其中提取长度字节并将其提取为整数值。然后,我们在返回之前添加到页眉和页脚值(如果已设置页脚标志)。
function headerOffset($fileHandle) { //提取文件的前10个字节,并将句柄设置回0。 fseek($fileHandle, 0); $block = fread($fileHandle, 10); fseek($fileHandle, 0); $offset = 0; if (substr($block, 0, 3) == "ID3") { //我们可以忽略字节3和4,因此这里不提取它们。 //提取ID3标志。 $id3v2Flags = ord($block[5]); $flagUnsynchronisation = $id3v2Flags & 0x80 ? 1 : 0; $flagExtendedHeader = $id3v2Flags & 0x40 ? 1 : 0; $flagExperimental = $id3v2Flags & 0x20 ? 1 : 0; $flagFooterPresent = $id3v2Flags & 0x10 ? 1 : 0; //提取长度字节。 $length0 = ord($block[6]); $length1 = ord($block[7]); $length2 = ord($block[8]); $length3 = ord($block[9]); //通过查看起始位来检查以确保这是一个安全同步整数。 if ((($length0 & 0x80) == 0) && (($length1 & 0x80) == 0) && (($length2 & 0x80) == 0) && (($length3 & 0x80) == 0)) { //提取标签大小。 $tagSize = $length0 << 21 | $length1 << 14 | $length2 << 7 | $length3; //根据页眉大小和页脚标志找出其他元素的长度。 $headerSize = 10; $footerSize = $flagFooterPresent ? 10 : 0; //全部加在一起。 $offset = $headerSize + $tagSize + $footerSize; } } return $offset; }
这意味着如果我们要跳过标题,我们可以调用此函数并快速遍历文件,此函数返回的字节数。
可以使用几种不同版本的ID3标签,但可以在数据中查找它们,以查找标题中的标签名称并读取该标签的内容。例如,ID3版本2.0标签“TRK”用于存储有关轨道名称的信息,读取接下来的3个字节将为我们提供该标签中数据的长度,因此我们要做的就是将许多字节读入内存并解码标签内容。
在下面的示例中,我们检测到TRK标签,并看到数据长23个字节(十六进制为17个),因此读取这27个字节作为ASCII字符编码可以得到轨道的名称。
T R K 54 54 32 00 00 17 = 23 bytes 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 00 42 72 69 6E 67 20 42 61 63 6B 20 74 68 65 20 50 6C 61 67 75 65 00 B r i n g B a c k T h e P l a g u e
在PHP中,我发现以下代码会产生一些不错的结果。它具有预定义的标签列表,并遍历列表中的每个潜在标签并将其提取到数组中。代码只提取标签,数据长度,然后遍历找到的数据,以可读的方式将其打印出来。
$id3v22 = ["TT2", "TAL", "TP1", "TRK", "TYE", "TLE", "ULT"]; for ($i = 0; $i < count($id3v22); $i++) { //在文件数据中查找每个标签。 if (strpos($binary, $id3v22[$i] . chr(0)) != FALSE) { //提取标签的位置和数据长度。 $pos = strpos($binary, $id3v22[$i] . chr(0)); $len = hexdec(bin2hex(substr($binary, ($pos + 3), 3))); $data = substr($binary, ($pos + 6), $len); $tag = substr($binary, $pos, 3); //提取数据。 $tagData = ''; for ($a = 0; $a <= strlen($data); $a++) { $char = substr($data, $a, 1); if (ord($char) != 0 && ord($char) != 3 && ord($char) != 225 && ctype_print($char)) { $tagData .= $char; } elseif (ord($char) == 225 || ord($char) == 13) { $tagData .= "\n"; } } if ($tag == "TT2") { $tags['Title'] = $tagData; } if ($tag == "TAL") { $tags['Album'] = $tagData; } if ($tag == "TP1") { $tags['Author'] = $tagData; } if ($tag == "TRK") { $tags['Track'] = $tagData; } if ($tag == "TYE") { $tags['Year'] = $tagData; } if ($tag == "TLE") { $tags['Length'] = $tagData; } if ($tag == "ULT") { $tags['Lyric'] = $tagData; } } }
ID3版本3和4标签非常相似,它们仅包含4个字符代码,因此需要适当的偏移量才能提取数据。
$id3v23 = ["TIT2", "TALB", "TPE1", "TRCK", "TYER", "TLEN", "USLT"]; //在文件数据中查找每个标签。 for ($i = 0; $i < count($id3v23); $i++) { if (strpos($binary, $id3v23[$i] . chr(0)) != FALSE) { //提取标签的位置和数据长度。 $pos = strpos($binary, $id3v23[$i] . chr(0)); $len = hexdec(bin2hex(substr($binary, ($pos + 5), 3))); $data = substr($binary, ($pos + 10), $len); $tag = substr($binary, $pos, 4); //提取标签和数据。 $tagData = ''; for ($a = 0; $a <= strlen($data); $a++) { $char = substr($data, $a, 1); if (ord($char) != 0 && ord($char) != 3 && ord($char) != 225 && ctype_print($char)) { $tagData .= $char; } elseif (ord($char) == 225 || ord($char) == 13) { $tagData .= "\n"; } } if ($tag == "TIT2") { $tags['Title'] = $tagData; } //其余标签将在此处提取到标签数组中。 } }
这些示例中要注意的一件事是ID3中还有其他可用的标签,但这只是一些更常见的标签。
以下是从我的收藏集中随机选择的MP3文件的一些结果。
Array ( [FileName] => 07 Bring Back the Plague.mp3 [TAG] => ID3 [Version] => 2.0 [Title] => Bring Back the Plague [Album] => Death Atlas [Author] => Cattle Decapitation [Track] => 7/15 [Year] => 2019 ) Array ( [FileName] => 11 All That Has Gone Before.mp3 [TAG] => ID3 [Version] => 2.0 [Title] => All That Has Gone Before [Album] => Grievances [Author] => Rolo Tomassi [Track] => 11/11 [Year] => 2015 ) Array ( [FileName] => 02 Impulse Crush.mp3 [TAG] => ID3 [Version] => 4.0 [Title] => Impulse Crush [Album] => The Language of Injury [Author] => ITHACA [Track] => 2 ) Array ( [FileName] => 06 Asura's Realm.mp3 [TAG] => ID3 [Version] => 2.0 [Title] => Asura's Realm [Album] => Samsara [Author] => Venom Prison [Track] => 6/10 [Year] => 2019 )
如果您想全面了解ID3,则可以浏览ID3网站,该网站包含有关标准的很多信息,包括所有标签以及我在此处跳过的其他信息。
提取音频数据
音频数据的主体包含一系列帧,每个帧代表一秒的音频数据。音频数据具有一个32位的标头部分,该部分分为13个不同的部分,然后是音频数据本身。每个帧都以一系列长度为12位的同步位开始,这意味着读取数据所需要做的就是跳过标头信息,然后在一行中找到每个12位的实例。标头采用以下格式。
1-12: First 12 bits all containing 1 MP3 sync word. 13: The version. 14-15: The layer. 16: Error protection. 17-20: The bitrate. 21-22: The sampling rate. 23: Padding bit (0 means the frame is not padded). 24: Private bit. 25-26: The mode. 27-28: The mode extension. 29: Copy-right flag (0 means not copy-righted). 30: Original (0 means copy of original media). 31-32: Emphasis.
标头出现之后,便是实际的音频数据,该数据将是通过查看比特率和采样率计算得出的字节长度。使用十六进制编辑器检查文件,我们可以看到以FFB开头的标头,其后是标头标志的字节以及实际的音频数据本身。
相当多的标头组件可以在查找表的基础上工作。意味着版本位中的值0转换为查找表中的版本2.5。此查找过程所需的查找值如下,该值与上述headerOffset()方法一起封装到一个类中,以将所有内容绑定在一起。
class Mp3 { protected $versions = [ 0x0 => '2.5', 0x1 => 'x', 0x2 => '2', 0x3 => '1', ]; protected $layers = [ 0x0 => 'x', 0x1 => '3', 0x2 => '2', 0x3 => '1', ]; protected $bitrates = [ 'V1L1' => [0,32,64,96,128,160,192,224,256,288,320,352,384,416,448], 'V1L2' => [0,32,48,56, 64, 80, 96,112,128,160,192,224,256,320,384], 'V1L3' => [0,32,40,48, 56, 64, 80, 96,112,128,160,192,224,256,320], 'V2L1' => [0,32,48,56, 64, 80, 96,112,128,144,160,176,192,224,256], 'V2L2' => [0, 8,16,24, 32, 40, 48, 56, 64, 80, 96,112,128,144,160], 'V2L3' => [0, 8,16,24, 32, 40, 48, 56, 64, 80, 96,112,128,144,160], ]; protected $samplerates = [ '1' => [44100, 48000, 32000], '2' => [22050, 24000, 16000], '2.5' => [11025, 12000, 8000], ]; protected $samples = [ 1 => [1 => 384, 2 =>1152, 3 => 1152,], 2 => [1 => 384, 2 =>1152, 3 => 576,], ]; //其他方法。 }
有了这些查找表后,我们可以将数据提取到MP3文件中。我在此处添加了很多注释以显示正在发生的情况,但是我们实质上是在提取标题数据,找出帧数据有多长时间并将该数据读入内存。
public function readAudioData() { //打开文件。 $fileHandle = fopen($this->file, "rb"); //跳过标题。 $offset = $this->headerOffset($fileHandle); fseek($fileHandle, $offset, SEEK_SET); while (!feof($fileHandle)) { //我们将文件一次蚕食10个字节。 $block = fread($fileHandle, 8); if (strlen($block) < 8) { break; } //寻找11111111111(帧同步位) else if ($block[0] == "\xff" && (ord($block[1]) & 0xe0)) { $fourbytes = substr($block, 0, 4); //帧同步中的第一个字节块始终为0xff //因此我们忽略$fourbytes[0],但需要处理$fourbytes[1] //版本信息。 $b1 = ord($fourbytes[1]); $b2 = ord($fourbytes[2]); $b3 = ord($fourbytes[3]); //提取版本并创建用于查找的简单版本。 $version = $this->versions[($b1 & 0x18) >> 3]; $simpleVersion = ($version == '2.5' ? 2 : $version); //提取层。 $layer = $this->layers[($b1 & 0x06) >> 1]; //提取保护位。 $protectionBit = ($b1 & 0x01); //提取比特率。 $bitrateKey = sprintf('V%dL%d', $simpleVersion, $layer); $bitrateId = ($b2 & 0xf0) >> 4; $bitrate = isset($this->bitrates[$bitrateKey][$bitrateId]) ? $this->bitrates[$bitrateKey][$bitrateId] : 0; //提取采样率。 $sampleRateId = ($b2 & 0x0c) >> 2; $sampleRate = isset($this->samplerates[$version][$sampleRateId]) ? $this->samplerates[$version][$sampleRateId] : 0; //提取填充位。 $paddingBit = ($b2 & 0x02) >> 1; //提取帧大小。 if ($layer == 1) { $framesize = intval(((12 * $bitrate * 1000 / $sampleRate) + $paddingBit) * 4); } else { //2和3之后。 $framesize = intval(((144 * $bitrate * 1000) / $sampleRate) + $paddingBit); } //提取样品。 $frameSamples = $this->samples[$simpleVersion][$layer]; //提取其他位。 $channelModeBits = ($b3 & 0xc0) >> 6; $modeExtensionBits = ($b3 & 0x30) >> 4; $copyrightBit = ($b3 & 0x08) >> 3; $originalBit = ($b3 & 0x04) >> 2; $emphasis = ($b3 & 0x03); //计算持续时间并将其添加到运行总计中。 $this->duration += ($frameSamples / $sampleRate); //将帧数据读入存储器。 $frameData = fread($fileHandle, $framesize - 8); //对帧数据做一些事情。 } else if (substr($block, 0, 3) == 'TAG') { //如果这是一个标签,则跳过它。 fseek($fileHandle, 128 - 10, SEEK_CUR); } else { fseek($fileHandle, -9, SEEK_CUR); } } }
现在将帧中的数据存储在内存中,我们就可以开始可视化内容了。我可以找到有关此部分包含哪些内容的信息,但这非常复杂。本质上,由于它是音频信号的数字表示,因此为了将一种类型的数据转换为另一种类型,需要进行大量压缩。
实际上,对于我要在此处执行的操作而言,一帧数据相当长。与其使用帧本身中的所有数据(在某些更高的质量级别下每帧可能超过1000字节),不如抓住所有数据并将其使用。
为了解决这个问题,我构建了一个渲染函数,该函数可以查看类中的data属性,并根据向数组中添加了哪些值来渲染图像。此功能将数据中的框置于图像本身的高度之内,并且具有足够的通用性,以致大多数添加到数据数组中的值都可以产生某种形式或结果。图像的长度由要处理的音频文件的长度(即数据数组的长度)决定。
public function renderAsImage() { $height = 500; //创建图像资源。 $image = imagecreate($this->duration * $this->factor, $height); //将背景色设置为黑色。 imagecolorallocate($image, 0, 0, 0); //分配我们可以使用的前景色的集合。 $colors[] = imagecolorallocate($image, 255, 255, 255); $colors[] = imagecolorallocate($image, 255, 0, 0); $colors[] = imagecolorallocate($image, 0, 255, 0); $colors[] = imagecolorallocate($image, 0, 0, 255); $colors[] = imagecolorallocate($image, 128, 0, 0); $colors[] = imagecolorallocate($image, 0, 128, 0); $colors[] = imagecolorallocate($image, 0, 0, 128); //遍历数据并绘制在画布上。 foreach ($this->data as $index => $data) { foreach ($data as $dataDuration => $dataBit) { imagefilledellipse($image, $dataDuration, (($dataBit * 2) - $height) * -1, 2, 2, $colors[$index]); } } //使用原始文件名作为图像名称的一部分来渲染图像。 imagepng($image, $this->filename . '.png'); }
为了创建音频输出的图像,我们现在只需要处理音频数据,然后将数据渲染出来。我们将剩下一个图像文件,其外观应类似于我们输入的音频文件。
$mp3 = new Mp3('11 All That Has Gone Before.mp3'); $mp3->readAudioData(); $mp3->renderAsImage();
我的第一个尝试是尝试对前8个字节的数据求平均,然后将其发送到数据数组。
$average = 0; $sampleBytes = 8; for ($i = 0; $i <= $sampleBytes; $i++) { $average += ord($frameData[$i]); } $this->data[0][$this->duration * $this->factor] = $average / $sampleBytes;
这产生了一些好的结果,但平均值却很混乱。我发现一个1kHz的音调可以持续30秒,可以作为一个很好的测试台,以了解该过程的结果。该文件创建了看起来不正确的数据带。
经过一些实验,我发现某些字节位置指示了所产生信号的强度或频率。使用这些值,我可以将每个帧中的7个数据点集合在一起,这似乎可以产生更好的结果。
$this->data[0][$this->duration * $this->factor] = ord($frameData[0]); $this->data[1][$this->duration * $this->factor] = ord($frameData[2]); $this->data[2][$this->duration * $this->factor] = ord($frameData[9]); $this->data[3][$this->duration * $this->factor] = ord($frameData[16]); $this->data[4][$this->duration * $this->factor] = ord($frameData[23]);
以下是一些我最近听过的音乐生成的图像文件的示例。文件的高度为500px,但是歌曲的宽度却很长。
肖邦C中的Prelude#1。我用作测试文件的一小段,因为它很好地显示了音频的结构。
CLSR。Ithaca创作的这首歌就是一个很好的例子,因为这首歌开始时很安静,中间声音很大,而结尾时很安静。
我对“全能”的“扳手”感兴趣,因为它在歌曲的中间有几个完全沉默的部分。这些在图形中显示得非常好。
涅rv乐队的《闻起来像青少年的精神》也是一个很好的例子,它清楚地显示了歌曲的“安静,安静,安静”的结构,以及结尾处的长时间淡入淡出。
用牛斩杀带回瘟疫是完全残酷的,这在这里产生的各种噪声中都可以看出。
Svalbard最近发布的精彩影片《OpenWound》也取得了一些不错的成绩。请注意,歌曲结尾处的淡入淡出。
最后,我想尝试一下Metallica的作品,因为一开始它有一个非常安静的部分。
我可能可以改善此处产生的结果,但是我对目前的结果感到很满意。至少我有一种从MP3文件中提取原始数据的方法,以及一种表示该数据的(当然)的方法。
如果您想要我在这里使用过的所有代码,那么我创建了一个摘要,将所有这些代码作为一个类收集在一起。随时适应您自己的需求。如果您发现任何问题,请与我联系,我们将尽力予以纠正。
在研究此主题时,我还找到了一些很好的资源,这些资源帮助我将本文中的大部分内容汇总在一起:
ID3v2.3MP3文件的内部结构
PHP计算MP3的持续时间
MP3文件的剖析
MPEG音频压缩基础
让我们来构建一个MP3解码器!