Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
50.68% |
74 / 146 |
|
16.67% |
1 / 6 |
CRAP | |
0.00% |
0 / 1 |
MP3Segmenter | |
50.68% |
74 / 146 |
|
16.67% |
1 / 6 |
86.44 | |
0.00% |
0 / 1 |
field | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
frameHeader | |
85.29% |
29 / 34 |
|
0.00% |
0 / 1 |
7.16 | |||
id3Header | |
85.71% |
12 / 14 |
|
0.00% |
0 / 1 |
3.03 | |||
parse | |
96.77% |
30 / 31 |
|
0.00% |
0 / 1 |
5 | |||
rewrite | |
0.00% |
0 / 24 |
|
0.00% |
0 / 1 |
12 | |||
timestampTag | |
0.00% |
0 / 40 |
|
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 | |
9 | namespace MediaWiki\TimedMediaHandler\HLS; |
10 | |
11 | use 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 | */ |
35 | class 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 | } |