ID3::read()   B
last analyzed

Complexity

Conditions 9
Paths 6

Size

Total Lines 50
Code Lines 26

Duplication

Lines 0
Ratio 0 %

Importance

Changes 2
Bugs 0 Features 0
Metric Value
cc 9
eloc 26
c 2
b 0
f 0
nc 6
nop 1
dl 0
loc 50
rs 8.0555
1
<?php
2
/**
3
 * Class ID3
4
 *
5
 * @created      22.09.2018
6
 * @author       smiley <[email protected]>
7
 * @copyright    2018 smiley
8
 * @license      MIT
9
 */
10
11
namespace chillerlan\ID3Tag;
12
13
use stdClass;
14
15
use function fclose, file_exists, floor, fopen, fread, fseek, ftell, in_array, is_file,
16
	is_readable, is_resource, ord, realpath, round, sprintf, strlen, strtolower, substr, unpack;
17
18
use const PHP_INT_MAX, SEEK_END, SEEK_SET;
19
20
/**
21
 * @link http://id3.org/id3guide
22
 * @link http://www.mp3-tech.org/
23
 * @link https://en.wikipedia.org/wiki/ID3
24
 * @link https://docs.microsoft.com/windows/desktop/wmformat/id3-tag-support
25
 * @link https://github.com/brokencube/id3
26
 * @link http://www.zedwood.com/article/php-calculate-duration-of-mp3
27
 * @link https://www.mp3tag.de/en/
28
 * @link https://github.com/taglib/taglib/tree/master/tests
29
 */
30
final class ID3{
31
32
	private const BITRATES = [
33
		0b0000 => [  0,   0,   0,   0,   0],
34
		0b0001 => [ 32,  32,  32,  32,   8],
35
		0b0010 => [ 64,  48,  40,  48,  16],
36
		0b0011 => [ 96,  56,  48,  56,  24],
37
		0b0100 => [128,  64,  56,  64,  32],
38
		0b0101 => [160,  80,  64,  80,  40],
39
		0b0110 => [192,  96,  80,  96,  48],
40
		0b0111 => [224, 112,  96, 112,  56],
41
		0b1000 => [256, 128, 112, 128,  64],
42
		0b1001 => [288, 160, 128, 144,  80],
43
		0b1010 => [320, 192, 160, 160,  96],
44
		0b1011 => [352, 224, 192, 176, 112],
45
		0b1100 => [384, 256, 224, 192, 128],
46
		0b1101 => [416, 320, 256, 224, 144],
47
		0b1110 => [448, 384, 320, 256, 160],
48
		0b1111 => [ -1,  -1,  -1,  -1,  -1],
49
	];
50
51
	private const SAMPLE_RATES = [
52
		0b00 => [
53
			0b00 => 11025,
54
			0b01 => 12000,
55
			0b10 => 8000,
56
			0b11 => 0,
57
		],
58
		0b10 => [
59
			0b00 => 22050,
60
			0b01 => 24000,
61
			0b10 => 16000,
62
			0b11 => 0,
63
		],
64
		0b11 => [
65
			0b00 => 44100,
66
			0b01 => 48000,
67
			0b10 => 32000,
68
			0b11 => 0,
69
		],
70
	];
71
72
	/**
73
	 * "Xing"/"Info" identification string at 0x0D (13), 0x15 (21) or 0x24 (36)
74
	 */
75
	private const VBR_ID_STRING_POS = [
76
		// v2.5
77
		0b00 => [
78
			0b00 => 21, // stereo
79
			0b01 => 21, // jntstereo
80
			0b10 => 21, // dual channel
81
			0b11 => 21, // mono
82
		],
83
		// v2
84
		0b10 => [
85
			0b00 => 21,
86
			0b01 => 21,
87
			0b10 => 21,
88
			0b11 => 13,
89
		],
90
		// v1
91
		0b11 => [
92
			0b00 => 36,
93
			0b01 => 36,
94
			0b10 => 36,
95
			0b11 => 21,
96
		],
97
	];
98
99
	/**
100
	 * @var resource
101
	 */
102
	private $fh;
103
104
	/**
105
	 * @return void
106
	 */
107
	public function __destruct(){
108
109
		if(is_resource($this->fh)){
110
			fclose($this->fh); // @codeCoverageIgnore
111
		}
112
113
	}
114
115
	/**
116
	 * @throws \chillerlan\ID3Tag\ID3Exception
117
	 */
118
	public function read(string $filename):ID3Data{
119
		$file = realpath($filename);
120
121
		if(!$file || !file_exists($file) || !is_file($file) || !is_readable($file)){
122
			throw new ID3Exception(sprintf('invalid file: %s', $filename));
123
		}
124
125
		$data     = new ID3Data($file);
126
		$this->fh = fopen($file, 'rb');
127
128
		// invalid resource or 2GB limit on 32-bit
129
		if(!$this->fh || $data->filesize > PHP_INT_MAX){
130
			return $data; // @codeCoverageIgnore
131
		}
132
133
		// check for an ID3v1 tag
134
		fseek($this->fh, -128, SEEK_END);
135
136
		if(fread($this->fh, 3) === 'TAG'){
137
			fseek($this->fh, -256, SEEK_END);
138
139
			$properties = [
140
				'id3v1'     => (new ID3v1)->parse(fread($this->fh, 256)),
141
				'v1tagsize' => 256,
142
			];
143
144
			$data->setProperties($properties);
145
		}
146
147
		// check for an id3v2 tag
148
		fseek($this->fh, 0, SEEK_SET);
149
150
		if(fread($this->fh, 3) === 'ID3'){
151
			fseek($this->fh, 6, SEEK_SET);
152
			$tagsize = ID3Helpers::syncSafeInteger(unpack('N', fread($this->fh, 4))[1]);
153
			fseek($this->fh, 0, SEEK_SET);
154
155
			$properties = [
156
				'id3v2'     => $this->readID3v2Tag(fread($this->fh, $tagsize)),
157
				'v2tagsize' => $tagsize + 10,
158
			];
159
160
			$data->setProperties($properties);
161
		}
162
163
		$data->setProperties($this->getMP3Stats($data->filesize, $data->v1tagsize, $data->v2tagsize));
164
165
		fclose($this->fh);
166
167
		return $data;
168
	}
169
170
	/**
171
	 *
172
	 * @noinspection PhpUnusedLocalVariableInspection
173
	 */
174
	private function readID3v2Tag(string $rawdata):?array{
175
176
		/**
177
		 * 3.1. ID3v2 header
178
		 *
179
		 * The ID3v2 tag header, which should be the first information in the
180
		 * file, is 10 bytes as follows:
181
		 *
182
		 * ID3v2/file identifier      "ID3"
183
		 * ID3v2 version              $03 00
184
		 * ID3v2 flags                %abc00000
185
		 * ID3v2 size             4 * %0xxxxxxx
186
		 *
187
		 * @link http://id3.org/id3v2.3.0#ID3v2_header
188
		 */
189
		$id3version = ord(substr($rawdata, 3, 1));
190
191
		if(!in_array($id3version, [2, 3, 4], true)){
192
#			throw new ID3Exception('invalid id3 version');
193
			return null;
194
		}
195
196
		$flags = ord(substr($rawdata, 5, 1));
197
198
		$compression  = false;
0 ignored issues
show
Unused Code introduced by
The assignment to $compression is dead and can be removed.
Loading history...
199
		$exthead      = false;
200
		$experimental = false;
0 ignored issues
show
Unused Code introduced by
The assignment to $experimental is dead and can be removed.
Loading history...
201
		$hasfooter    = false;
0 ignored issues
show
Unused Code introduced by
The assignment to $hasfooter is dead and can be removed.
Loading history...
202
203
		/**
204
		 * a - Unsynchronisation
205
		 *
206
		 * Bit 7 in the 'ID3v2 flags' indicates whether or not
207
		 * unsynchronisation is used (see section 5 for details); a set bit
208
		 * indicates usage.
209
		 */
210
		$unsync = (bool)($flags & 0b10000000);
211
212
		if($id3version === 2){
213
214
			/**
215
			 * b - Compression
216
			 *
217
			 * Bit 6 is indicating whether or not compression is used;
218
			 * a set bit indicates usage. Since no compression scheme has been
219
			 * decided yet, the ID3 decoder (for now) should just ignore the entire
220
			 * tag if the compression bit is set.
221
			 */
222
			$compression = (bool)($flags & 0b01000000);
223
		}
224
225
		if($id3version === 3 || $id3version === 4){
226
227
			/**
228
			 * b - Extended header
229
			 *
230
			 * Bit 6 indicates whether or not the header is followed by an
231
			 * extended header. The extended header is described in section 3.2.
232
			 */
233
			$exthead = (bool)($flags & 0b01000000);
234
235
			/**
236
			 * c - Experimental indicator
237
			 *
238
			 * Bit 5 should be used as an 'experimental indicator'.
239
			 * This flag should always be set when the tag is in an experimental stage.
240
			 */
241
			$experimental = (bool)($flags & 0b00100000);
242
		}
243
244
		if($id3version === 4){
245
246
			/**
247
			 * d - Footer present
248
			 *
249
			 * Bit 4 indicates that a footer (section 3.4) is present at the very
250
			 * end of the tag. A set bit indicates the presence of a footer.
251
			 */
252
			$hasfooter = (bool)($flags & 0b00010000);
253
		}
254
255
		$start = 10;
256
257
		/**
258
		 * 3.2. ID3v2.3 extended header
259
		 *
260
		 * @link http://id3.org/id3v2.3.0#ID3v2_extended_header
261
		 *
262
		 * Extended header size   $xx xx xx xx
263
		 * Extended Flags         $xx xx
264
		 * Size of padding        $xx xx xx xx
265
		 */
266
		if($exthead){
267
			$extHeaderSize = ID3Helpers::syncSafeInteger(unpack('N', substr($rawdata, $start, 4))[1]);
268
			$start         += 4;
269
			// @todo (skipping the extended header for now...)
270
			/** @noinspection PhpUnusedLocalVariableInspection */
271
			$extHeader = substr($rawdata, $start, $extHeaderSize);
0 ignored issues
show
Unused Code introduced by
The assignment to $extHeader is dead and can be removed.
Loading history...
272
			$start     += $extHeaderSize;
273
		}
274
275
		$tagdata = substr($rawdata, $start);
276
277
		if($unsync && $id3version <= 3){
278
			$tagdata = ID3Helpers::unsyncString($tagdata);
279
		}
280
281
		$parser = __NAMESPACE__.'\\ID3v2'.$id3version;
282
283
		return (new $parser)->parse($tagdata);
284
	}
285
286
	/**
287
	 *
288
	 */
289
	private function getMP3Stats(int $filesize, int $v1Tagsize, int $v2Tagsize):array{
290
		$offset     = $this->getMP3FrameStart($filesize, $v1Tagsize, $v2Tagsize);
291
		$framecount = 0;
292
		$duration   = 0.0;
293
		$bitrate    = 0;
294
295
		if($offset === null){
296
			return [];
297
		}
298
299
		while($offset < $filesize){
300
			$frame = $this->parseMP3FrameHeader($offset);
301
302
			if($frame === null){
303
				$offset = $this->getMP3FrameStart($filesize, $v1Tagsize, $offset);
304
305
				if($offset !== null){
306
					continue;
307
				}
308
309
				break;
310
			}
311
312
			/**
313
			 * In the Info Tag, the "Xing" identification string at 0x0D (13),
314
			 * 0x15 (21) or 0x24 (36) (depending on MPEG layer and number of channels)
315
			 * of the header is replaced by "Info" in case of a CBR file.
316
			 *
317
			 * This was done to avoid CBR files to be recognized as traditional
318
			 * Xing VBR files by some decoders. Although the two identification
319
			 * strings "Xing" and "Info" are both valid, it is suggested that you
320
			 * keep the identification string "Xing" in case of VBR bistream
321
			 * in order to keep compatibility.
322
			 *
323
			 * @link http://gabriel.mp3-tech.org/mp3infotag.html
324
			 */
325
			if($framecount === 0){
326
				$pos = $this::VBR_ID_STRING_POS[$frame->version][$frame->channelmode] ?? 36;
327
				fseek($this->fh, $offset + $pos, SEEK_SET);
328
329
				if(strtolower(fread($this->fh, 4)) !== 'xing'){ // lower just in case someone thinks they're special
330
331
					return [
332
						'duration' => (int)round(($filesize - $v1Tagsize - $v2Tagsize) / (($frame->bitrate * 1000) / 8)),
333
						'bitrate'  => $frame->bitrate,
334
					];
335
				}
336
337
			}
338
339
			$duration += $frame->duration;
340
			$offset   += $frame->length;
341
			$bitrate  += $frame->bitrate;
342
343
			$framecount++;
344
		}
345
346
		return [
347
			'duration'   => (int)round($duration),
348
			'bitrate'    => (int)round($bitrate / $framecount),
349
			'framecount' => $framecount,
350
		];
351
	}
352
353
	/**
354
	 *
355
	 */
356
	private function getMP3FrameStart(int $filesize, int $v1Tagsize, int $offset):?int{
357
		fseek($this->fh, $offset, SEEK_SET);
358
		$size = $filesize - $v1Tagsize;
359
360
		while($offset < $size){
361
			$offset++;
362
363
			$byte = fread($this->fh, 1);
364
365
			if($byte === false){
366
				return null;
367
			}
368
369
			if($byte === "\xff"){
370
				$byte = fread($this->fh, 1);
371
372
				if($byte === "\xf3" ||$byte === "\xfa" || $byte === "\xfb"){//
373
					$offset = ftell($this->fh) - 2;
374
					fseek($this->fh, $offset, SEEK_SET);
375
376
					return $offset;
377
				}
378
379
			}
380
381
		}
382
383
		return null;
384
	}
385
386
	/**
387
	 *
388
	 */
389
	private function parseMP3FrameHeader(int $offset):?stdClass{
390
		fseek($this->fh, $offset, SEEK_SET);
391
392
		$headerBytes = fread($this->fh, 4);
393
394
		if(strlen($headerBytes) !== 4 || !($headerBytes[0] === "\xff" && (ord($headerBytes[1]) & 0xe0))){
395
			return null;
396
		}
397
398
		$b1 = ord($headerBytes[1]);
399
		$b2 = ord($headerBytes[2]);
400
		$b3 = ord($headerBytes[3]);
401
402
		$info = new stdClass;
403
404
		/**
405
		 * http://www.mp3-tech.org/programmer/frame_header.html
406
		 * AAAA AAAA  AAAB BCCD  EEEE FFGH  IIJJ KLMM
407
		 * A - Frame sync (all bits set)
408
		 * B - MPEG Audio version ID
409
		 * C - Layer description
410
		 * D - Protection bit
411
		 * E - Bitrate index
412
		 * F - Sampling rate frequency index
413
		 * G - Padding bit
414
		 * H - Private bit
415
		 * I - Channel Mode
416
		 * J - Mode extension (Only if Joint stereo)
417
		 * K - Copyright
418
		 * L - Original
419
		 * M - Emphasis
420
		 */
421
#		$info->sync          = (bigEndian2Int(substr($headerBytes, 0, 2)) & 0xFFE0) >> 4;
422
		$info->version       = ($b1 & 0x18) >> 3;         //    BB
423
		$info->layer         = ($b1 & 0x06) >> 1;         //      CC
424
#		$info->protection    = (bool)($b1 & 0x01);        //        D
425
		$info->bitrateIndex  = ($b2 & 0xF0) >> 4;         // EEEE
426
		$info->samplerate    = ($b2 & 0x0C) >> 2;         //     FF
427
		$info->padding       = (bool)(($b2 & 0x02) >> 1); //       G
428
#		$info->private       = (bool)($b2 & 0x01);        //        H
429
		$info->channelmode   = ($b3 & 0xC0) >> 6;         // II
430
#		$info->modeextension = ($b3 & 0x30) >> 4;         //   JJ
431
#		$info->copyright     = (bool)(($b3 & 0x08) >> 3); //     K
432
#		$info->original      = (bool)(($b3 & 0x04) >> 2); //      L
433
#		$info->emphasis      = ($b3 & 0x03);              //       MM
434
435
		if(!in_array($info->version, [0b00, 0b10, 0b11]) || !in_array($info->layer, [0b01, 0b10, 0b11])){
436
			return null;
437
		}
438
439
		$sampleRate = $this::SAMPLE_RATES[$info->version][$info->samplerate];
440
441
		if($sampleRate <= 0){
442
			// Invalid sample rate value
443
			return null;
444
		}
445
446
		$bitRate = 0;
447
448
		if($info->version === 0b11){
449
			switch($info->layer){
450
				case 0b11:
451
					$bitRate = $this::BITRATES[$info->bitrateIndex][0];
452
					break;
453
				case 0b10:
454
					$bitRate = $this::BITRATES[$info->bitrateIndex][1];
455
					break;
456
				case 0b01:
457
					$bitRate = $this::BITRATES[$info->bitrateIndex][2];
458
					break;
459
			}
460
		}
461
		else{
462
			switch($info->layer){
463
				case 0b11:
464
					$bitRate = $this::BITRATES[$info->bitrateIndex][3];
465
					break;
466
				case 0b10:
467
				case 0b01:
468
					$bitRate = $this::BITRATES[$info->bitrateIndex][4];
469
					break;
470
			}
471
		}
472
473
		if($bitRate <= 0){
474
			return null; // bitrate "free"
475
		}
476
477
		$info->bitrate = $bitRate;
478
		$bitRate *= 1000;
479
480
		if($info->layer === 0b11){
481
			$info->length = (((12 * $bitRate) / $sampleRate) + (int)$info->padding) * 4;
482
		}
483
		elseif($info->layer === 0b10 || $info->layer === 0b01){
484
			$info->length = ((144 * $bitRate) / $sampleRate) + (int)$info->padding;
485
		}
486
487
		$info->length = floor($info->length);
488
489
		if($info->length <= 0){
490
			return null;
491
		}
492
493
		$info->duration = $info->length * 8 / $bitRate;
494
495
		return $info;
496
	}
497
498
	/**
499
	 * @todo
500
	 */
501
#	public function write(iterable $data):bool{
502
#		return false;
503
#	}
504
505
}
506