diff --git a/HLS/MP3Segmenter.php b/HLS/MP3Segmenter.php new file mode 100644 index 0000000..dbc1208 --- /dev/null +++ b/HLS/MP3Segmenter.php @@ -0,0 +1,405 @@ + [ 21, 11 ], + 'mpeg' => [ 19, 2 ], + 'layer' => [ 17, 2 ], + 'protection' => [ 16, 1 ], + 'bitrate' => [ 12, 4 ], + 'sampleRate' => [ 10, 2 ], + 'padding' => [ 9, 1 ], + // below this not needed at present + 'private' => [ 8, 1 ], + 'channelMode' => [ 6, 2 ], + 'modeExt' => [ 4, 2 ], + 'copyright' => [ 3, 1 ], + 'original' => [ 2, 1 ], + 'emphasis' => [ 0, 2 ], + ]; + + /** + * 11-bit sync mask for MP3 frame header + * @var int + */ + private const SYNC_MASK = 0x7ff; + + /** + * Map of sample count per frame based on version/mode + * This is just in case we need to measure non-default sample rates! + * @var array + */ + 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 ], + ]; + + /** + * Map of sample rates based on version/mode + * @var array + */ + 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 ], + ]; + + /** + * Map of bit rates based on version/mode/code + * @var array + */ + private static $bitrates = [ + // MPEG-2 + [ + // 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 ], + // 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 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 ], + // 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 ], + ] + ]; + + /** + * Timestamp resolution for HLS ID3 timestamp tags + */ + private const KHZ_90 = 90000; + + /** + * Decode a binary field from the MP3 frame header + */ + private static function field( string $name, int $header ): int { + [ $shift, $bits ] = self::$bits[$name]; + $mask = ( 1 << $bits ) - 1; + return ( $header >> $shift ) & $mask; + } + + /** + * Decode an MP3 header bitfield + */ + private static function frameHeader( string $bytes ): ?array { + if ( strlen( $bytes ) < 4 ) { + return null; + } + $data = unpack( "Nval", $bytes ); + $header = $data['val']; + + // This includes "MPEG 2.5" support, so checks for 11 set bits + // not 12 set bits as per original MPEG 1/2 + $sync = self::field( 'sync', $header ); + if ( $sync !== self::SYNC_MASK ) { + return null; + } + + $mpeg = self::field( 'mpeg', $header ); + $layer = self::field( 'layer', $header ); + $protection = self::field( 'protection', $header ); + + $br = self::field( 'bitrate', $header ); + $bitrate = 1000 * self::$bitrates[$mpeg & 1][$layer][$br]; + if ( $bitrate == 0 ) { + return null; + } + + $sr = self::field( 'sampleRate', $header ); + $sampleRate = self::$sampleRates[$mpeg][$sr]; + if ( $sampleRate == 1 ) { + return null; + } + + $padding = self::field( 'padding', $header ); + + $samples = self::$samplesPerFrame[$mpeg][$layer]; + $duration = $samples / $sampleRate; + $nbits = $duration * $bitrate; + $nbytes = $nbits / 8; + $size = intval( $nbytes ); + + if ( $protection == 0 ) { + $size += 2; + } + if ( $padding == 1 ) { + $size++; + } + + return [ + 'samples' => $samples, + 'sampleRate' => $sampleRate, + 'size' => $size, + 'duration' => $duration, + ]; + } + + private static function id3Header( string $bytes ): ?array { + // 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 + $headerLen = 10; + if ( strlen( $bytes ) < $headerLen ) { + return null; + } + + $data = unpack( "a3tag/nversion/Cflags/C4size", $bytes ); + if ( $data['tag'] !== 'ID3' ) { + return null; + } + + $size = $headerLen + + ( $data['size4'] | + ( $data['size3'] << 7 ) | + ( $data['size2'] << 14 ) | + ( $data['size1'] << 21 ) ); + return [ + 'size' => $size, + ]; + } + + protected function parse(): void { + $file = fopen( $this->filename, 'rb' ); + $stream = new OwningStreamReader( $file ); + + $timestamp = 0.0; + while ( true ) { + $start = $stream->pos(); + $lookahead = 10; + $bytes = $stream->read( $lookahead ); + if ( $bytes === null ) { + // end of file + break; + } + + // Check for MP3 frame header sync pattern + $header = self::frameHeader( $bytes ); + if ( $header ) { + // Note we don't need the data at this time. + $stream->seek( $start + $header['size'] ); + $timestamp += $header['duration']; + $this->segments[] = [ + 'start' => $start, + 'size' => $header['size'], + 'timestamp' => $timestamp, + 'duration' => $header['duration'], + ]; + continue; + } + + // Check for ID3v2 tag + $id3 = self::id3Header( $bytes ); + if ( $id3 ) { + // For byte range purposes; count as zero duration + $stream->seek( $start + $id3['size'] ); + $this->segments[] = [ + 'start' => $start, + 'size' => $id3['size'], + 'timestamp' => $timestamp, + 'duration' => 0.0, + ]; + continue; + } + + throw new Exception( "Not a valid MP3 or ID3 frame at $start" ); + } + } + + /** + * Rewrite the file to include ID3 private tags with timestamp + * data for HLS at segment boundaries. This will modify the file + * in-place and change the segment offsets and sizes in the object. + */ + public function rewrite(): void { + $offset = 0; + $id3s = []; + $segments = []; + foreach ( $this->segments as $i => $orig ) { + $id3 = self::timestampTag( $orig['timestamp'] ); + $delta = strlen( $id3 ); + $id3s[$i] = $id3; + $segments[$i] = [ + 'start' => $orig['start'] + $offset, + 'size' => $orig['size'] + $delta, + 'timestamp' => $orig['timestamp'], + 'duration' => $orig['duration'], + ]; + $offset += $delta; + } + + $file = fopen( $this->filename, 'rw+b' ); + $stream = new OwningStreamReader( $file ); + + echo "old: "; + var_dump( $this->segments ); + + echo "new: "; + var_dump( $segments ); + + // Move each segment forward, starting at the lastmost to work in-place. + $preserveKeys = true; + foreach ( array_reverse( $this->segments, $preserveKeys ) as $i => $orig ) { + $stream->seek( $orig['start'] ); + $bytes = $stream->readExactly( $orig['size'] ); + + $stream->seek( $segments[$i]['start'] ); + $stream->write( $id3s[$i] ); + $stream->write( $bytes ); + } + + $this->segments = $segments; + } + + /** + * Generate an ID3 private tag with a timestamp for use in HLS + * streams of raw media data such as MP3 or AAC. + */ + protected static function timestampTag( float $timestamp ): string { + /* + PRIV frame type + + should contain: + + The ID3 PRIV owner identifier MUST be + "com.apple.streaming.transportStreamTimestamp". The ID3 payload MUST + be a 33-bit MPEG-2 Program Elementary Stream timestamp expressed as a + big-endian eight-octet number, with the upper 31 bits set to zero. + Clients SHOULD NOT play Packed Audio Segments without this ID3 tag. + + https://id3.org/id3v2.4.0-frames + https://id3.org/id3v2.4.0-structure + + bit order is MSB first, big-endian + + header 10 bytes + extended header (var, optional) + frames (variable) + pading (variable, optional) + footer (10 bytes, optional) + + + header: + "ID3" + version: 16 bits $04 00 + flags: 32 bits + idv2 size: 32 bits (in chunks of 4 bytes, not counting header or footer) + + flags: + bit 7 - unsyncrhonization (??) + bit 6 - extended header + bit 5 - experimental indicator + bit 4 - footer present + + frame: + id - 32 bits (four chars) + size - 32 bits (in chunks of 4 bytes, excluding frame header) + flags - 16 bits + (frame data) + + priv payload: + owner text string followed by \x00 + (binary data) + + The timestamps... I think... have 90 kHz integer resolution + so convert from the decimal seconds in the HLS + + */ + + $owner = "com.apple.streaming.transportStreamTimestamp\x00"; + $pts = round( $timestamp * self::KHZ_90 ); + $thirtyThreeBits = pow( 2, 33 ); + $thirtyOneBits = pow( 2, 31 ); + if ( $pts >= $thirtyThreeBits ) { + // make sure they won't get too big for 33 bits + // this allows about a 24 hour media length + throw new Exception( "Timestamp overflow in MP3 output stream: $pts >= $thirtyThreeBits" ); + } + $pts_high = intval( floor( $pts / $thirtyOneBits ) ); + $pts_low = intval( $pts - ( $pts_high * $thirtyOneBits ) ); + + // Private frame payload + $frame_data = pack( + 'a*NN', + $owner, + $pts_high, + $pts_low, + ); + + // Private frame header + $frame_type = 'PRIV'; + $frame_flags = 0; + $frame_length = strlen( $frame_data ); + if ( $frame_length > 127 ) { + throw new Error( "Should never happen: too large ID3 frame data" ); + } + $frame = pack( + 'a4Nna*', + $frame_type, + $frame_length, + $frame_flags, + $frame_data + ); + + // ID3 tag + $tag_type = 'ID3'; + $tag_version = 0x0400; + $tag_flags = 0; + // if >127 bytes may need to adjust + $tag_length = strlen( $frame ); + if ( $tag_length > 127 ) { + throw new Error( "Should never happen: too large ID3 tag" ); + } + $tag = pack( + 'a3nCNa*', + $tag_type, + $tag_version, + $tag_flags, + $tag_length, + $frame + ); + + return $tag; + } +} diff --git a/HLS/OwningStreamReader.php b/HLS/OwningStreamReader.php new file mode 100644 index 0000000..f64c9a9 --- /dev/null +++ b/HLS/OwningStreamReader.php @@ -0,0 +1,21 @@ +file ) { + fclose( $this->file ); + $this->file = null; + } + } +} diff --git a/HLS/Segmenter.php b/HLS/Segmenter.php new file mode 100644 index 0000000..049812a --- /dev/null +++ b/HLS/Segmenter.php @@ -0,0 +1,155 @@ +filename = $filename; + if ( $segments ) { + $this->segments = $segments; + } else { + $this->segments = []; + $this->parse(); + } + } + + /** + * Fill the segments from the underlying file + */ + abstract protected function parse(): void; + + /** + * Consolidate adjacent segments to approach the target segment length. + */ + public function consolidate( float $target ): void { + $out = []; + $n = count( $this->segments ); + $init = $this->segments['init'] ?? false; + if ( $init ) { + $n--; + $out['init'] = $init; + } + if ( $n < 2 ) { + return; + } + + $first = $this->segments[0]; + $start = $first['start']; + $size = $first['size']; + $timestamp = $first['timestamp']; + $duration = $first['duration']; + + $i = 1; + while ( $i < $n ) { + // Append segments until we get close + while ( $i < $n - 1 && $duration < $target ) { + $segment = $this->segments[$i]; + $total = $duration + $segment['duration']; + if ( $total >= $target ) { + $after = $total - $target; + $before = $target - $duration; + if ( $before < $after ) { + // Break segment early + break; + } + } + $duration += $segment['duration']; + $size += $segment['size']; + $i++; + } + + // Save out a segment + $out[] = [ + 'start' => $start, + 'size' => $size, + 'timestamp' => $timestamp, + 'duration' => $duration, + ]; + + if ( $i < $n ) { + $segment = $this->segments[$i]; + $start = $segment['start']; + $size = $segment['size']; + $timestamp = $segment['timestamp']; + $duration = $segment['duration']; + $i++; + } + } + $out[] = [ + 'start' => $start, + 'size' => $size, + 'timestamp' => $timestamp, + 'duration' => $duration, + ]; + $this->segments = $out; + } + + /** + * Modify the media file and segments in-place to insert any + * tweaks needed for the file to stream correctly. + * + * This is used by MP3Segmenter to insert ID3 timestamps. + */ + public function rewrite(): void { + // no-op in default; fragmented .mp4 can be left as-is + } + + public function playlist( float $target, string $filename ): string { + $lines = []; + $lines[] = "#EXTM3U"; + $lines[] = "#EXT-X-VERSION:7"; + $lines[] = "#EXT-X-TARGETDURATION:$target"; + $lines[] = "#EXT-MEDIA-SEQUENCE:0"; + $lines[] = "#EXT-PLAYLIST-TYPE:VOD"; + + $url = urlencode( $filename ); + + $init = $this->segments['init'] ?? false; + if ( $init ) { + $lines[] = "#EXT-X-MAP:URI=\"{$url}\",BYTERANGE=\"{$init['size']}@{$init['start']}\""; + } + + $n = count( $this->segments ) - 1; + for ( $i = 0; $i < $n; $i++ ) { + $segment = $this->segments[$i]; + $lines[] = "#EXTINF:{$segment['duration']},"; + $lines[] = "#EXT-X-BYTERANGE:{$segment['size']}@{$segment['start']}"; + $lines[] = "{$url}"; + } + + $lines[] = "#EXT-X-ENDLIST"; + + return implode( "\n", $lines ); + } + + public static function segment( string $filename ): Segmenter { + $ext = strtolower( substr( $filename, strrpos( $filename, '.' ) ) ); + switch ( $ext ) { + case '.mp3': + return new MP3Segmenter( $filename ); + case '.mp4': + case '.m4v': + case '.m4a': + case '.mov': + case '.3gp': + return new MP4Segmenter( $filename ); + default: + throw new Exception( "Unexpected streaming file extension $ext" ); + } + } +} diff --git a/HLS/StreamReader.php b/HLS/StreamReader.php new file mode 100644 index 0000000..b64a4d9 --- /dev/null +++ b/HLS/StreamReader.php @@ -0,0 +1,104 @@ +file = $file; + $this->pos = $this->tell(); + } + + private function tell(): int { + return ftell( $this->file ); + } + + public function pos(): int { + return $this->pos; + } + + /** + * Seek to given absolute file position. + * @throws Exception on error + */ + public function seek( int $pos ): void { + $this->pos = intval( $pos ); + + if ( $this->pos === $this->tell() ) { + return; + } + $retval = fseek( $this->file, $this->pos, SEEK_SET ); + + if ( $retval < 0 ) { + throw new Exception( "Failed to seek to $this->pos bytes" ); + } + } + + /** + * Read $len bytes or return null on EOF/short read. + * @throws Exception on error + */ + public function read( int $len ): ?string { + $this->seek( $this->pos ); + $bytes = fread( $this->file, $len ); + if ( $bytes === false ) { + throw new Exception( "Read error for $len bytes at $this->pos" ); + } + if ( strlen( $bytes ) < $len ) { + return null; + } + $this->pos += strlen( $bytes ); + return $bytes; + } + + /** + * Read exactly $len bytes + * @throws Exception on error or short read + */ + public function readExactly( int $len ): string { + $bytes = $this->read( $len ); + if ( $bytes === null ) { + throw new Exception( "Short read for $len bytes at $this->pos" ); + } + return $bytes; + } + + /** + * Write the given data and return number of bytes written. + * Short writes are possible on network connections, in theory. + * @throws Exception on error + */ + public function write( string $bytes ): int { + $this->seek( $this->pos ); + $len = strlen( $bytes ); + $nbytes = fwrite( $this->file, $bytes ); + if ( $nbytes === false ) { + throw new Exception( "Write error for $len bytes at $this->pos" ); + } + return $nbytes; + } +} diff --git a/HLS/rewrite-mp3.php b/HLS/rewrite-mp3.php new file mode 100644 index 0000000..6d72143 --- /dev/null +++ b/HLS/rewrite-mp3.php @@ -0,0 +1,20 @@ +consolidate( $target ); +$segmenter->rewrite(); +$m3u8 = $segmenter->playlist( $target, $filename ); + +print $m3u8 . "\n"; diff --git a/make-fmp4.sh b/make-fmp4.sh index 4345fd4..074bba5 100755 --- a/make-fmp4.sh +++ b/make-fmp4.sh @@ -23,12 +23,13 @@ INFILE=caminandes-llamigos.webm set -e # Audio for HLS -#ffmpeg -i $INFILE -vn $AUDIO_MP3 -y fmp4.audio.mpeg.mp3 +# note - must make the MP3 because we have to reprocess it! +ffmpeg -i $INFILE -vn $AUDIO_MP3 -y fmp4.audio.mpeg.mp3 + #ffmpeg -i $INFILE -vn $AUDIO_MP3 $AUDFLAGS -y fmp4.audio.mpeg.mp4 #ffmpeg -i $INFILE -vn $AUDIO_MP3 $AUDFLAGS -y fmp4.audio.mpeg.mov #ffmpeg -i $INFILE -vn $AUDIO_AAC $AUDFLAGS -y fmp4.audio.aac.mp4 #ffmpeg -i $INFILE -vn $AUDIO_OPUS $AUDFLAGS -y fmp4.audio.opus.mp4 -ffmpeg -i $INFILE -vn $AUDIO_OPUS $AUDFLAGS -y fmp4.audio.alac.mp4 # Video for HLS @@ -50,7 +51,8 @@ ffmpeg -i $INFILE -vn $AUDIO_OPUS $AUDFLAGS -y fmp4.audio.alac.mp4 # Playlist processing -php extract-playlist.php fmp4.audio.mpeg.mp3 > fmp4.audio.mpeg.mp3.m3u8 +php HLS/rewrite-mp3.php fmp4.audio.mpeg.mp3 > fmp4.audio.mpeg.mp3.m3u8 +#php extract-playlist.php fmp4.audio.mpeg.mp3 > fmp4.audio.mpeg.mp3.m3u8 php extract-playlist.php fmp4.audio.mpeg.mp4 > fmp4.audio.mpeg.mp4.m3u8 php extract-playlist.php fmp4.audio.mpeg.mov > fmp4.audio.mpeg.mov.m3u8 php extract-playlist.php fmp4.audio.aac.mp4 > fmp4.audio.aac.mp4.m3u8 diff --git a/test-rewrite.sh b/test-rewrite.sh new file mode 100644 index 0000000..9dc874e --- /dev/null +++ b/test-rewrite.sh @@ -0,0 +1,2 @@ +cp fmp4.audio.mpeg.mp3 rewrite.audio.mpeg.mp3 +php HLS/rewrite-mp3.php rewrite.audio.mpeg.mp3