[21, 36], self::MPEG_2 => [13, 21], self::MPEG_25 => [13, 21], ]; /** * @var int Limit in bytes for seeking a mpeg header in file */ public static $headerSeekLimit = 2048; public static $framesCountRead = 2; /** * @var Mp3File File object for I/O handling */ protected $fileObj; /** * @var int MPEG codec version (1 or 2 or 2.5 or undefined) */ public $codecVersion; /** * @var int Audio layer version (1 or 2 or 3) */ public $layerVersion; /** * @var int Audio size in bytes. Note that this value is NOT equals file size. */ public $audioSize; /** * @var float Audio duration in seconds.microseconds (e.g. 3603.0171428571) */ public $duration; /** * @var int Audio bit rate in bps (e.g. 128000) */ public $bitRate; /** * @var int Audio sample rate in Hz (e.g. 44100) */ public $sampleRate; /** * @var boolean Contains true if audio has variable bit rate */ public $isVbr = false; /** * @var boolean Contains true if audio has cover */ public $hasCover = false; /** * @var array Contains VBR properties */ public $vbrProperties = []; /** * @var array Contains picture properties */ public $coverProperties = []; /** * Channel mode (stereo or dual_mono or joint_stereo or mono) * @var string */ public $channel; /** * @var array Unified list of tags (id3v1 and id3v2 united) */ public $tags = []; /** * @var array Audio tags ver. 1 (aka id3v1) */ public $tags1 = []; /** * @var array Audio tags ver. 2 (aka id3v2) */ public $tags2 = []; /** * @var int Major version of id3v2 tag (if id3v2 present) (2 or 3 or 4) */ public $id3v2MajorVersion; public $id3v2Version; /** * @var int Minor version of id3v2 tag (if id3v2 present) */ public $id3v2MinorVersion; public $id3v2Revision; /** * @var array List of id3v2 header flags (if id3v2 present) */ public $id3v2Flags = []; /** * @var array List of id3v2 tags flags (if id3v2 present) */ public $id3v2TagsFlags = []; /** * @var string Contains audio file name */ public $_fileName; /** * @var int Contains file size */ public $_fileSize; /** * @var int Number of audio frames in file */ public $_framesCount = 0; /** * @var float Contains time spent to read&extract audio information. */ public $_parsingTime; /** * @var int Calculated frame size for Constant Bit Rate */ private $_cbrFrameSize; /** * @var int|null Size of id3v2-data */ public $_id3Size; /** * $mode is self::META, self::TAGS or their combination. * * @param string $filename * @param bool $parseTags * * @throws \Exception */ public function __construct(string $filename, bool $parseTags = false) { if (self::$_bitRateTable === null) self::$_bitRateTable = require __DIR__.'/../data/bitRateTable.php'; if (self::$_sampleRateTable === null) self::$_sampleRateTable = require __DIR__.'/../data/sampleRateTable.php'; $this->_fileName = $filename; if (str_contains($filename, '://')) { $this->fileObj = new Mp3FileRemote($filename); } else { $this->fileObj = new Mp3FileLocal($filename); } $this->_fileSize = $this->fileObj->getFileSize(); $mode = $parseTags ? self::META | self::TAGS : self::META; $this->audioSize = $this->parseAudio($mode); } /** * @return bool|null|string */ public function getCover() { if (empty($this->coverProperties)) { return null; } $curPos = $this->fileObj->getFilePos(); $this->fileObj->seekTo($this->coverProperties['offset']); $data = $this->fileObj->getBytes($this->coverProperties['size']); $this->fileObj->seekTo($curPos); return $data; } protected function getSynchsafeSize(string $rawBytes): int { $sizeBytes = unpack('C4', $rawBytes); $size = $sizeBytes[1] << 21 | $sizeBytes[2] << 14 | $sizeBytes[3] << 7 | $sizeBytes[4]; return $size; } /** * Reads audio file in binary mode. * mpeg audio file structure: * ID3V2 TAG - provides a lot of meta data. [optional] * MPEG AUDIO FRAMES - contains audio data. A frame consists of a frame header and a frame data. The first frame may contain extra information about mp3 (marked with "Xing" or "Info" string). Rest of frames can contain only audio data. * ID3V1 TAG - provides a few of meta data. [optional] * @param int $mode * @return float|int * @throws \Exception */ private function parseAudio($mode) { $time = microtime(true); /** @var int Size of audio data (exclude tags size) */ $audioSize = $this->fileObj->getFileSize(); // parse tags if ($this->fileObj->getBytes(3) == self::TAG2_SYNC) { if ($mode & self::TAGS) { $audioSize -= ($this->_id3Size = $this->readId3v2Body()); } else { $this->fileObj->seekForward(2); // 2 bytes of tag version $this->fileObj->seekForward(1); // 1 byte of tag flags $size = $this->getSynchsafeSize($this->fileObj->getBytes(4)); $size += 10; // add header size $audioSize -= ($this->_id3Size = $size); } } $this->fileObj->seekTo($this->fileObj->getFileSize() - 128); if ($this->fileObj->getBytes(3) == self::TAG1_SYNC) { if ($mode & self::TAGS) { $audioSize -= $this->readId3v1Body(); } else { $audioSize -= 128; } } if ($mode & self::TAGS) { $this->fillTags(); } $this->fileObj->seekTo(0); // audio meta if ($mode & self::META) { if ($this->_id3Size !== null) $this->fileObj->seekTo($this->_id3Size); /** * First frame can lie. Need to fix in the future. * @link https://github.com/wapmorgan/Mp3Info/issues/13#issuecomment-447470813 * Read first N frames */ for ($i = 0; $i < self::$framesCountRead; $i++) { $framesCount = $this->readMpegFrame(); } $this->_framesCount = $framesCount !== null ? $framesCount : ceil($audioSize / $this->_cbrFrameSize); // recalculate average bit rate in vbr case if ($this->isVbr && $framesCount !== null) { $avgFrameSize = $audioSize / $framesCount; $this->bitRate = $avgFrameSize * $this->sampleRate / (1000 * $this->layerVersion == self::LAYER_3 ? 12 : 144); } // The faster way to detect audio duration: $samples_in_second = $this->layerVersion == 1 ? self::LAYER_1_FRAME_SIZE : self::LAYERS_23_FRAME_SIZE; // for VBR: adjust samples in second according to VBR quality // disabled for now // if ($this->isVbr && isset($this->vbrProperties['quality'])) { // $samples_in_second = floor($samples_in_second * $this->vbrProperties['quality'] / 100); // } // Calculate total number of audio samples (framesCount * sampleInFrameCount) / samplesInSecondCount $this->duration = ($this->_framesCount - 1) * $samples_in_second / $this->sampleRate; } $this->_parsingTime = microtime(true) - $time; return $audioSize; } /** * Read first frame information. * @return int Number of frames (if present if first frame of VBR-file) * @throws \Exception */ private function readMpegFrame() { $headerSeekMax = $this->fileObj->getFilePos() + self::$headerSeekLimit; $headerBytes = $this->fileObj->getBytes(3); // preload with 3 Bytes $pos = false; do { $headerBytes .= $this->fileObj->getBytes(1); // load next Byte $headerBytes = substr($headerBytes, -4); // limit to 4 Bytes if (unpack('n', $headerBytes)[1] & self::FRAME_SYNC === self::FRAME_SYNC) { $pos = $this->fileObj->getFilePos() - 4; break; } } while ($this->fileObj->getFilePos() <= $headerSeekMax); if (!$pos) { throw new Exception('No Mpeg frame header found up until pos ' . $headerSeekMax . '(0x' . dechex($headerSeekMax).')!'); } switch (ord($headerBytes[1]) >> 3 & 0b11) { case 0b00: $this->codecVersion = self::MPEG_25; break; case 0b01: return null; break; case 0b10: $this->codecVersion = self::MPEG_2; break; case 0b11: $this->codecVersion = self::MPEG_1; break; } switch (ord($headerBytes[1]) >> 1 & 0b11) { case 0b01: $this->layerVersion = self::LAYER_3; break; case 0b10: $this->layerVersion = self::LAYER_2; break; case 0b11: $this->layerVersion = self::LAYER_1; break; } $this->bitRate = self::$_bitRateTable[$this->codecVersion][$this->layerVersion][ord($headerBytes[2]) >> 4]; $this->sampleRate = self::$_sampleRateTable[$this->codecVersion][(ord($headerBytes[2]) >> 2) & 0b11]; if ($this->sampleRate === false) { return null; } switch (ord($headerBytes[3]) >> 6) { case 0b00: $this->channel = self::STEREO; break; case 0b01: $this->channel = self::JOINT_STEREO; break; case 0b10: $this->channel = self::DUAL_MONO; break; case 0b11: $this->channel = self::MONO; break; } $vbr_offset = self::$_vbrOffsets[$this->codecVersion][$this->channel == self::MONO ? 0 : 1]; // check for VBR $this->fileObj->seekTo($pos + $vbr_offset); if ($this->fileObj->getBytes(4) == self::VBR_SYNC) { $this->isVbr = true; $flagsBytes = $this->fileObj->getBytes(4); // VBR frames count presence if ((ord($flagsBytes[3]) & 2)) { $this->vbrProperties['frames'] = implode(unpack('N', $this->fileObj->getBytes(4))); } // VBR stream size presence if (ord($flagsBytes[3]) & 4) { $this->vbrProperties['bytes'] = implode(unpack('N', $this->fileObj->getBytes(4))); } // VBR TOC presence if (ord($flagsBytes[3]) & 1) { $this->fileObj->seekForward(100); } // VBR quality if (ord($flagsBytes[3]) & 8) { $this->vbrProperties['quality'] = implode(unpack('N', $this->fileObj->getBytes(4))); } } // go to the end of frame if ($this->layerVersion == self::LAYER_1) { $this->_cbrFrameSize = floor((12 * $this->bitRate / $this->sampleRate + (ord($headerBytes[2]) >> 1 & 0b1)) * 4); } else { $this->_cbrFrameSize = floor(144 * $this->bitRate / $this->sampleRate + (ord($headerBytes[2]) >> 1 & 0b1)); } $this->fileObj->seekTo($pos + $this->_cbrFrameSize); return $this->vbrProperties['frames'] ?? null; } /** * Reads id3v1 tag. * @return int Returns length of id3v1 tag. */ private function readId3v1Body() { $this->tags1['song'] = trim($this->fileObj->getBytes(30)); $this->tags1['artist'] = trim($this->fileObj->getBytes(30)); $this->tags1['album'] = trim($this->fileObj->getBytes(30)); $this->tags1['year'] = trim($this->fileObj->getBytes(4)); $this->tags1['comment'] = trim($this->fileObj->getBytes(28)); $this->fileObj->seekForward(1); $this->tags1['track'] = ord($this->fileObj->getBytes(1)); $this->tags1['genre'] = ord($this->fileObj->getBytes(1)); return 128; } /** * Reads id3v2 tag. * ----------------------------------- * Overall tag header structure (10 bytes) * ID3v2/file identifier "ID3" (3 bytes) * ID3v2 version (2 bytes) * ID3v2 flags (1 byte) * ID3v2 size 4 * %0xxxxxxx (4 bytes) * ----------------------------------- * id3v2.2.0 tag header (10 bytes) * ID3/file identifier "ID3" (3 bytes) * ID3 version $02 00 (2 bytes) * ID3 flags %xx000000 (1 byte) * ID3 size 4 * %0xxxxxxx (4 bytes) * Flags: * x (bit 7) - unsynchronisation * x (bit 6) - compression * ----------------------------------- * id3v2.3.0 tag header (10 bytes) * ID3v2/file identifier "ID3" (3 bytes) * ID3v2 version $03 00 (2 bytes) * ID3v2 flags %abc00000 (1 byte) * ID3v2 size 4 * %0xxxxxxx (4 bytes) * Flags: * a - Unsynchronisation * b - Extended header * c - Experimental indicator * Extended header structure (10 bytes) * Extended header size $xx xx xx xx * Extended Flags $xx xx * Size of padding $xx xx xx xx * Extended flags: * %x0000000 00000000 * x - CRC data present * ----------------------------------- * id3v2.4.0 tag header (10 bytes) * ID3v2/file identifier "ID3" (3 bytes) * ID3v2 version $04 00 (2 bytes) * ID3v2 flags %abcd0000 (1 byte) * ID3v2 size 4 * %0xxxxxxx (4 bytes) * Flags: * a - Unsynchronisation * b - Extended header * c - Experimental indicator * d - Footer present * @param resource $fp * @return int Returns length of id3v2 tag. * @throws \Exception */ private function readId3v2Body() { // read the rest of the id3v2 header $raw = $this->fileObj->getBytes(3); $data = unpack('Cversion/Crevision/Cflags', $raw); $this->id3v2Version = $data['version']; $this->id3v2Revision = $data['revision']; // backwards compatibility: $this->id3v2MajorVersion = $data['version']; $this->id3v2MinorVersion = $data['revision']; $this->id3v2Flags = array(); if ($this->id3v2Version >= 2) { // parse id3v2.2.0 header flags $this->id3v2Flags['unsynchronisation'] = ($data['flags'] & 128 == 128); $this->id3v2Flags['compression'] = ($data['flags'] & 64 == 64); } if ($this->id3v2Version >= 3) { // id3v2.3 changes second bit from compression to extended_header $this->id3v2Flags['extended_header'] = &$this->id3v2Flags['compression']; unset($this->id3v2Flags['compression']); // parse additional id3v2.3.0 header flags $this->id3v2Flags['experimental_indicator'] = ($data['flags'] & 32 == 32); if ($this->id3v2Flags['extended_header']) { throw new Exception('NEED TO PARSE EXTENDED HEADER!'); } } if ($this->id3v2Version >= 4) { // parse additional id3v2.4.0 header flags $this->id3v2Flags['footer_present'] = ($data['flags'] & 16 == 16); if ($this->id3v2Flags['footer_present']) { // footer is a copy of header - so can be ignored // (10 Bytes not included in $size) //throw new Exception('NEED TO PARSE id3v2.4 FOOTER!'); } } $size = $this->getSynchsafeSize($this->fileObj->getBytes(4)); if ($this->id3v2Version == 2) { // parse id3v2.2.0 body /*throw new \Exception('NEED TO PARSE id3v2.2.0 flags!');*/ } elseif ($this->id3v2Version == 3) { // parse id3v2.3.0 body $this->parseId3v23Body(10 + $size); } elseif ($this->id3v2Version == 4) { // parse id3v2.4.0 body $this->parseId3v24Body(10 + $size); } return 10 + $size; // 10 bytes - header, rest - body } /** * Parses id3v2.3.0 tag body. * @todo Complete. */ protected function parseId3v23Body($lastByte) { while ($this->fileObj->getFilePos() < $lastByte) { $raw = $this->fileObj->getBytes(10); $frame_id = substr($raw, 0, 4); if ($frame_id == str_repeat(chr(0), 4)) { $this->fileObj->seekTo($lastByte); break; } $data = unpack('Nframe_size/H2flags', substr($raw, 4)); $frame_size = $data['frame_size']; $flags = base_convert($data['flags'], 16, 2); $this->id3v2TagsFlags[$frame_id] = array( 'payload_size' => $frame_size, 'flags' => array( 'tag_alter_preservation' => (bool)substr($flags, 0, 1), 'file_alter_preservation' => (bool)substr($flags, 1, 1), 'read_only' => (bool)substr($flags, 2, 1), 'compression' => (bool)substr($flags, 8, 1), 'encryption' => (bool)substr($flags, 9, 1), 'grouping_identity' => (bool)substr($flags, 10, 1), ), ); switch ($frame_id) { // case 'UFID': # Unique file identifier // break; ################# Text information frames case 'TALB': # Album/Movie/Show title case 'TCON': # Content type case 'TYER': # Year case 'TXXX': # User defined text information frame case 'TRCK': # Track number/Position in set case 'TIT2': # Title/songname/content description case 'TPE1': # Lead performer(s)/Soloist(s) case 'TBPM': # BPM (beats per minute) case 'TCOM': # Composer case 'TCOP': # Copyright message case 'TDAT': # Date case 'TDLY': # Playlist delay case 'TENC': # Encoded by case 'TEXT': # Lyricist/Text writer case 'TFLT': # File type case 'TIME': # Time case 'TIT1': # Content group description case 'TIT3': # Subtitle/Description refinement case 'TKEY': # Initial key case 'TLAN': # Language(s) case 'TLEN': # Length case 'TMED': # Media type case 'TOAL': # Original album/movie/show title case 'TOFN': # Original filename case 'TOLY': # Original lyricist(s)/text writer(s) case 'TOPE': # Original artist(s)/performer(s) case 'TORY': # Original release year case 'TOWN': # File owner/licensee case 'TPE2': # Band/orchestra/accompaniment case 'TPE3': # Conductor/performer refinement case 'TPE4': # Interpreted, remixed, or otherwise modified by case 'TPOS': # Part of a set case 'TPUB': # Publisher case 'TRDA': # Recording dates case 'TRSN': # Internet radio station name case 'TRSO': # Internet radio station owner case 'TSIZ': # Size case 'TSRC': # ISRC (international standard recording code) case 'TSSE': # Software/Hardware and settings used for encoding $this->tags2[$frame_id] = $this->handleTextFrame($frame_size, $this->fileObj->getBytes($frame_size)); break; ################# Text information frames ################# URL link frames // case 'WCOM': # Commercial information // break; // case 'WCOP': # Copyright/Legal information // break; // case 'WOAF': # Official audio file webpage // break; // case 'WOAR': # Official artist/performer webpage // break; // case 'WOAS': # Official audio source webpage // break; // case 'WORS': # Official internet radio station homepage // break; // case 'WPAY': # Payment // break; // case 'WPUB': # Publishers official webpage // break; // case 'WXXX': # User defined URL link frame // break; ################# URL link frames // case 'IPLS': # Involved people list // break; // case 'MCDI': # Music CD identifier // break; // case 'ETCO': # Event timing codes // break; // case 'MLLT': # MPEG location lookup table // break; // case 'SYTC': # Synchronized tempo codes // break; // case 'USLT': # Unsychronized lyric/text transcription // break; // case 'SYLT': # Synchronized lyric/text // break; case 'COMM': # Comments $dataEnd = $this->fileObj->getFilePos() + $frame_size; $raw = $this->fileObj->getBytes(4); $data = unpack('C1encoding/A3language', $raw); // read until \null character $short_description = ''; $last_null = false; $actual_text = false; while ($this->fileObj->getFilePos() < $dataEnd) { $char = $this->fileObj->getBytes(1); if ($char == "\00" && $actual_text === false) { if ($data['encoding'] == 0x1) { # two null-bytes for utf-16 if ($last_null) $actual_text = null; else $last_null = true; } else # no condition for iso-8859-1 $actual_text = null; } else if ($actual_text !== false) $actual_text .= $char; else $short_description .= $char; } if ($actual_text === false) $actual_text = $short_description; // list($short_description, $actual_text) = sscanf("s".chr(0)."s", $data['texts']); // list($short_description, $actual_text) = explode(chr(0), $data['texts']); $this->tags2[$frame_id][$data['language']] = array( 'short' => (bool)($data['encoding'] == 0x00) ? mb_convert_encoding($short_description, 'utf-8', 'iso-8859-1') : mb_convert_encoding($short_description, 'utf-8', 'utf-16'), 'actual' => (bool)($data['encoding'] == 0x00) ? mb_convert_encoding($actual_text, 'utf-8', 'iso-8859-1') : mb_convert_encoding($actual_text, 'utf-8', 'utf-16'), ); break; // case 'RVAD': # Relative volume adjustment // break; // case 'EQUA': # Equalization // break; // case 'RVRB': # Reverb // break; case 'APIC': # Attached picture $this->hasCover = true; $dataEnd = $this->fileObj->getFilePos() + $frame_size; $this->coverProperties = ['text_encoding' => ord($this->fileObj->getBytes(1))]; // fseek($fp, $frame_size - 4, SEEK_CUR); $this->coverProperties['mime_type'] = $this->readTextUntilNull($dataEnd); $this->coverProperties['picture_type'] = ord($this->fileObj->getBytes(1)); $this->coverProperties['description'] = $this->readTextUntilNull($dataEnd); $this->coverProperties['offset'] = $this->fileObj->getFilePos(); $this->coverProperties['size'] = $dataEnd - $this->fileObj->getFilePos(); $this->fileObj->seekTo($dataEnd); break; // case 'GEOB': # General encapsulated object // break; case 'PCNT': # Play counter $data = unpack('L', $this->fileObj->getBytes($frame_size)); $this->tags2[$frame_id] = $data[1]; break; // case 'POPM': # Popularimeter // break; // case 'RBUF': # Recommended buffer size // break; // case 'AENC': # Audio encryption // break; // case 'LINK': # Linked information // break; // case 'POSS': # Position synchronisation frame // break; // case 'USER': # Terms of use // break; // case 'OWNE': # Ownership frame // break; // case 'COMR': # Commercial frame // break; // case 'ENCR': # Encryption method registration // break; // case 'GRID': # Group identification registration // break; // case 'PRIV': # Private frame // break; default: $this->fileObj->seekForward($frame_size); break; } } } /** * Parses id3v2.4.0 tag body. * * @param $lastByte */ protected function parseId3v24Body($lastByte) { while ($this->fileObj->getFilePos() < $lastByte) { $frame_id = $this->fileObj->getBytes(4); if ($frame_id == str_repeat(chr(0), 4)) { $this->fileObj->seekTo($lastByte); break; } $frame_size = $this->getSynchsafeSize($this->fileObj->getBytes(4)); $data = unpack('H2flags', $this->fileObj->getBytes(2)); $flags = base_convert($data['flags'], 16, 2); $this->id3v2TagsFlags[$frame_id] = array( 'payload_size' => $frame_size, 'flags' => array( 'tag_alter_preservation' => (bool)substr($flags, 1, 1), 'file_alter_preservation' => (bool)substr($flags, 2, 1), 'read_only' => (bool)substr($flags, 3, 1), 'grouping_identity' => (bool)substr($flags, 9, 1), 'compression' => (bool)substr($flags, 12, 1), 'encryption' => (bool)substr($flags, 13, 1), 'unsynchronisation' => (bool)substr($flags, 14, 1), 'data_length_indicator' => (bool)substr($flags, 15, 1), ), ); switch ($frame_id) { // case 'UFID': # Unique file identifier // break; ################# Text information frames case 'TALB': # Album/Movie/Show title case 'TCON': # Content type case 'TYER': # Year case 'TRCK': # Track number/Position in set case 'TIT2': # Title/songname/content description case 'TPE1': # Lead performer(s)/Soloist(s) case 'TBPM': # BPM (beats per minute) case 'TCOM': # Composer case 'TCOP': # Copyright message case 'TDAT': # Date case 'TDRC': # Recording time case 'TDLY': # Playlist delay case 'TENC': # Encoded by case 'TEXT': # Lyricist/Text writer case 'TFLT': # File type case 'TIME': # Time case 'TIT1': # Content group description case 'TIT3': # Subtitle/Description refinement case 'TKEY': # Initial key case 'TLAN': # Language(s) case 'TLEN': # Length case 'TMED': # Media type case 'TOAL': # Original album/movie/show title case 'TOFN': # Original filename case 'TOLY': # Original lyricist(s)/text writer(s) case 'TOPE': # Original artist(s)/performer(s) case 'TORY': # Original release year case 'TOWN': # File owner/licensee case 'TPE2': # Band/orchestra/accompaniment case 'TPE3': # Conductor/performer refinement case 'TPE4': # Interpreted, remixed, or otherwise modified by case 'TPOS': # Part of a set case 'TPUB': # Publisher case 'TRDA': # Recording dates case 'TRSN': # Internet radio station name case 'TRSO': # Internet radio station owner case 'TSIZ': # Size case 'TSRC': # ISRC (international standard recording code) case 'TSSE': # Software/Hardware and settings used for encoding $this->tags2[$frame_id] = $this->handleTextFrame($frame_size, $this->fileObj->getBytes($frame_size)); break; case 'TXXX': # User defined text information frame $dataEnd = $this->fileObj->getFilePos() + $frame_size; $encoding = ord($this->fileObj->getBytes(1)); $description_raw = $this->readTextUntilNull($dataEnd); $description = $this->_getUtf8Text($encoding, $description_raw); $value = $this->fileObj->getBytes($dataEnd - $this->fileObj->getFilePos()); $tagName = $frame_id . ':' . $description; if (key_exists($tagName, $this->tags2)) { // this should never happen! TXXX-description must be unique. if (!is_array($this->tags2[$tagName])) { $this->tags2[$tagName] = array($this->tags2[$tagName]); } $this->tags2[$tagName][] = $value; } else { $this->tags2[$tagName] = $value; } break; ################# Text information frames ################# URL link frames // case 'WCOM': # Commercial information // break; // case 'WCOP': # Copyright/Legal information // break; // case 'WOAF': # Official audio file webpage // break; // case 'WOAR': # Official artist/performer webpage // break; // case 'WOAS': # Official audio source webpage // break; // case 'WORS': # Official internet radio station homepage // break; // case 'WPAY': # Payment // break; // case 'WPUB': # Publishers official webpage // break; // case 'WXXX': # User defined URL link frame // break; ################# URL link frames // case 'IPLS': # Involved people list // break; // case 'MCDI': # Music CD identifier // break; // case 'ETCO': # Event timing codes // break; // case 'MLLT': # MPEG location lookup table // break; // case 'SYTC': # Synchronized tempo codes // break; // case 'USLT': # Unsychronized lyric/text transcription // break; // case 'SYLT': # Synchronized lyric/text // break; case 'COMM': # Comments $dataEnd = $this->fileObj->getFilePos() + $frame_size; $encoding = unpack('C', $this->fileObj->getBytes(1))[1]; $language = $this->fileObj->getBytes(3); $allText_raw = $this->fileObj->getBytes($dataEnd - $this->fileObj->getFilePos()); $allText = $this->_getUtf8Text($encoding, $allText_raw); list($short_description, $actual_text) = explode("\0", $allText, 2); $this->tags2[$frame_id][$language] = array( 'short' => $short_description, 'actual' => $actual_text, ); break; // case 'RVAD': # Relative volume adjustment // break; // case 'EQUA': # Equalization // break; // case 'RVRB': # Reverb // break; case 'APIC': # Attached picture $this->hasCover = true; $dataEnd = $this->fileObj->getFilePos() + $frame_size; $this->coverProperties = ['text_encoding' => ord($this->fileObj->getBytes(1))]; // $this->fileObj->seekForward($frame_size - 4); $this->coverProperties['mime_type'] = $this->readTextUntilNull($dataEnd); $this->coverProperties['picture_type'] = ord($this->fileObj->getBytes(1)); $this->coverProperties['description'] = $this->readTextUntilNull($dataEnd); $this->coverProperties['offset'] = $this->fileObj->getFilePos(); $this->coverProperties['size'] = $dataEnd - $this->fileObj->getFilePos(); $this->fileObj->seekTo($dataEnd); break; // case 'GEOB': # General encapsulated object // break; case 'PCNT': # Play counter $data = unpack('L', $this->fileObj->getBytes($frame_size)); $this->tags2[$frame_id] = $data[1]; break; // case 'POPM': # Popularimeter // break; // case 'RBUF': # Recommended buffer size // break; // case 'AENC': # Audio encryption // break; // case 'LINK': # Linked information // break; // case 'POSS': # Position synchronisation frame // break; // case 'USER': # Terms of use // break; // case 'OWNE': # Ownership frame // break; // case 'COMR': # Commercial frame // break; // case 'ENCR': # Encryption method registration // break; // case 'GRID': # Group identification registration // break; // case 'PRIV': # Private frame // break; default: $this->fileObj->seekForward($frame_size); break; } } } /** * Converts text encoding according to ID3 indicator * * @param int $encoding Encoding ID from ID3 frame * @param string $rawText Raw text from ID3 frame * * @return string */ private function _getUtf8Text(int $encoding, ?string $rawText): string { if (is_null($rawText)) { $rawText = ''; } switch ($encoding) { case 0x00: // ISO-8859-1 return mb_convert_encoding($rawText, 'utf-8', 'iso-8859-1'); case 0x01: // UTF-16 with BOM return mb_convert_encoding($rawText . "\00", 'utf-8', 'utf-16'); // Following is for id3v2.4.x only case 0x02: // UTF-16 without BOM return mb_convert_encoding($rawText . "\00", 'utf-8', 'utf-16'); case 0x03: // UTF-8 return $rawText; default: throw new RuntimeException('Unknown text encoding type: ' . $encoding); } } /** * @param $frameSize * @param $raw * * @return string */ private function handleTextFrame($frameSize, $raw) { $data = unpack('C1encoding/A' . ($frameSize - 1) . 'information', $raw); return $this->_getUtf8Text($data['encoding'], $data['information']); } /** * @param resource $fp * @param int $dataEnd * @return string|null */ private function readTextUntilNull($dataEnd) { $text = null; while ($this->fileObj->getFilePos() < $dataEnd) { $char = $this->fileObj->getBytes(1); if ($char === "\00") { return $text; } $text .= $char; } return $text; } /** * Fills `tags` property with values id3v2 and id3v1 tags. */ protected function fillTags() { foreach ([ 'song' => 'TIT2', 'artist' => 'TPE1', 'album' => 'TALB', 'year' => 'TYER', 'comment' => 'COMM', 'track' => 'TRCK', 'genre' => 'TCON', ] as $tag => $id3v2_tag) { if (!isset($this->tags2[$id3v2_tag]) && (!isset($this->tags1[$tag]) || empty($this->tags1[$tag]))) continue; $this->tags[$tag] = isset($this->tags2[$id3v2_tag]) ? ($id3v2_tag === 'COMM' ? current($this->tags2[$id3v2_tag])['actual'] : $this->tags2[$id3v2_tag]) : $this->tags1[$tag]; } } /** * Simple function that checks mpeg-audio correctness of given file. * Actually it checks that first 3 bytes of file is a id3v2 tag mark or * that first 11 bits of file is a frame header sync mark or that 3 bytes on -128 position of file is id3v1 tag. * To perform full test create an instance of Mp3Info with given file. * * @param string $filename File to be tested. * @return boolean True if file looks that correct mpeg audio, False otherwise. * @throws \Exception */ public static function isValidAudio($filename) { if (str_contains($filename, '://')) { $fileObj = new Mp3FileRemote($filename); } else { if (!file_exists($filename)) { throw new Exception('File ' . $filename . ' is not present!'); } $fileObj = new Mp3FileLocal($filename); } $filesize = $fileObj->getFileSize(); $raw = $fileObj->getBytes(3); if ($raw === self::TAG2_SYNC) { // id3v2 tag return true; } if (unpack('n', $raw)[1] & self::FRAME_SYNC === self::FRAME_SYNC) { // mpeg header tag return true; } if ($filesize > 128) { $fileObj->seekTo($filesize - 128); if ($fileObj->getBytes(3) === self::TAG1_SYNC) { // id3v1 tag return true; } } return false; } }