Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
50.68% covered (warning)
50.68%
74 / 146
16.67% covered (danger)
16.67%
1 / 6
CRAP
0.00% covered (danger)
0.00%
0 / 1
MP3Segmenter
50.68% covered (warning)
50.68%
74 / 146
16.67% covered (danger)
16.67%
1 / 6
86.44
0.00% covered (danger)
0.00%
0 / 1
 field
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 frameHeader
85.29% covered (warning)
85.29%
29 / 34
0.00% covered (danger)
0.00%
0 / 1
7.16
 id3Header
85.71% covered (warning)
85.71%
12 / 14
0.00% covered (danger)
0.00%
0 / 1
3.03
 parse
96.77% covered (success)
96.77%
30 / 31
0.00% covered (danger)
0.00%
0 / 1
5
 rewrite
0.00% covered (danger)
0.00%
0 / 24
0.00% covered (danger)
0.00%
0 / 1
12
 timestampTag
0.00% covered (danger)
0.00%
0 / 40
0.00% covered (danger)
0.00%
0 / 1
20
1<?php
2/**
3 * .m3u8 playlist generation for HLS (HTTP Live Streaming)
4 *
5 * @file
6 * @ingroup HLS
7 */
8
9namespace MediaWiki\TimedMediaHandler\HLS;
10
11use RuntimeException;
12
13/**
14 * Reads an MP3 file calculating the byte and time boundaries
15 * of the separate addressable frames, allowing creation of a
16 * streaming playlist.
17 *
18 * Optionally can rewrite the file to insert timestamp ID3 tags
19 * which Apple's HLS says it wants in raw packet streams. Note
20 * that initially the parse() result will make every addressible
21 * frame (and any source ID3 tags ahead of it) an individual segment.
22 * Before inserting timestamp tags with rewrite(), make a call to
23 * consolidate($segmentLength).
24 *
25 * Because the relevant ISO standards are not redistributable,
26 * artisanal 3rd-party documentation was sourced via web searches
27 * such as:
28 * - https://en.wikipedia.org/wiki/MP3
29 * - http://www.mp3-tech.org/programmer/frame_header.html
30 * - https://datatracker.ietf.org/doc/html/rfc8216
31 * - https://id3.org/id3v2.4.0-frames
32 * - https://id3.org/id3v2.4.0-structure
33 * - https://web.archive.org/web/20081008034714/http://www.id3.org/id3v2.3.0
34 */
35class MP3Segmenter extends Segmenter {
36
37    /**
38     * Internal layout of MP3 frame header bitfield
39     * @var array
40     */
41    private static $bits = [
42        'sync'        => [ 21, 11 ],
43        'mpeg'        => [ 19, 2 ],
44        'layer'       => [ 17, 2 ],
45        'protection'  => [ 16, 1 ],
46        'bitrate'     => [ 12, 4 ],
47        'sampleRate'  => [ 10, 2 ],
48        'padding'     => [ 9, 1 ],
49        // below this not needed at present
50        'private'     => [ 8, 1 ],
51        'channelMode' => [ 6, 2 ],
52        'modeExt'     => [ 4, 2 ],
53        'copyright'   => [ 3, 1 ],
54        'original'    => [ 2, 1 ],
55        'emphasis'    => [ 0, 2 ],
56    ];
57
58    /**
59     * 11-bit sync mask for MP3 frame header
60     * @var int
61     */
62    private const SYNC_MASK = 0x7ff;
63
64    /**
65     * Map of sample count per frame based on version/mode
66     * This is just in case we need to measure non-default sample rates!
67     * @var array
68     */
69    private static $samplesPerFrame = [
70        // invalid / layer 3 / 2 / 1
71
72        // MPEG-2.5
73        [ 0, 576, 1152, 384 ],
74        // Reserved
75        [ 0, 0, 0, 0 ],
76        // MPEG-2
77        [ 0, 576, 1152, 384 ],
78        // MPEG-1
79        [ 0, 1152, 384, 384 ],
80    ];
81
82    /**
83     * Map of sample rates based on version/mode
84     * @var array
85     */
86    private static $sampleRates = [
87        // MPEG-2.5
88        [ 11025, 12000, 8000, 1 ],
89        // Reserved
90        [ 1, 1, 1, 1 ],
91        // MPEG-2
92        [ 22050, 24000, 16000, 1 ],
93        // MPEG-1
94        [ 44100, 48000, 32000, 1 ],
95    ];
96
97    /**
98     * Map of bit rates based on version/mode/code
99     * @var array
100     */
101    private static $bitrates = [
102        // MPEG-2
103        [
104            // invalid layer
105            [ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, ],
106            // layer 3
107            [ 0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160, 0 ],
108            // layer 2
109            [ 0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160, 0 ],
110            // layer 1
111            [ 0, 32, 48, 56, 64, 80, 96, 112, 128, 144, 160, 176, 192, 224, 256, 0 ],
112        ],
113        // MPEG-1
114        [
115            // invalid layer
116            [ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, ],
117            // layer 3
118            [ 0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160, 192, 224, 256, 320, 0 ],
119            // layer 2
120            [ 0, 32, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 384, 0 ],
121            // layer 1
122            [ 0, 32, 64, 96, 128, 160, 192, 224, 256, 288, 320, 352, 384, 316, 448, 0 ],
123        ]
124    ];
125
126    /**
127     * Timestamp resolution for HLS ID3 timestamp tags
128     */
129    private const KHZ_90 = 90000;
130
131    /**
132     * Decode a binary field from the MP3 frame header
133     */
134    private static function field( string $name, int $header ): int {
135        [ $shift, $bits ] = self::$bits[$name];
136        $mask = ( 1 << $bits ) - 1;
137        return ( $header >> $shift ) & $mask;
138    }
139
140    /**
141     * Decode an MP3 header bitfield
142     */
143    private static function frameHeader( string $bytes ): ?array {
144        if ( strlen( $bytes ) < 4 ) {
145            return null;
146        }
147        $data = unpack( "Nval", $bytes );
148        $header = $data['val'];
149
150        // This includes "MPEG 2.5" support, so checks for 11 set bits
151        // not 12 set bits as per original MPEG 1/2
152        $sync = self::field( 'sync', $header );
153        if ( $sync !== self::SYNC_MASK ) {
154            return null;
155        }
156
157        $mpeg = self::field( 'mpeg', $header );
158        $layer = self::field( 'layer', $header );
159        $protection = self::field( 'protection', $header );
160
161        $br = self::field( 'bitrate', $header );
162        $bitrate = 1000 * self::$bitrates[$mpeg & 1][$layer][$br];
163        if ( $bitrate === 0 ) {
164            return null;
165        }
166
167        $sr = self::field( 'sampleRate', $header );
168        $sampleRate = self::$sampleRates[$mpeg][$sr];
169        if ( $sampleRate === 1 ) {
170            return null;
171        }
172
173        $padding = self::field( 'padding', $header );
174
175        $samples = self::$samplesPerFrame[$mpeg][$layer];
176        $duration = $samples / $sampleRate;
177        $nbits = $duration * $bitrate;
178        $nbytes = $nbits / 8;
179        $size = intval( $nbytes );
180
181        if ( $protection === 0 ) {
182            $size += 2;
183        }
184        if ( $padding === 1 ) {
185            $size++;
186        }
187
188        return [
189            'samples' => $samples,
190            'sampleRate' => $sampleRate,
191            'size' => $size,
192            'duration' => $duration,
193        ];
194    }
195
196    private static function id3Header( string $bytes ): ?array {
197        // ID3v2.3
198        // https://web.archive.org/web/20081008034714/http://www.id3.org/id3v2.3.0
199        // ID3v2/file identifier   "ID3"
200        // ID3v2 version           $03 00
201        // ID3v2 flags             %abc00000
202        // ID3v2 size              4 * %0xxxxxxx
203        $headerLen = 10;
204        if ( strlen( $bytes ) < $headerLen ) {
205            return null;
206        }
207
208        $data = unpack( "a3tag/nversion/Cflags/C4size", $bytes );
209        if ( $data['tag'] !== 'ID3' ) {
210            return null;
211        }
212
213        $size = $headerLen +
214            ( $data['size4'] |
215                ( $data['size3'] << 7 ) |
216                ( $data['size2'] << 14 ) |
217                ( $data['size1'] << 21 ) );
218        return [
219            'size' => $size,
220        ];
221    }
222
223    protected function parse(): void {
224        $file = fopen( $this->filename, 'rb' );
225        $stream = new OwningStreamReader( $file );
226
227        $timestamp = 0.0;
228        while ( true ) {
229            $start = $stream->pos();
230            $lookahead = 10;
231            try {
232                $bytes = $stream->read( $lookahead );
233            } catch ( ShortReadException $e ) {
234                // end of file
235                break;
236            }
237
238            // Check for MP3 frame header sync pattern
239            $header = self::frameHeader( $bytes );
240            if ( $header ) {
241                // Note we don't need the data at this time.
242                $stream->seek( $start + $header['size'] );
243                $timestamp += $header['duration'];
244                $this->segments[] = [
245                    'start' => $start,
246                    'size' => $header['size'],
247                    'timestamp' => $timestamp,
248                    'duration' => $header['duration'],
249                ];
250                continue;
251            }
252
253            // Check for ID3v2 tag
254            $id3 = self::id3Header( $bytes );
255            if ( $id3 ) {
256                // For byte range purposes; count as zero duration
257                $stream->seek( $start + $id3['size'] );
258                $this->segments[] = [
259                    'start' => $start,
260                    'size' => $id3['size'],
261                    'timestamp' => $timestamp,
262                    'duration' => 0.0,
263                ];
264                continue;
265            }
266
267            throw new RuntimeException( "Not a valid MP3 or ID3 frame at $start" );
268        }
269    }
270
271    /**
272     * Rewrite the file to include ID3 private tags with timestamp
273     * data for HLS at segment boundaries. This will modify the file
274     * in-place and change the segment offsets and sizes in the object.
275     *
276     * Beware that an i/o error during modification of the file could
277     * leave the file in an inconsistent state. Short read exceptions
278     * should be impossible unless the file is being modified from under
279     * us.
280     */
281    public function rewrite(): void {
282        $offset = 0;
283        $id3s = [];
284        $segments = [];
285        foreach ( $this->segments as $i => $orig ) {
286            $id3 = self::timestampTag( $orig['timestamp'] );
287            $delta = strlen( $id3 );
288            $id3s[$i] = $id3;
289            $segments[$i] = [
290                'start' => $orig['start'] + $offset,
291                'size' => $orig['size'] + $delta,
292                'timestamp' => $orig['timestamp'],
293                'duration' => $orig['duration'],
294            ];
295            $offset += $delta;
296        }
297
298        $file = fopen( $this->filename, 'rw+b' );
299        $stream = new OwningStreamReader( $file );
300
301        // Move each segment forward, starting at the lastmost to work in-place.
302        $preserveKeys = true;
303        foreach ( array_reverse( $this->segments, $preserveKeys ) as $i => $orig ) {
304            $stream->seek( $orig['start'] );
305            $bytes = $stream->read( $orig['size'] );
306
307            $stream->seek( $segments[$i]['start'] );
308            $stream->write( $id3s[$i] );
309            $stream->write( $bytes );
310        }
311
312        $this->segments = $segments;
313    }
314
315    /**
316     * Generate an ID3 private tag with a timestamp for use in HLS
317     * streams of raw media data such as MP3 or AAC.
318     */
319    protected static function timestampTag( float $timestamp ): string {
320        /*
321        https://datatracker.ietf.org/doc/html/rfc8216
322        PRIV frame type
323
324        should contain:
325
326        The ID3 PRIV owner identifier MUST be
327        "com.apple.streaming.transportStreamTimestamp".  The ID3 payload MUST
328        be a 33-bit MPEG-2 Program Elementary Stream timestamp expressed as a
329        big-endian eight-octet number, with the upper 31 bits set to zero.
330        Clients SHOULD NOT play Packed Audio Segments without this ID3 tag.
331
332        https://id3.org/id3v2.4.0-frames
333        https://id3.org/id3v2.4.0-structure
334
335        bit order is MSB first, big-endian
336
337        header 10 bytes
338        extended header (var, optional)
339        frames (variable)
340        pading (variable, optional)
341        footer (10 bytes, optional)
342
343
344        header:
345            "ID3"
346            version: 16 bits $04 00
347            flags: 32 bits
348            idv2 size: 32 bits (in chunks of 4 bytes, not counting header or footer)
349
350        flags:
351            bit 7 - unsyncrhonization (??)
352            bit 6 - extended header
353            bit 5 - experimental indicator
354            bit 4 - footer present
355
356        frame:
357            id - 32 bits (four chars)
358            size - 32 bits (in chunks of 4 bytes, excluding frame header)
359            flags - 16 bits
360            (frame data)
361
362        priv payload:
363            owner text string followed by \x00
364            (binary data)
365
366        The timestamps... I think... have 90 kHz integer resolution
367        so convert from the decimal seconds in the HLS
368
369        */
370
371        $owner = "com.apple.streaming.transportStreamTimestamp\x00";
372        $pts = round( $timestamp * self::KHZ_90 );
373        $thirtyThreeBits = pow( 2, 33 );
374        $thirtyOneBits = pow( 2, 31 );
375        if ( $pts >= $thirtyThreeBits ) {
376            // make sure they won't get too big for 33 bits
377            // this allows about a 24 hour media length
378            throw new RuntimeException( "Timestamp overflow in MP3 output stream: $pts >= $thirtyThreeBits" );
379        }
380        $pts_high = intval( floor( $pts / $thirtyOneBits ) );
381        $pts_low = intval( $pts - ( $pts_high * $thirtyOneBits ) );
382
383        // Private frame payload
384        $frame_data = pack(
385            'a*NN',
386            $owner,
387            $pts_high,
388            $pts_low,
389        );
390
391        // Private frame header
392        $frame_type = 'PRIV';
393        $frame_flags = 0;
394        $frame_length = strlen( $frame_data );
395        if ( $frame_length > 127 ) {
396            throw new RuntimeException( "Should never happen: too large ID3 frame data" );
397        }
398        $frame = pack(
399            'a4Nna*',
400            $frame_type,
401            $frame_length,
402            $frame_flags,
403            $frame_data
404        );
405
406        // ID3 tag
407        $tag_type = 'ID3';
408        $tag_version = 0x0400;
409        $tag_flags = 0;
410        // if >127 bytes may need to adjust
411        $tag_length = strlen( $frame );
412        if ( $tag_length > 127 ) {
413            throw new RuntimeException( "Should never happen: too large ID3 tag" );
414        }
415        return pack(
416            'a3nCNa*',
417            $tag_type,
418            $tag_version,
419            $tag_flags,
420            $tag_length,
421            $frame
422        );
423    }
424}