music-metadata 12.0.0 → 12.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.md +72 -30
  2. package/lib/ParserFactory.js +6 -0
  3. package/lib/apev2/APEv2Parser.js +1 -0
  4. package/lib/asf/AsfObject.d.ts +7 -2
  5. package/lib/asf/AsfObject.js +103 -26
  6. package/lib/asf/AsfParser.d.ts +3 -0
  7. package/lib/asf/AsfParser.js +81 -12
  8. package/lib/common/MetadataCollector.js +4 -0
  9. package/lib/common/TrackInfo.d.ts +5 -0
  10. package/lib/common/TrackInfo.js +39 -0
  11. package/lib/common/Util.d.ts +1 -1
  12. package/lib/common/Util.js +6 -12
  13. package/lib/core.d.ts +1 -1
  14. package/lib/core.js +1 -1
  15. package/lib/matroska/MatroskaDtd.js +1 -0
  16. package/lib/matroska/MatroskaParser.js +31 -13
  17. package/lib/matroska/types.d.ts +8 -32
  18. package/lib/matroska/types.js +1 -18
  19. package/lib/mp4/AtomToken.d.ts +11 -0
  20. package/lib/mp4/AtomToken.js +85 -12
  21. package/lib/mp4/MP4Parser.d.ts +6 -0
  22. package/lib/mp4/MP4Parser.js +281 -75
  23. package/lib/mpeg/AdtsFrameHeader.d.ts +17 -0
  24. package/lib/mpeg/AdtsFrameHeader.js +44 -0
  25. package/lib/mpeg/MpegFrameHeader.d.ts +41 -0
  26. package/lib/mpeg/MpegFrameHeader.js +84 -0
  27. package/lib/mpeg/MpegParser.d.ts +11 -25
  28. package/lib/mpeg/MpegParser.js +60 -333
  29. package/lib/ogg/OggLoader.js +1 -1
  30. package/lib/ogg/OggParser.js +42 -2
  31. package/lib/ogg/OggStreamMetadata.d.ts +14 -0
  32. package/lib/ogg/OggStreamMetadata.js +27 -0
  33. package/lib/ogg/OggToken.d.ts +1 -0
  34. package/lib/ogg/OggToken.js +2 -1
  35. package/lib/ogg/flac/FlacStream.d.ts +3 -1
  36. package/lib/ogg/flac/FlacStream.js +17 -3
  37. package/lib/ogg/opus/OpusStream.js +1 -0
  38. package/lib/ogg/speex/SpeexStream.js +1 -0
  39. package/lib/ogg/theora/Theora.d.ts +4 -0
  40. package/lib/ogg/theora/Theora.js +5 -1
  41. package/lib/ogg/theora/TheoraStream.d.ts +1 -1
  42. package/lib/ogg/theora/TheoraStream.js +13 -0
  43. package/lib/ogg/vorbis/Vorbis.js +3 -3
  44. package/lib/ogg/vorbis/VorbisStream.js +1 -0
  45. package/lib/type.d.ts +34 -3
  46. package/lib/type.js +22 -1
  47. package/package.json +5 -4
package/README.md CHANGED
@@ -606,7 +606,10 @@ Returns a list of supported MIME-types. This may include some MIME-types which a
606
606
  only that the parser will attempt to calculate it when possible, even if it requires reading the full file.
607
607
 
608
608
  - `includeChapters`: `boolean` (default: `false`)
609
- When `true`, the MP4 parser scans the `mdat` atom for chapters.
609
+ When `true`, the MP4 parser reads QuickTime chapter tracks and Nero `chpl` chapter lists.
610
+ A usable QuickTime chapter track takes precedence over a Nero list.
611
+ Files, buffers, and Blobs support chapters whether `moov` appears before or after `mdat`.
612
+ For forward-only streams, `moov` must appear before the chapter data.
610
613
 
611
614
  - `mkvUseIndex`: `boolean` (default: `false`)
612
615
 
@@ -665,45 +668,84 @@ Audio format information. Defined in the TypeScript `IFormat` interface:
665
668
 
666
669
  #### `metadata.format.trackInfo`
667
670
 
668
- Containers such as Matroska and MPEG-4 can contain multiple audio and video tracks.
669
- The experimental `metadata.format.trackInfo` property describes these individual tracks.
671
+ `metadata.format.trackInfo` provides the same track abstraction for single-stream audio files and
672
+ multi-track containers (MPEG-4, Matroska, ASF, and Ogg). Tracks can carry audio, video, subtitles, or
673
+ metadata. Import `TrackType` from `music-metadata` to select tracks without knowing the container:
674
+
675
+ ```js
676
+ import { parseFile, TrackType } from 'music-metadata';
677
+
678
+ const { format } = await parseFile('movie.mp4');
679
+ const audioTracks = format.trackInfo.filter(track => track.type === TrackType.audio);
680
+ const videoTracks = format.trackInfo.filter(track => track.type === TrackType.video);
681
+ ```
682
+
683
+ Each entry describes one discovered track. The order follows container discovery and does not identify
684
+ a preferred track; use `type`, container flags, and language to choose one. In MPEG-4, the codec and
685
+ media properties describe the first sample description, even when the track has multiple sample descriptions. In Ogg, entries describe
686
+ logical media streams; Skeleton headers do not create media tracks. Single-stream audio parsers map
687
+ their format properties to one audio track after parsing.
688
+
689
+ Properties are optional when unavailable and are not copied between tracks. In particular, a container
690
+ or summary bitrate is not a per-track bitrate. MPEG-4 track timing comes from media headers (or parsed
691
+ audio fragments), and track bitrates from sample sizes and duration. ASF supplies audio/video stream
692
+ properties and stream bitrates from both standalone and nested Stream Properties Objects. Codec List
693
+ entries supply names for matching streams; unused codecs do not create tracks. Matroska applies
694
+ specified defaults for flags, language, channels, and sample rate, and prefers IETF language tags when present. Video duration/bitrate coverage and overall
695
+ video statistics remain incomplete. The existing `format` fields retain their summary semantics.
696
+
697
+ For Ogg/Opus, the per-track average bitrate uses the logical stream's payload bytes, including codec
698
+ headers and excluding Ogg framing and other streams. It requires the logical stream's end-of-stream
699
+ (EOS) page and a known duration; reaching input EOF alone is insufficient. Set `duration: true` to
700
+ allow scanning when needed. The summary `format.bitrate` retains
701
+ its existing file-size estimate and can differ from the per-track bitrate. Vorbis and Speex use nominal
702
+ bitrates when their headers provide them.
670
703
 
671
704
  `metadata.format.trackInfo` is an array of [trackInfo](#trackinfo) objects, empty when no track information is available.
672
705
 
673
706
  ##### trackInfo
674
707
 
675
708
  Individual track information. Defined in the TypeScript `ITrackInfo` interface:
676
- - `trackInfo.type?: TrackType` Track type
709
+ - `trackInfo.id?: number` Container track ID/number, or Ogg stream serial number; only meaningful within the file
710
+ - `trackInfo.type?: TrackType` Track type (`audio`, `video`, `subtitle`, `metadata`, or other known types)
711
+ - `trackInfo.codecId?: string` Container-specific codec identifier, such as `mp4a`, `A_AAC`, or ASF audio format tag `0x0161`
677
712
  - `trackInfo.codecName?: string` Codec name
713
+ - `trackInfo.codecProfile?: string` Codec profile
714
+ - `trackInfo.duration?: number` Track duration in seconds
715
+ - `trackInfo.bitrate?: number` Encoded track bitrate in bits per second
716
+ - `trackInfo.lossless?: boolean` Whether the track uses lossless compression
678
717
  - `trackInfo.codecSettings?: string` Codec settings
679
- - `trackInfo.flagEnabled?: boolean` Set if the track is usable, default: `true`
680
- - `trackInfo.flagDefault?: boolean` Set if that track (audio, video or subs) SHOULD be active if no language found matches the user preference.
681
- - `trackInfo.flagLacing?: boolean` Set if the track **may** contain blocks using lacing
718
+ - `trackInfo.flagEnabled?: boolean` Whether the track is enabled; Matroska defaults to `true`, MPEG-4 uses the track header flag
719
+ - `trackInfo.flagDefault?: boolean` Whether the container marks the track as a default selection; Matroska defaults to `true`
720
+ - `trackInfo.flagForced?: boolean` Whether a subtitle track should be displayed without explicit selection
721
+ - `trackInfo.flagLacing?: boolean` Whether a Matroska track may contain blocks using lacing; defaults to `true`
682
722
  - `trackInfo.name?: string` A human-readable track name.
683
723
  - `trackInfo.language?: string` Specifies the language of the track
684
- - `trackInfo.audio?: IAudioTrack`, see [`trackInfo.audioTrack`](#trackinfoaudiotrack)
685
- - `trackInfo.video?: IVideoTrack`, see [`trackInfo.videoTrack`](#trackinfovideotrack)
686
-
687
- ##### `trackInfo.audioTrack`
688
-
689
- - `audioTrack.samplingFrequency?: number`
690
- - `audioTrack.outputSamplingFrequency?: number`
691
- - `audioTrack.channels?: number`
692
- - `audioTrack.channelPositions?: Uint8Array`
693
- - `audioTrack.bitDepth?: number`
694
-
695
- ##### `trackInfo.videoTrack`
696
-
697
- - `videoTrack.flagInterlaced?: boolean`
698
- - `videoTrack.stereoMode?: number`
699
- - `videoTrack.pixelWidth?: number`
700
- - `videoTrack.pixelHeight?: number`
701
- - `videoTrack.displayWidth?: number`
702
- - `videoTrack.displayHeight?: number`
703
- - `videoTrack.displayUnit?: number`
704
- - `videoTrack.aspectRatioType?: number`
705
- - `videoTrack.colourSpace?: Uint8Array`
706
- - `videoTrack.gammaValue?: number`
724
+ - `trackInfo.audio?: IAudioTrack`, see [`trackInfo.audio`](#trackinfoaudio)
725
+ - `trackInfo.video?: IVideoTrack`, see [`trackInfo.video`](#trackinfovideo)
726
+
727
+ ##### `trackInfo.audio`
728
+
729
+ - `trackInfo.audio.numberOfSamples?: number` Number of decoded sample frames (one sample per channel), when known
730
+ - `trackInfo.audio.samplingFrequency?: number` Sample rate in hertz; for Opus, the informational original input rate
731
+ - `trackInfo.audio.outputSamplingFrequency?: number` Output sample rate in hertz; Opus granules and sample counts use 48000 Hz
732
+ - `trackInfo.audio.channels?: number`
733
+ - `trackInfo.audio.channelPositions?: Uint8Array`
734
+ - `trackInfo.audio.bitDepth?: number`
735
+
736
+ ##### `trackInfo.video`
737
+
738
+ - `trackInfo.video.frameRate?: number` Frames per second, when known
739
+ - `trackInfo.video.flagInterlaced?: boolean`
740
+ - `trackInfo.video.stereoMode?: number`
741
+ - `trackInfo.video.pixelWidth?: number`
742
+ - `trackInfo.video.pixelHeight?: number`
743
+ - `trackInfo.video.displayWidth?: number`
744
+ - `trackInfo.video.displayHeight?: number`
745
+ - `trackInfo.video.displayUnit?: number`
746
+ - `trackInfo.video.aspectRatioType?: number`
747
+ - `trackInfo.video.colourSpace?: Uint8Array`
748
+ - `trackInfo.video.gammaValue?: number`
707
749
 
708
750
  #### `metadata.common`
709
751
 
@@ -6,6 +6,7 @@ import { aiffParserLoader } from './aiff/AiffLoader.js';
6
6
  import { apeParserLoader } from './apev2/Apev2Loader.js';
7
7
  import { asfParserLoader } from './asf/AsfLoader.js';
8
8
  import { MetadataCollector } from './common/MetadataCollector.js';
9
+ import { createAudioTrackInfo } from './common/TrackInfo.js';
9
10
  import { scanAppendingHeaders } from './core.js';
10
11
  import { dsdiffParserLoader } from './dsdiff/DsdiffLoader.js';
11
12
  import { dsfParserLoader } from './dsf/DsfLoader.js';
@@ -92,6 +93,11 @@ export class ParserFactory {
92
93
  const parser = new ParserImpl(metadata, tokenizer, opts ?? {});
93
94
  debug(`Parser ${parserLoader.parserType} loaded`);
94
95
  await parser.parse();
96
+ // Audio-only parsers describe their single stream through the format fields.
97
+ // Container parsers supply their own tracks; never copy aggregate fields to them.
98
+ if (metadata.format.trackInfo.length === 0 && metadata.format.hasAudio === true) {
99
+ metadata.addStreamInfo(createAudioTrackInfo(metadata.format));
100
+ }
95
101
  if (metadata.format.trackInfo) {
96
102
  if (metadata.format.hasAudio === undefined) {
97
103
  metadata.setFormat('hasAudio', !!metadata.format.trackInfo.find(track => track.type === TrackType.audio));
@@ -199,6 +199,7 @@ export class APEv2Parser extends BasicParser {
199
199
  // ToDo before
200
200
  this.metadata.setFormat('lossless', true);
201
201
  this.metadata.setFormat('container', "Monkey's Audio");
202
+ this.metadata.setFormat('codec', "Monkey's Audio");
202
203
  this.metadata.setFormat('bitsPerSample', header.bitsPerSample);
203
204
  this.metadata.setFormat('sampleRate', header.sampleRate);
204
205
  this.metadata.setFormat('numberOfChannels', header.channel);
@@ -1,5 +1,5 @@
1
1
  import type { IGetToken, ITokenizer } from 'strtok3';
2
- import type { AnyTagValue, IPicture, ITag } from '../type.js';
2
+ import type { AnyTagValue, IAudioTrack, IPicture, ITag, IVideoTrack } from '../type.js';
3
3
  import AsfGuid from './AsfGuid.js';
4
4
  declare const AsfContentParseError_base: {
5
5
  new (message: string): {
@@ -199,6 +199,11 @@ export interface IStreamPropertiesObject {
199
199
  * Error Correction Type
200
200
  */
201
201
  errorCorrectionType: AsfGuid;
202
+ streamNumber: number;
203
+ codecId?: string;
204
+ audio?: IAudioTrack;
205
+ video?: IVideoTrack;
206
+ bitrate?: number;
202
207
  }
203
208
  /**
204
209
  * Token for: 3.3 Stream Properties Object (mandatory, one per stream)
@@ -283,7 +288,7 @@ export interface IExtendedStreamPropertiesObject {
283
288
  streamNameCount: number;
284
289
  payloadExtensionSystems: number;
285
290
  streamNames: IStreamName[];
286
- streamPropertiesObject: number | null;
291
+ streamPropertiesObject: IStreamPropertiesObject | null;
287
292
  }
288
293
  /**
289
294
  * 4.1 Extended Stream Properties Object (optional, 1 per media stream)
@@ -1,6 +1,5 @@
1
1
  // ASF Objects
2
2
  import * as Token from 'token-types';
3
- import * as util from '../common/Util.js';
4
3
  import { AttachedPictureType } from '../id3v2/ID3v2Token.js';
5
4
  import { makeUnexpectedFileContentError } from '../ParseError.js';
6
5
  import AsfGuid from './AsfGuid.js';
@@ -97,6 +96,7 @@ export class State {
97
96
  export class FilePropertiesObject extends State {
98
97
  static guid = AsfGuid.FilePropertiesObject;
99
98
  get(buf, off) {
99
+ const flags = Token.UINT32_LE.get(buf, off + 64);
100
100
  return {
101
101
  fileId: AsfGuid.fromBin(buf, off),
102
102
  fileSize: Token.UINT64_LE.get(buf, off + 16),
@@ -106,8 +106,8 @@ export class FilePropertiesObject extends State {
106
106
  sendDuration: Token.UINT64_LE.get(buf, off + 48),
107
107
  preroll: Token.UINT64_LE.get(buf, off + 56),
108
108
  flags: {
109
- broadcast: util.getBit(buf, off + 64, 24),
110
- seekable: util.getBit(buf, off + 64, 25)
109
+ broadcast: (flags & 1) !== 0,
110
+ seekable: (flags & 2) !== 0
111
111
  },
112
112
  // flagsNumeric: Token.UINT32_LE.get(buf, off + 64),
113
113
  minimumDataPacketSize: Token.UINT32_LE.get(buf, off + 68),
@@ -123,11 +123,52 @@ export class FilePropertiesObject extends State {
123
123
  export class StreamPropertiesObject extends State {
124
124
  static guid = AsfGuid.StreamPropertiesObject;
125
125
  get(buf, off) {
126
- return {
126
+ const available = Math.min(this.len, buf.length - off);
127
+ if (available < 54) {
128
+ throw new AsfContentParseError('Truncated Stream Properties Object');
129
+ }
130
+ const typeSpecificSize = Token.UINT32_LE.get(buf, off + 40);
131
+ const correctionSize = Token.UINT32_LE.get(buf, off + 44);
132
+ if (typeSpecificSize + correctionSize > available - 54) {
133
+ throw new AsfContentParseError('ASF stream data exceeds Stream Properties Object payload');
134
+ }
135
+ const result = {
127
136
  streamType: AsfGuid.decodeMediaType(AsfGuid.fromBin(buf, off)),
128
- errorCorrectionType: AsfGuid.fromBin(buf, off + 8)
129
- // ToDo
137
+ errorCorrectionType: AsfGuid.fromBin(buf, off + 16),
138
+ streamNumber: Token.UINT16_LE.get(buf, off + 48) & 0x7f
130
139
  };
140
+ const data = off + 54;
141
+ if (result.streamType === 'audio' && typeSpecificSize >= 16) {
142
+ result.codecId = `0x${Token.UINT16_LE.get(buf, data).toString(16).padStart(4, '0')}`;
143
+ result.audio = {
144
+ channels: Token.UINT16_LE.get(buf, data + 2),
145
+ samplingFrequency: Token.UINT32_LE.get(buf, data + 4)
146
+ };
147
+ const bitDepth = Token.UINT16_LE.get(buf, data + 14);
148
+ if (bitDepth > 0) {
149
+ result.audio.bitDepth = bitDepth;
150
+ }
151
+ const bytesPerSecond = Token.UINT32_LE.get(buf, data + 8);
152
+ if (bytesPerSecond > 0) {
153
+ result.bitrate = bytesPerSecond * 8;
154
+ }
155
+ }
156
+ else if (result.streamType === 'video' && typeSpecificSize >= 11) {
157
+ result.video = {
158
+ pixelWidth: Token.UINT32_LE.get(buf, data),
159
+ pixelHeight: Token.UINT32_LE.get(buf, data + 4)
160
+ };
161
+ // ASF specification 9.2: DWORD width, DWORD height, BYTE flags, WORD format size.
162
+ // The packed BITMAPINFOHEADER follows at +11; its compression DWORD is at +16.
163
+ const formatSize = Token.UINT16_LE.get(buf, data + 9);
164
+ if (formatSize > typeSpecificSize - 11) {
165
+ throw new AsfContentParseError('ASF video format data exceeds stream data');
166
+ }
167
+ if (formatSize >= 20) {
168
+ result.codecId = new Token.StringType(4, 'ascii').get(buf, data + 27);
169
+ }
170
+ }
171
+ return result;
131
172
  }
132
173
  }
133
174
  /**
@@ -293,32 +334,68 @@ export class ExtendedContentDescriptionObjectState extends State {
293
334
  export class ExtendedStreamPropertiesObjectState extends State {
294
335
  static guid = AsfGuid.ExtendedStreamPropertiesObject;
295
336
  get(buf, off) {
296
- const view = new DataView(buf.buffer, off);
297
- return {
337
+ const end = off + Math.min(this.len, buf.length - off);
338
+ if (end - off < 64) {
339
+ throw new AsfContentParseError('Truncated Extended Stream Properties Object');
340
+ }
341
+ const flags = Token.UINT32_LE.get(buf, off + 44);
342
+ const result = {
298
343
  startTime: Token.UINT64_LE.get(buf, off),
299
344
  endTime: Token.UINT64_LE.get(buf, off + 8),
300
- dataBitrate: view.getInt32(12, true),
301
- bufferSize: view.getInt32(16, true),
302
- initialBufferFullness: view.getInt32(20, true),
303
- alternateDataBitrate: view.getInt32(24, true),
304
- alternateBufferSize: view.getInt32(28, true),
305
- alternateInitialBufferFullness: view.getInt32(32, true),
306
- maximumObjectSize: view.getInt32(36, true),
345
+ dataBitrate: Token.UINT32_LE.get(buf, off + 16),
346
+ bufferSize: Token.UINT32_LE.get(buf, off + 20),
347
+ initialBufferFullness: Token.UINT32_LE.get(buf, off + 24),
348
+ alternateDataBitrate: Token.UINT32_LE.get(buf, off + 28),
349
+ alternateBufferSize: Token.UINT32_LE.get(buf, off + 32),
350
+ alternateInitialBufferFullness: Token.UINT32_LE.get(buf, off + 36),
351
+ maximumObjectSize: Token.UINT32_LE.get(buf, off + 40),
307
352
  flags: {
308
- // ToDo, check flag positions
309
- reliableFlag: util.getBit(buf, off + 40, 0),
310
- seekableFlag: util.getBit(buf, off + 40, 1),
311
- resendLiveCleanpointsFlag: util.getBit(buf, off + 40, 2)
353
+ reliableFlag: (flags & 1) !== 0,
354
+ seekableFlag: (flags & 2) !== 0,
355
+ resendLiveCleanpointsFlag: (flags & 8) !== 0
312
356
  },
313
- // flagsNumeric: Token.UINT32_LE.get(buf, off + 64),
314
- streamNumber: view.getInt16(42, true),
315
- streamLanguageId: view.getInt16(44, true),
316
- averageTimePerFrame: view.getInt32(52, true),
317
- streamNameCount: view.getInt32(54, true),
318
- payloadExtensionSystems: view.getInt32(56, true),
319
- streamNames: [], // ToDo
357
+ streamNumber: Token.UINT16_LE.get(buf, off + 48) & 0x7f,
358
+ streamLanguageId: Token.UINT16_LE.get(buf, off + 50),
359
+ averageTimePerFrame: Number(Token.UINT64_LE.get(buf, off + 52)),
360
+ streamNameCount: Token.UINT16_LE.get(buf, off + 60),
361
+ payloadExtensionSystems: Token.UINT16_LE.get(buf, off + 62),
362
+ streamNames: [],
320
363
  streamPropertiesObject: null
321
364
  };
365
+ let cursor = off + 64;
366
+ const requireBytes = (length) => {
367
+ if (length > end - cursor) {
368
+ throw new AsfContentParseError('ASF extended stream data exceeds object payload');
369
+ }
370
+ };
371
+ for (let index = 0; index < result.streamNameCount; ++index) {
372
+ requireBytes(4);
373
+ const language = Token.UINT16_LE.get(buf, cursor);
374
+ const length = Token.UINT16_LE.get(buf, cursor + 2);
375
+ cursor += 4;
376
+ requireBytes(length);
377
+ result.streamNames.push({
378
+ streamLanguageId: language,
379
+ streamName: parseUnicodeAttr(buf.subarray(cursor, cursor + length))
380
+ });
381
+ cursor += length;
382
+ }
383
+ for (let index = 0; index < result.payloadExtensionSystems; ++index) {
384
+ requireBytes(22);
385
+ const length = Token.UINT32_LE.get(buf, cursor + 18);
386
+ cursor += 22;
387
+ requireBytes(length);
388
+ cursor += length;
389
+ }
390
+ if (cursor < end) {
391
+ requireBytes(HeaderObjectToken.len);
392
+ const header = HeaderObjectToken.get(buf, cursor);
393
+ if (!header.objectId.equals(AsfGuid.StreamPropertiesObject) || header.objectSize !== end - cursor) {
394
+ throw new AsfContentParseError('Invalid embedded ASF Stream Properties Object');
395
+ }
396
+ result.streamPropertiesObject = new StreamPropertiesObject(header).get(buf, cursor + HeaderObjectToken.len);
397
+ }
398
+ return result;
322
399
  }
323
400
  }
324
401
  /**
@@ -10,6 +10,9 @@ import { BasicParser } from '../common/BasicParser.js';
10
10
  * - https://msdn.microsoft.com/en-us/library/windows/desktop/ee663575(v=vs.85).aspx
11
11
  */
12
12
  export declare class AsfParser extends BasicParser {
13
+ private streams;
14
+ private codecs;
15
+ private streamBitrates;
13
16
  parse(): Promise<void>;
14
17
  private parseObjectHeaders;
15
18
  private addTags;
@@ -1,5 +1,6 @@
1
1
  import initDebug from 'debug';
2
2
  import { EndOfStreamError } from 'strtok3';
3
+ import * as Token from 'token-types';
3
4
  import { BasicParser } from '../common/BasicParser.js';
4
5
  import { TrackType } from '../type.js';
5
6
  import AsfGuid from './AsfGuid.js';
@@ -19,6 +20,9 @@ const maxAsfMetadataObjectSize = 16 * 1024 * 1024;
19
20
  * - https://msdn.microsoft.com/en-us/library/windows/desktop/ee663575(v=vs.85).aspx
20
21
  */
21
22
  export class AsfParser extends BasicParser {
23
+ streams = new Map();
24
+ codecs = [];
25
+ streamBitrates = new Map();
22
26
  async parse() {
23
27
  const header = await this.tokenizer.readToken(AsfObject.TopLevelHeaderObjectToken);
24
28
  if (!header.objectId.equals(AsfGuid.HeaderObject)) {
@@ -28,6 +32,52 @@ export class AsfParser extends BasicParser {
28
32
  throw new AsfContentParseError(`Unrealistic number of ASF header objects: ${header.numberOfHeaderObjects}`);
29
33
  }
30
34
  await this.parseObjectHeaders(header.numberOfHeaderObjects, header.objectSize - AsfObject.TopLevelHeaderObjectToken.len);
35
+ for (const stream of this.streams.values()) {
36
+ const type = stream.streamType === 'audio'
37
+ ? TrackType.audio
38
+ : stream.streamType === 'video'
39
+ ? TrackType.video
40
+ : stream.streamType === 'command'
41
+ ? TrackType.control
42
+ : stream.streamType === 'binary'
43
+ ? TrackType.metadata
44
+ : undefined;
45
+ const codec = this.codecs.find(codec => {
46
+ if (type === TrackType.audio && codec.type.audioCodec && codec.information.length >= 2) {
47
+ return stream.codecId === `0x${Token.UINT16_LE.get(codec.information, 0).toString(16).padStart(4, '0')}`;
48
+ }
49
+ return (type === TrackType.video &&
50
+ codec.type.videoCodec &&
51
+ codec.information.length >= 4 &&
52
+ stream.codecId === new Token.StringType(4, 'ascii').get(codec.information, 0));
53
+ });
54
+ const track = { id: stream.streamNumber, type };
55
+ if (stream.codecId !== undefined) {
56
+ track.codecId = stream.codecId;
57
+ }
58
+ if (codec) {
59
+ track.codecName = codec.codecName;
60
+ }
61
+ if (stream.audio) {
62
+ track.audio = stream.audio;
63
+ }
64
+ if (stream.video) {
65
+ track.video = stream.video;
66
+ }
67
+ const bitrate = this.streamBitrates.get(stream.streamNumber) ?? stream.bitrate;
68
+ if (bitrate !== undefined) {
69
+ track.bitrate = bitrate;
70
+ }
71
+ this.metadata.addStreamInfo(track);
72
+ }
73
+ const audio = [...this.streams.values()].find(stream => stream.audio)?.audio;
74
+ if (audio) {
75
+ this.metadata.setFormat('sampleRate', audio.samplingFrequency);
76
+ this.metadata.setFormat('numberOfChannels', audio.channels);
77
+ if (audio.bitDepth !== undefined) {
78
+ this.metadata.setFormat('bitsPerSample', audio.bitDepth);
79
+ }
80
+ }
31
81
  }
32
82
  async parseObjectHeaders(numberOfObjectHeaders, remainingHeaderSize) {
33
83
  let tags;
@@ -49,6 +99,13 @@ export class AsfParser extends BasicParser {
49
99
  this.validateAllocationSize(payloadSize, 'Stream Properties Object');
50
100
  const spo = await this.tokenizer.readToken(new AsfObject.StreamPropertiesObject(header));
51
101
  this.metadata.setFormat('container', `ASF/${spo.streamType}`);
102
+ if (spo.streamType === 'audio') {
103
+ this.metadata.setFormat('hasAudio', true);
104
+ }
105
+ else if (spo.streamType === 'video') {
106
+ this.metadata.setFormat('hasVideo', true);
107
+ }
108
+ this.streams.set(spo.streamNumber, spo);
52
109
  break;
53
110
  }
54
111
  case AsfObject.HeaderExtensionObject.guid.str: {
@@ -85,12 +142,7 @@ export class AsfParser extends BasicParser {
85
142
  const message = error instanceof Error ? error.message : String(error);
86
143
  throw new AsfContentParseError(`Invalid ASF Codec List Object: ${message}`);
87
144
  }
88
- codecs.forEach(codec => {
89
- this.metadata.addStreamInfo({
90
- type: codec.type.videoCodec ? TrackType.video : TrackType.audio,
91
- codecName: codec.codecName
92
- });
93
- });
145
+ this.codecs.push(...codecs);
94
146
  const audioCodecs = codecs
95
147
  .filter(codec => codec.type.audioCodec)
96
148
  .map(codec => codec.codecName)
@@ -98,10 +150,21 @@ export class AsfParser extends BasicParser {
98
150
  this.metadata.setFormat('codec', audioCodecs);
99
151
  break;
100
152
  }
101
- case AsfGuid.StreamBitratePropertiesObject.str:
102
- // ToDo?
103
- await this.ignorePayload(payloadSize, 'Stream Bitrate Properties Object');
153
+ case AsfGuid.StreamBitratePropertiesObject.str: {
154
+ if (payloadSize < 2) {
155
+ throw new AsfContentParseError('Truncated Stream Bitrate Properties Object');
156
+ }
157
+ const count = await this.tokenizer.readToken(Token.UINT16_LE);
158
+ if (count * 6 > payloadSize - 2) {
159
+ throw new AsfContentParseError('ASF stream bitrate records exceed object payload');
160
+ }
161
+ for (let index = 0; index < count; ++index) {
162
+ const streamNumber = (await this.tokenizer.readToken(Token.UINT16_LE)) & 0x7f;
163
+ this.streamBitrates.set(streamNumber, await this.tokenizer.readToken(Token.UINT32_LE));
164
+ }
165
+ await this.ignorePayload(payloadSize - 2 - count * 6, 'Stream Bitrate Properties Object');
104
166
  break;
167
+ }
105
168
  case AsfGuid.PaddingObject.str:
106
169
  // ToDo: register bytes pad
107
170
  debug('Padding: %s bytes', payloadSize);
@@ -170,11 +233,17 @@ export class AsfParser extends BasicParser {
170
233
  const { header, payloadSize } = await this.readObjectHeader(extensionSize, 'extension');
171
234
  // Parse data part of the ASF Object
172
235
  switch (header.objectId.str) {
173
- case AsfObject.ExtendedStreamPropertiesObjectState.guid.str: // 4.1
174
- // ToDo: extended stream header properties are ignored
236
+ case AsfObject.ExtendedStreamPropertiesObjectState.guid.str: {
237
+ // 4.1
175
238
  this.validateAllocationSize(payloadSize, 'Extended Stream Properties Object');
176
- await this.tokenizer.readToken(new AsfObject.ExtendedStreamPropertiesObjectState(header));
239
+ const extended = await this.tokenizer.readToken(new AsfObject.ExtendedStreamPropertiesObjectState(header));
240
+ if (extended.streamPropertiesObject) {
241
+ const stream = extended.streamPropertiesObject;
242
+ this.streams.set(stream.streamNumber, stream);
243
+ this.metadata.setFormat('container', `ASF/${stream.streamType}`);
244
+ }
177
245
  break;
246
+ }
178
247
  case AsfObject.MetadataObjectState.guid.str: {
179
248
  // 4.7
180
249
  this.validateAllocationSize(payloadSize, 'Metadata Object');
@@ -5,6 +5,7 @@ import { TrackTypeValueToKeyMap } from '../type.js';
5
5
  import { CombinedTagMapper } from './CombinedTagMapper.js';
6
6
  import { CommonTagMapper } from './GenericTagMapper.js';
7
7
  import { isSingleton, isUnique } from './GenericTagTypes.js';
8
+ import { normalizeBitrate } from './TrackInfo.js';
8
9
  import { toRatio } from './Util.js';
9
10
  const debug = initDebug('music-metadata:collector');
10
11
  const TagPriority = [
@@ -65,6 +66,9 @@ export class MetadataCollector {
65
66
  }
66
67
  addStreamInfo(streamInfo) {
67
68
  debug(`streamInfo: type=${streamInfo.type ? TrackTypeValueToKeyMap[streamInfo.type] : '?'}, codec=${streamInfo.codecName}`);
69
+ if (normalizeBitrate(streamInfo.bitrate) === undefined) {
70
+ delete streamInfo.bitrate;
71
+ }
68
72
  this.format.trackInfo.push(streamInfo);
69
73
  }
70
74
  setFormat(key, value) {
@@ -0,0 +1,5 @@
1
+ import { type IFormat, type ITrackInfo } from '../type.js';
2
+ /** Return a bitrate only when it conveys a finite, positive value. */
3
+ export declare function normalizeBitrate(bitrate: number | undefined): number | undefined;
4
+ /** Map the format of a single audio stream to the shared track abstraction. */
5
+ export declare function createAudioTrackInfo(format: IFormat): ITrackInfo;
@@ -0,0 +1,39 @@
1
+ import { TrackType } from '../type.js';
2
+ /** Return a bitrate only when it conveys a finite, positive value. */
3
+ export function normalizeBitrate(bitrate) {
4
+ return bitrate !== undefined && Number.isFinite(bitrate) && bitrate > 0 ? bitrate : undefined;
5
+ }
6
+ /** Map the format of a single audio stream to the shared track abstraction. */
7
+ export function createAudioTrackInfo(format) {
8
+ const audio = {};
9
+ const track = { type: TrackType.audio, audio };
10
+ if (format.codec !== undefined) {
11
+ track.codecName = format.codec;
12
+ }
13
+ if (format.codecProfile !== undefined) {
14
+ track.codecProfile = format.codecProfile;
15
+ }
16
+ if (format.duration !== undefined) {
17
+ track.duration = format.duration;
18
+ }
19
+ const bitrate = normalizeBitrate(format.bitrate);
20
+ if (bitrate !== undefined) {
21
+ track.bitrate = bitrate;
22
+ }
23
+ if (format.lossless !== undefined) {
24
+ track.lossless = format.lossless;
25
+ }
26
+ if (format.sampleRate !== undefined) {
27
+ audio.samplingFrequency = format.sampleRate;
28
+ }
29
+ if (format.numberOfChannels !== undefined) {
30
+ audio.channels = format.numberOfChannels;
31
+ }
32
+ if (format.bitsPerSample !== undefined) {
33
+ audio.bitDepth = format.bitsPerSample;
34
+ }
35
+ if (format.numberOfSamples !== undefined) {
36
+ audio.numberOfSamples = format.numberOfSamples;
37
+ }
38
+ return track;
39
+ }
@@ -19,7 +19,7 @@ export declare function stripNulls(str: string): string;
19
19
  * Total offset in bits = byteOffset * 8 + bitOffset
20
20
  * @param source Byte buffer
21
21
  * @param byteOffset Starting offset in bytes
22
- * @param bitOffset Starting offset in bits: 0 = lsb
22
+ * @param bitOffset Starting offset in bits: 0 = most significant bit
23
23
  * @param len Length of number in bits
24
24
  * @return Decoded bit aligned number
25
25
  */
@@ -81,25 +81,19 @@ export function stripNulls(str) {
81
81
  * Total offset in bits = byteOffset * 8 + bitOffset
82
82
  * @param source Byte buffer
83
83
  * @param byteOffset Starting offset in bytes
84
- * @param bitOffset Starting offset in bits: 0 = lsb
84
+ * @param bitOffset Starting offset in bits: 0 = most significant bit
85
85
  * @param len Length of number in bits
86
86
  * @return Decoded bit aligned number
87
87
  */
88
88
  export function getBitAllignedNumber(source, byteOffset, bitOffset, len) {
89
89
  const byteOff = byteOffset + ~~(bitOffset / 8);
90
90
  const bitOff = bitOffset % 8;
91
- let value = source[byteOff];
92
- value &= 0xff >> bitOff;
93
- const bitsRead = 8 - bitOff;
94
- const bitsLeft = len - bitsRead;
95
- if (bitsLeft < 0) {
96
- value >>= 8 - bitOff - len;
91
+ const value = source[byteOff] & (0xff >> bitOff);
92
+ const bitsLeft = len + bitOff - 8;
93
+ if (bitsLeft <= 0) {
94
+ return value >> -bitsLeft;
97
95
  }
98
- else if (bitsLeft > 0) {
99
- value <<= bitsLeft;
100
- value |= getBitAllignedNumber(source, byteOffset, bitOffset + bitsRead, bitsLeft);
101
- }
102
- return value;
96
+ return (value << bitsLeft) | getBitAllignedNumber(source, byteOff + 1, 0, bitsLeft);
103
97
  }
104
98
  /**
105
99
  * Read bit-aligned number start from buffer
package/lib/core.d.ts CHANGED
@@ -7,7 +7,7 @@ import type { IAudioMetadata, INativeTagDict, IOptions, IPicture, IPrivateOption
7
7
  export type { IFileInfo } from 'strtok3';
8
8
  export * from './ParseError.js';
9
9
  export { CouldNotDetermineFileTypeError, UnsupportedFileTypeError } from './ParseError.js';
10
- export { type IAudioMetadata, type IChapter, type ICommonTagsResult, type IFormat, type ILyricsTag, type IMetadataEvent, type IMetadataEventTag, type INativeTagDict, type IOptions, type IPicture, type IRatio, type ITag, LyricsContentType, TimestampFormat } from './type.js';
10
+ export { type IAudioMetadata, type IAudioTrack, type IChapter, type ICommonTagsResult, type IFormat, type ILyricsTag, type IMetadataEvent, type IMetadataEventTag, type INativeTagDict, type IOptions, type IPicture, type IRatio, type ITag, type ITrackInfo, type IVideoTrack, LyricsContentType, TimestampFormat, TrackType } from './type.js';
11
11
  /**
12
12
  * Parse Web API File
13
13
  * Requires Blob to be able to stream using a ReadableStreamBYOBReader, only available since Node.js ≥ 20
package/lib/core.js CHANGED
@@ -8,7 +8,7 @@ import { getLyricsHeaderLength } from './lyrics3/Lyrics3.js';
8
8
  import { ParserFactory } from './ParserFactory.js';
9
9
  export * from './ParseError.js';
10
10
  export { CouldNotDetermineFileTypeError, UnsupportedFileTypeError } from './ParseError.js';
11
- export { LyricsContentType, TimestampFormat } from './type.js';
11
+ export { LyricsContentType, TimestampFormat, TrackType } from './type.js';
12
12
  /**
13
13
  * Parse Web API File
14
14
  * Requires Blob to be able to stream using a ReadableStreamBYOBReader, only available since Node.js ≥ 20