diff --git a/extract-playlist.php b/extract-playlist.php index ad2d05d..8d5f6e7 100644 --- a/extract-playlist.php +++ b/extract-playlist.php @@ -394,10 +394,203 @@ function extractFragmentedMP4( $filename ) { return $segments; } -function consolidate( $target, $segments ) { - $out = [ - 'init' => $segments['init'], +// http://www.mp3-tech.org/programmer/frame_header.html + +class MP3FrameHeader { + private $header; + + public $valid = false; + public $size = 0; + + public $sync; + public $mpeg; + public $layer; + public $protection; + public $bitrate; + public $sampleRate; + public $padding; + + private static $bits = [ + 'sync' => [ 21, 11 ], + 'mpeg' => [ 19, 2 ], + 'layer' => [ 17, 2 ], + 'protection' => [ 16, 1 ], + 'bitrate' => [ 12, 4 ], + 'sampleRate' => [ 10, 2 ], + 'padding' => [ 9, 1 ], + 'private' => [ 8, 1 ], // not needed below this + 'channelMode' => [ 6, 2 ], + 'modeExt' => [ 4, 2 ], + 'copyright' => [ 3, 1 ], + 'original' => [ 2, 1 ], + 'emphasis' => [ 0, 2 ], ]; + + private const SYNC_MASK = 0x7ff; + + // 1s used for reserved slots to avoid exploding + // in case of invalid input + private static $sampleRates = [ + // MPEG-2.5 + [ 11025, 12000, 8000, 1 ], + // Reserved + [ 1, 1, 1, 1 ], + // MPEG-2 + [ 22050, 24000, 16000, 1 ], + // MPEG-1 + [ 44100, 48000, 32000, 1 ], + ]; + + private static $bitrates = [ + [ + // MPEG-2 + // invalid + [ 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 ], + // layer 2 + [ 0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160, 0 ], + // layer 1 + [ 0, 32, 48, 56, 64, 80, 96, 112, 128, 144, 160, 176, 192, 224, 256, 0 ], + ], + [ + // MPEG-1 + // invalid + [ 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 ], + // layer 2 + [ 0, 32, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 384, 0 ], + // layer 1 + [ 0, 32, 64, 96, 128, 160, 192, 224, 256, 288, 320, 352, 384, 316, 448, 0 ], + ] + ]; + + private function field( $name ) { + [ $shift, $bits ] = self::$bits[$name]; + $mask = ( 1 << $bits ) - 1; + return ( $this->header >> $shift ) & $mask; + } + + public function __construct( $header ) { + $this->header = $header; + + $this->sync = $this->field( 'sync' ) == self::SYNC_MASK; + if ( $this->sync ) { + $this->mpeg = $this->field( 'mpeg' ); + $this->layer = $this->field( 'layer' ); + $this->protection = $this->field( 'protection' ); + $br = $this->field( 'bitrate' ); + $this->bitrate = 1000 * self::$bitrates[$this->mpeg & 1][$this->layer][$br]; + $sr = $this->field( 'sampleRate' ); + $this->sampleRate = self::$sampleRates[$this->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->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 ); + if ( $this->protection ) { + $this->size += 2; + } + if ( $this->padding ) { + $this->size++; + } + } + } +} + +class MP3Reader { + + private $file; + + public function __construct( $file ) { + $this->file = $file; + } + + public function pos() { + return ftell( $this->file ); + } + + public function readFrame() { + while ( true ) { + // Look for the sync pattern + $start = $this->pos(); + $bytes = fread( $this->file, 4 ); + if ( $bytes === false || strlen( $bytes ) < 4 ) { + // end of file + return false; + } + + $data = unpack( "Nval", $bytes ); + $header = new MP3FrameHeader( $data['val'] ); + if ( $header->valid ) { + // 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; + } + + $x = hexdump( $bytes ); + print "BACKUP $x\n"; + + if ( $header->sync && !$header->valid ) { + $x = hexdump( $bytes ); + die('xxx ' . $x); + } + + // back up 3 bytes and try again + fseek( $this->file, $start + 1, SEEK_SET ); + } + } +} + +function extractMP3( $filename ) { + $file = fopen( $filename, 'rb' ); + if ( !$file ) { + throw new Exception( 'Error opening MP3 file' ); + } + try { + $reader = new MP3Reader( $file ); + $segments = []; + $timestamp = 0.0; + while ( true ) { + $start = $reader->pos(); + $frame = $reader->readFrame(); + if ( !$frame ) { + return $segments; + } + $duration = 144.0 / $frame->sampleRate; + $segments[] = [ + 'start' => $start, + 'size' => $frame->size, + 'timestamp' => $timestamp, + 'duration' => $duration, + ]; + $timestamp += $duration; + } + } finally { + fclose( $file ); + } +} + +function consolidate( $target, $segments ) { + $out = []; + if ( isset( $segments['init'] ) ) { + $out['init'] = $segments['init']; + } + $n = count( $segments ) - 1; $start = $segments[0]['start']; $size = $segments[0]['size']; @@ -492,8 +685,17 @@ function playlist( $filename, $segments ) { $argv = $_SERVER['argv']; $self = array_shift( $argv ); $filename = array_shift( $argv ); -$segments = extractFragmentedMP4( $filename ); -$segments = consolidate( 10, $segments ); +$target = 10; + +$ext = substr( $filename, strrpos( $filename, '.' ) ); +if ( $ext === '.mp3' ) { + $segments = extractMP3( $filename ); +} elseif ( $ext === '.mp4' ) { + $segments = extractFragmentedMP4( $filename ); +} else { + die( "Unexpected file extension $ext\n" ); +} +$segments = consolidate( $target, $segments ); /* foreach ( $segments as $key => $segment ) {