diff --git a/extract-playlist.php b/extract-playlist.php index 8d5f6e7..7be2dea 100644 --- a/extract-playlist.php +++ b/extract-playlist.php @@ -401,6 +401,8 @@ class MP3FrameHeader { public $valid = false; public $size = 0; + public $samples = 0; + public $duration = 0.0; public $sync; public $mpeg; @@ -428,6 +430,33 @@ class MP3FrameHeader { private const SYNC_MASK = 0x7ff; + private static $versions = [ + 'MPEG-2.5', + 'reserved', + 'MPEG-2', + 'MPEG-1', + ]; + + private static $layers = [ + 'reserved', + 'III', + 'II', + 'I', + ]; + + private static $samplesPerFrame = [ + // invalid / layer 3 / 2 / 1 + + // MPEG-2.5 + [ 0, 576, 1152, 384 ], + // Reserved + [ 0, 0, 0, 0 ], + // MPEG-2 + [ 0, 576, 1152, 384 ], + // MPEG-1 + [ 0, 1152, 384, 384 ], + ]; + // 1s used for reserved slots to avoid exploding // in case of invalid input private static $sampleRates = [ @@ -442,9 +471,9 @@ class MP3FrameHeader { ]; private static $bitrates = [ + // MPEG-2 [ - // MPEG-2 - // invalid + // invalid layer [ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, ], // layer 3 [ 0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160, 0 ], @@ -453,9 +482,9 @@ class MP3FrameHeader { // layer 1 [ 0, 32, 48, 56, 64, 80, 96, 112, 128, 144, 160, 176, 192, 224, 256, 0 ], ], + // MPEG-1 [ - // MPEG-1 - // invalid + // invalid layer [ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, ], // layer 3 [ 0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160, 192, 224, 256, 320, 0 ], @@ -475,31 +504,47 @@ class MP3FrameHeader { public function __construct( $header ) { $this->header = $header; - $this->sync = $this->field( 'sync' ) == self::SYNC_MASK; + $sync = $this->field( 'sync' ); + $this->sync = $sync == self::SYNC_MASK; if ( $this->sync ) { - $this->mpeg = $this->field( 'mpeg' ); - $this->layer = $this->field( 'layer' ); - $this->protection = $this->field( 'protection' ); + $mpeg = $this->field( 'mpeg' ); + $this->mpeg = self::$versions[$mpeg]; + + $layer = $this->field( 'layer' ); + $this->layer = self::$layers[$layer]; + + $protection = $this->field( 'protection' ); + $this->protection = !$protection; + $br = $this->field( 'bitrate' ); - $this->bitrate = 1000 * self::$bitrates[$this->mpeg & 1][$this->layer][$br]; + $this->bitrate = 1000 * self::$bitrates[$mpeg & 1][$layer][$br]; + $sr = $this->field( 'sampleRate' ); - $this->sampleRate = self::$sampleRates[$this->mpeg][$sr]; + $this->sampleRate = self::$sampleRates[$mpeg][$sr]; + $this->padding = $this->field( 'padding' ); - $this->valid = $this->sync; - if ( $this->bitrate == 0 ) { - var_dump( $this ); - echo "br: $br\n"; - //throw new Exception( "Invalid bitrate" ); - $this->valid = false; + if ( $this->sync ) { + if ( $this->bitrate == 0 ) { + $this->valid = false; + var_dump( $this ); + echo "br: $br\n"; + throw new Exception( "Invalid bitrate" ); + } + if ( $this->sampleRate == 1 ) { + $this->valid = false; + var_dump( $this ); + echo "sr: $sr\n"; + throw new Exception( "Invalid sample rate" ); + } + $this->valid = true; } - if ( $this->sampleRate == 1 ) { - var_dump( $this ); - echo "sr: $sr\n"; - //throw new Exception( "Invalid sample rate" ); - $this->valid = false; - } - $this->size = intval( 144.0 * $this->bitrate / $this->sampleRate ); + + $this->samples = self::$samplesPerFrame[$mpeg][$layer]; + $this->duration = $this->samples / $this->sampleRate; + $nbits = $this->duration * $this->bitrate; + $nbytes = $nbits / 8; + $this->size = intval( $nbytes ); if ( $this->protection ) { $this->size += 2; } @@ -513,6 +558,7 @@ class MP3FrameHeader { class MP3Reader { private $file; + private $timestamp = 0.0; public function __construct( $file ) { $this->file = $file; @@ -524,33 +570,60 @@ class MP3Reader { public function readFrame() { while ( true ) { - // Look for the sync pattern $start = $this->pos(); - $bytes = fread( $this->file, 4 ); - if ( $bytes === false || strlen( $bytes ) < 4 ) { + $lookahead = 10; + $bytes = fread( $this->file, $lookahead ); + if ( $bytes === false || strlen( $bytes ) < $lookahead ) { // end of file return false; } + // Check for MP3 frame header sync pattern $data = unpack( "Nval", $bytes ); $header = new MP3FrameHeader( $data['val'] ); - if ( $header->valid ) { + if ( $header->sync ) { + $segments[] = [ + 'start' => $start, + 'size' => $header->size, + 'timestamp' => $this->timestamp, + 'duration' => $header->duration, + ]; + $this->timestamp += $header->duration; // Note we don't need the data at this time. - //fseek( $this->file, $start + $header->size, SEEK_SET ); - var_dump ( $header ); - fread( $this->file, $header->size - 4 ); - return $header; + fseek( $this->file, $start + $header->size, SEEK_SET ); + continue; } - $x = hexdump( $bytes ); - print "BACKUP $x\n"; - - if ( $header->sync && !$header->valid ) { - $x = hexdump( $bytes ); - die('xxx ' . $x); + // ID3v2.3 + // https://web.archive.org/web/20081008034714/http://www.id3.org/id3v2.3.0 + // ID3v2/file identifier "ID3" + // ID3v2 version $03 00 + // ID3v2 flags %abc00000 + // ID3v2 size 4 * %0xxxxxxx + $id3 = unpack( "a3tag/nversion/Cflags/C4size", $bytes ); + if ( $id3['tag'] === 'ID3' ) { + $size = $lookahead + + ( $id3['size4'] | + ( $id3['size3'] << 7) | + ( $id3['size2'] << 14) | + ( $id3['size1'] << 21) ); + // For byte range purposes; count as zero duration + $segments[] = [ + 'start' => $start, + 'size' => $size, + 'timestamp' => $this->timestamp, + 'duration' => 0.0, + ]; + fseek( $this->file, $start + $size, SEEK_SET ); + continue; } + + $hex = hexdump( $bytes ); + $safe = safestr ( $bytes ); + var_dump($segments); + throw new Exception("Invalid packet at $start? $hex $safe"); - // back up 3 bytes and try again + // back up and try again fseek( $this->file, $start + 1, SEEK_SET ); } } @@ -567,19 +640,13 @@ function extractMP3( $filename ) { $timestamp = 0.0; while ( true ) { $start = $reader->pos(); - $frame = $reader->readFrame(); - if ( !$frame ) { + $segment = $reader->readFrame(); + if ( !$segment ) { return $segments; } - $duration = 144.0 / $frame->sampleRate; - $segments[] = [ - 'start' => $start, - 'size' => $frame->size, - 'timestamp' => $timestamp, - 'duration' => $duration, - ]; - $timestamp += $duration; + $segments[] = $segment; } + return $segments; } finally { fclose( $file ); } @@ -590,6 +657,9 @@ function consolidate( $target, $segments ) { if ( isset( $segments['init'] ) ) { $out['init'] = $segments['init']; } + if ( count( $segments ) < 2 ) { + return $segments; + } $n = count( $segments ) - 1; $start = $segments[0]['start'];