music-metadata 12.1.1 → 12.2.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.
- package/README.md +33 -9
- package/lib/ParserFactory.js +5 -2
- package/lib/asf/AsfParser.d.ts +1 -0
- package/lib/asf/AsfParser.js +24 -2
- package/lib/caf/CafLoader.d.ts +2 -0
- package/lib/caf/CafLoader.js +8 -0
- package/lib/caf/CafParser.d.ts +37 -0
- package/lib/caf/CafParser.js +253 -0
- package/lib/caf/CafTagMap.d.ts +8 -0
- package/lib/caf/CafTagMap.js +38 -0
- package/lib/caf/CafToken.d.ts +75 -0
- package/lib/caf/CafToken.js +135 -0
- package/lib/common/CombinedTagMapper.js +3 -1
- package/lib/common/GenericTagTypes.d.ts +1 -1
- package/lib/common/MetadataCollector.js +9 -1
- package/lib/common/TrackInfo.d.ts +3 -0
- package/lib/common/TrackInfo.js +29 -0
- package/lib/matroska/MatroskaParser.d.ts +4 -0
- package/lib/matroska/MatroskaParser.js +64 -1
- package/lib/matroska/types.d.ts +1 -0
- package/lib/mp4/Atom.js +1 -0
- package/lib/mp4/AtomToken.d.ts +2 -0
- package/lib/mp4/AtomToken.js +25 -8
- package/lib/mp4/MP4Parser.d.ts +4 -0
- package/lib/mp4/MP4Parser.js +189 -76
- package/lib/ogg/OggParser.js +3 -0
- package/lib/ogg/theora/Theora.d.ts +1 -0
- package/lib/ogg/theora/Theora.js +2 -1
- package/lib/ogg/theora/TheoraStream.d.ts +4 -1
- package/lib/ogg/theora/TheoraStream.js +30 -4
- package/lib/type.d.ts +6 -2
- package/package.json +4 -5
package/README.md
CHANGED
|
@@ -51,6 +51,7 @@ If you find this project useful and would like to support its development, consi
|
|
|
51
51
|
| [APE](https://wikipedia.org/wiki/Monkey's_Audio) | Monkey's Audio | <img src="./image/logo-APE-AI.svg" width="40" alt="Monkey's Audio logo"> |
|
|
52
52
|
| [ASF](https://wikipedia.org/wiki/Advanced_Systems_Format) | Advanced Systems Format | |
|
|
53
53
|
| [BWF](https://en.wikipedia.org/wiki/Broadcast_Wave_Format) | Extended WAV format for broadcast and archiving | |
|
|
54
|
+
| [CAF](https://en.wikipedia.org/wiki/Core_Audio_File_Format) | Core Audio File Format | <img src="./image/logo-Apple_Computer_Logo_rainbow.svg" width="40" alt="Apple rainbow logo"> |
|
|
54
55
|
| [DSDIFF](https://wikipedia.org/wiki/Direct_Stream_Digital) | Philips DSDIFF | <img src="./image/logo-DSD.svg" width="80" alt="DSD logo"> |
|
|
55
56
|
| [DSF](https://wikipedia.org/wiki/Direct_Stream_Digital) | Sony's DSD Stream File | <img src="./image/logo-DSD.svg" width="80" alt="DSD logo"> |
|
|
56
57
|
| [FLAC](https://wikipedia.org/wiki/FLAC) | Free Lossless Audio Codec | <img src="./image/logo-FLAC.svg" width="80" alt="FLAC logo"> |
|
|
@@ -80,6 +81,7 @@ Following tag header formats are supported:
|
|
|
80
81
|
- [RIFF](https://wikipedia.org/wiki/Resource_Interchange_File_Format)/INFO
|
|
81
82
|
- [Vorbis comment](https://wikipedia.org/wiki/Vorbis_comment)
|
|
82
83
|
- [AIFF](https://wikipedia.org/wiki/Audio_Interchange_File_Format)
|
|
84
|
+
- [CAF](https://developer.apple.com/library/archive/documentation/MusicAudio/Reference/CAFSpec/CAF_spec/CAF_spec.html)
|
|
83
85
|
|
|
84
86
|
Following lyric formats are supported:
|
|
85
87
|
- [LRC](https://en.wikipedia.org/wiki/LRC_(file_format))
|
|
@@ -666,8 +668,10 @@ Audio format information. Defined in the TypeScript `IFormat` interface:
|
|
|
666
668
|
- `format.codec?` Name of the codec (algorithm used for the audio compression)
|
|
667
669
|
- `format.codecProfile?: string` Codec profile / settings
|
|
668
670
|
- `format.tagTypes?: TagType[]` List of tagging formats found in parsed audio file
|
|
669
|
-
- `format.duration?: number` Duration in seconds
|
|
670
|
-
- `format.bitrate?: number`
|
|
671
|
+
- `format.duration?: number` Duration in seconds (existing audio/container summary; complete presentation duration for video-only files)
|
|
672
|
+
- `format.bitrate?: number` Existing audio/container summary bitrate in bits per second
|
|
673
|
+
- `format.containerDuration?: number` Complete media presentation duration in seconds, including video
|
|
674
|
+
- `format.overallBitrate?: number` Average bitrate of the entire file in bits per second, including container overhead; requires a known file size and presentation duration
|
|
671
675
|
- `format.sampleRate?: number` Sampling rate in Samples per second (S/s)
|
|
672
676
|
- `format.bitsPerSample?: number` Audio bit depth
|
|
673
677
|
- `format.lossless?: boolean` True if lossless, false for lossy encoding
|
|
@@ -686,9 +690,14 @@ metadata. Import `TrackType` from `music-metadata` to select tracks without know
|
|
|
686
690
|
```js
|
|
687
691
|
import { parseFile, TrackType } from 'music-metadata';
|
|
688
692
|
|
|
689
|
-
const { format } = await parseFile('movie.mp4');
|
|
693
|
+
const { format } = await parseFile('movie.mp4', { duration: true });
|
|
690
694
|
const audioTracks = format.trackInfo.filter(track => track.type === TrackType.audio);
|
|
691
695
|
const videoTracks = format.trackInfo.filter(track => track.type === TrackType.video);
|
|
696
|
+
for (const track of videoTracks) {
|
|
697
|
+
console.log(track.duration, track.bitrate, track.video?.pixelWidth, track.video?.pixelHeight);
|
|
698
|
+
console.log(track.video?.frameRate);
|
|
699
|
+
}
|
|
700
|
+
console.log(format.containerDuration, format.overallBitrate);
|
|
692
701
|
```
|
|
693
702
|
|
|
694
703
|
Each entry describes one discovered track. The order follows container discovery and does not identify
|
|
@@ -698,12 +707,27 @@ logical media streams; Skeleton headers do not create media tracks. Single-strea
|
|
|
698
707
|
their format properties to one audio track after parsing.
|
|
699
708
|
|
|
700
709
|
Properties are optional when unavailable and are not copied between tracks. In particular, a container
|
|
701
|
-
or summary bitrate is not a per-track bitrate.
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
710
|
+
or summary bitrate is not a per-track bitrate. The existing `format.duration` and `format.bitrate`
|
|
711
|
+
fields retain their summary semantics. Use `containerDuration` for the complete presentation and
|
|
712
|
+
`overallBitrate` for the actual file size multiplied by eight and divided by that duration. These
|
|
713
|
+
statistics remain unset when timing is unavailable; `overallBitrate` also requires a known input size.
|
|
714
|
+
|
|
715
|
+
MPEG-4 derives audio and video timing from media headers, sample tables, and fragments, including
|
|
716
|
+
fragment defaults and decode timestamps. Track bitrates use sample sizes and duration; video properties
|
|
717
|
+
include encoded dimensions, display dimensions, and average frame rate. ASF supplies stream dimensions,
|
|
718
|
+
stream bitrates, and extended stream timing and frame rate when present. Standalone and nested Stream
|
|
719
|
+
Properties Objects describe actual streams; Codec List entries supply names without creating unused tracks.
|
|
720
|
+
|
|
721
|
+
Matroska applies specified defaults for flags, language, channels, and sample rate, and prefers IETF
|
|
722
|
+
language tags when present. Track statistics tags provide duration, bitrate, and frame count; without
|
|
723
|
+
these tags, per-track timing may be unavailable even when segment duration is known. Media clusters
|
|
724
|
+
are not scanned to calculate missing track statistics.
|
|
725
|
+
|
|
726
|
+
Ogg/Theora duration comes from granule positions. Set `duration: true` to allow scanning beyond the
|
|
727
|
+
initial metadata pages. Once a video stream has been fully read, its average bitrate uses that logical
|
|
728
|
+
stream's payload bytes, including codec headers and excluding Ogg framing and other streams. Resolution
|
|
729
|
+
and frame rate are available from the identification header. Audio streams retain their own codec
|
|
730
|
+
bitrate semantics.
|
|
707
731
|
|
|
708
732
|
For Ogg/Opus, the per-track average bitrate uses the logical stream's payload bytes, including codec
|
|
709
733
|
headers and excluding Ogg framing and other streams. It requires the logical stream's end-of-stream
|
package/lib/ParserFactory.js
CHANGED
|
@@ -5,9 +5,10 @@ import { parse as mimeTypeParse } from 'media-typer';
|
|
|
5
5
|
import { aiffParserLoader } from './aiff/AiffLoader.js';
|
|
6
6
|
import { apeParserLoader } from './apev2/Apev2Loader.js';
|
|
7
7
|
import { asfParserLoader } from './asf/AsfLoader.js';
|
|
8
|
+
import { cafParserLoader } from './caf/CafLoader.js';
|
|
8
9
|
import { getMaxMemoryAllocation } from './common/Allocation.js';
|
|
9
10
|
import { MetadataCollector } from './common/MetadataCollector.js';
|
|
10
|
-
import { createAudioTrackInfo } from './common/TrackInfo.js';
|
|
11
|
+
import { createAudioTrackInfo, finalizeContainerInfo } from './common/TrackInfo.js';
|
|
11
12
|
import { scanAppendingHeaders } from './core.js';
|
|
12
13
|
import { dsdiffParserLoader } from './dsdiff/DsdiffLoader.js';
|
|
13
14
|
import { dsfParserLoader } from './dsf/DsfLoader.js';
|
|
@@ -48,7 +49,8 @@ export class ParserFactory {
|
|
|
48
49
|
wavpackParserLoader,
|
|
49
50
|
musepackParserLoader,
|
|
50
51
|
dsfParserLoader,
|
|
51
|
-
dsdiffParserLoader
|
|
52
|
+
dsdiffParserLoader,
|
|
53
|
+
cafParserLoader
|
|
52
54
|
].forEach(parser => {
|
|
53
55
|
this.registerParser(parser);
|
|
54
56
|
});
|
|
@@ -108,6 +110,7 @@ export class ParserFactory {
|
|
|
108
110
|
metadata.setFormat('hasVideo', !!metadata.format.trackInfo.find(track => track.type === TrackType.video));
|
|
109
111
|
}
|
|
110
112
|
}
|
|
113
|
+
finalizeContainerInfo(metadata, tokenizer.fileInfo.size);
|
|
111
114
|
return metadata.toCommonMetadata();
|
|
112
115
|
}
|
|
113
116
|
/**
|
package/lib/asf/AsfParser.d.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { BasicParser } from '../common/BasicParser.js';
|
|
|
12
12
|
export declare class AsfParser extends BasicParser {
|
|
13
13
|
private streams;
|
|
14
14
|
private codecs;
|
|
15
|
+
private extendedStreams;
|
|
15
16
|
private streamBitrates;
|
|
16
17
|
parse(): Promise<void>;
|
|
17
18
|
private parseObjectHeaders;
|
package/lib/asf/AsfParser.js
CHANGED
|
@@ -22,6 +22,7 @@ const headerType = 'asf';
|
|
|
22
22
|
export class AsfParser extends BasicParser {
|
|
23
23
|
streams = new Map();
|
|
24
24
|
codecs = [];
|
|
25
|
+
extendedStreams = new Map();
|
|
25
26
|
streamBitrates = new Map();
|
|
26
27
|
async parse() {
|
|
27
28
|
const header = await this.readToken(AsfObject.TopLevelHeaderObjectToken);
|
|
@@ -52,6 +53,7 @@ export class AsfParser extends BasicParser {
|
|
|
52
53
|
stream.codecId === new Token.StringType(4, 'ascii').get(codec.information, 0));
|
|
53
54
|
});
|
|
54
55
|
const track = { id: stream.streamNumber, type };
|
|
56
|
+
const extended = this.extendedStreams.get(stream.streamNumber);
|
|
55
57
|
if (stream.codecId !== undefined) {
|
|
56
58
|
track.codecId = stream.codecId;
|
|
57
59
|
}
|
|
@@ -64,7 +66,20 @@ export class AsfParser extends BasicParser {
|
|
|
64
66
|
if (stream.video) {
|
|
65
67
|
track.video = stream.video;
|
|
66
68
|
}
|
|
67
|
-
|
|
69
|
+
if (extended) {
|
|
70
|
+
if (extended.endTime > extended.startTime) {
|
|
71
|
+
track.duration = Number(extended.endTime - extended.startTime) / 1000;
|
|
72
|
+
}
|
|
73
|
+
const name = extended.streamNames[0]?.streamName;
|
|
74
|
+
if (name) {
|
|
75
|
+
track.name = name;
|
|
76
|
+
}
|
|
77
|
+
if (track.video && extended.averageTimePerFrame > 0) {
|
|
78
|
+
track.video.frameRate = 1e7 / extended.averageTimePerFrame;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
const bitrate = this.streamBitrates.get(stream.streamNumber) ??
|
|
82
|
+
(extended && extended.dataBitrate > 0 ? extended.dataBitrate : stream.bitrate);
|
|
68
83
|
if (bitrate !== undefined) {
|
|
69
84
|
track.bitrate = bitrate;
|
|
70
85
|
}
|
|
@@ -90,7 +105,13 @@ export class AsfParser extends BasicParser {
|
|
|
90
105
|
// 3.2
|
|
91
106
|
this.validateAllocationSize(payloadSize, 'File Properties Object');
|
|
92
107
|
const fpo = await this.readToken(new AsfObject.FilePropertiesObject(header));
|
|
93
|
-
|
|
108
|
+
if (!fpo.flags.broadcast) {
|
|
109
|
+
const duration = Number(fpo.playDuration) / 1e7 - Number(fpo.preroll) / 1000;
|
|
110
|
+
if (Number.isFinite(duration) && duration > 0) {
|
|
111
|
+
this.metadata.setFormat('duration', duration);
|
|
112
|
+
this.metadata.setFormat('containerDuration', duration);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
94
115
|
this.metadata.setFormat('bitrate', fpo.maximumBitrate);
|
|
95
116
|
break;
|
|
96
117
|
}
|
|
@@ -235,6 +256,7 @@ export class AsfParser extends BasicParser {
|
|
|
235
256
|
// 4.1
|
|
236
257
|
this.validateAllocationSize(payloadSize, 'Extended Stream Properties Object');
|
|
237
258
|
const extended = await this.readToken(new AsfObject.ExtendedStreamPropertiesObjectState(header));
|
|
259
|
+
this.extendedStreams.set(extended.streamNumber, extended);
|
|
238
260
|
if (extended.streamPropertiesObject) {
|
|
239
261
|
const stream = extended.streamPropertiesObject;
|
|
240
262
|
this.streams.set(stream.streamNumber, stream);
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { BasicParser } from '../common/BasicParser.js';
|
|
2
|
+
/**
|
|
3
|
+
* CAF - Core Audio File Format
|
|
4
|
+
*
|
|
5
|
+
* Ref: https://developer.apple.com/library/archive/documentation/MusicAudio/Reference/CAFSpec/CAF_spec/CAF_spec.html
|
|
6
|
+
*/
|
|
7
|
+
export declare class CafParser extends BasicParser {
|
|
8
|
+
private desc;
|
|
9
|
+
private packetTable;
|
|
10
|
+
private dataBytes;
|
|
11
|
+
private expectedFirstChunk;
|
|
12
|
+
private reachedEndOfFile;
|
|
13
|
+
parse(): Promise<void>;
|
|
14
|
+
private endOfFileReached;
|
|
15
|
+
private readChunk;
|
|
16
|
+
private readAudioDescription;
|
|
17
|
+
private applyAudioDescription;
|
|
18
|
+
private readPacketTable;
|
|
19
|
+
private readAudioData;
|
|
20
|
+
private readInfo;
|
|
21
|
+
private readChannelLayout;
|
|
22
|
+
private addChannelLayout;
|
|
23
|
+
/**
|
|
24
|
+
* Rejects a whole chunk whose declared size exceeds the remaining input.
|
|
25
|
+
* Only applicable when the total input size is known.
|
|
26
|
+
*/
|
|
27
|
+
private validateChunkAvailability;
|
|
28
|
+
/**
|
|
29
|
+
* Reads a chunk body without trusting its declared size.
|
|
30
|
+
* @param chunkSize Declared body size
|
|
31
|
+
* @param chunkType Chunk name for error messages
|
|
32
|
+
*/
|
|
33
|
+
private readChunkBody;
|
|
34
|
+
private skipUnknownChunk;
|
|
35
|
+
private applyDerivedFormat;
|
|
36
|
+
private deriveFrameCount;
|
|
37
|
+
}
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import initDebug from 'debug';
|
|
2
|
+
import * as strtok3 from 'strtok3';
|
|
3
|
+
import * as Token from 'token-types';
|
|
4
|
+
import { BasicParser } from '../common/BasicParser.js';
|
|
5
|
+
import * as CafToken from './CafToken.js';
|
|
6
|
+
import { CafContentError } from './CafToken.js';
|
|
7
|
+
const debug = initDebug('music-metadata:parser:caf');
|
|
8
|
+
// Unrecognised mFormatID values are reported as-is, without claiming losslessness.
|
|
9
|
+
const codecByFormatId = {
|
|
10
|
+
lpcm: { codec: 'PCM', lossless: true },
|
|
11
|
+
ima4: { codec: 'IMA4 ADPCM', lossless: false },
|
|
12
|
+
alac: { codec: 'ALAC', lossless: true },
|
|
13
|
+
flac: { codec: 'FLAC', lossless: true },
|
|
14
|
+
ulaw: { codec: 'ITU-T G.711 mu-law', lossless: false },
|
|
15
|
+
alaw: { codec: 'ITU-T G.711 A-law', lossless: false },
|
|
16
|
+
'.mp1': { codec: 'MPEG-1 Layer I', lossless: false },
|
|
17
|
+
'.mp2': { codec: 'MPEG-1 Layer II', lossless: false },
|
|
18
|
+
'.mp3': { codec: 'MPEG-1 Layer III', lossless: false },
|
|
19
|
+
'aac ': { codec: 'AAC', lossless: false },
|
|
20
|
+
aace: { codec: 'AAC', lossless: false },
|
|
21
|
+
aacf: { codec: 'AAC', lossless: false },
|
|
22
|
+
aacg: { codec: 'AAC', lossless: false },
|
|
23
|
+
aach: { codec: 'AAC', lossless: false },
|
|
24
|
+
aacl: { codec: 'AAC', lossless: false },
|
|
25
|
+
aacp: { codec: 'AAC', lossless: false }
|
|
26
|
+
};
|
|
27
|
+
const editCountLen = 4;
|
|
28
|
+
const numEntriesLen = 4;
|
|
29
|
+
/**
|
|
30
|
+
* CAF - Core Audio File Format
|
|
31
|
+
*
|
|
32
|
+
* Ref: https://developer.apple.com/library/archive/documentation/MusicAudio/Reference/CAFSpec/CAF_spec/CAF_spec.html
|
|
33
|
+
*/
|
|
34
|
+
export class CafParser extends BasicParser {
|
|
35
|
+
desc;
|
|
36
|
+
packetTable;
|
|
37
|
+
dataBytes;
|
|
38
|
+
expectedFirstChunk = true;
|
|
39
|
+
reachedEndOfFile = false;
|
|
40
|
+
async parse() {
|
|
41
|
+
const header = await this.tokenizer.readToken(CafToken.FileHeader);
|
|
42
|
+
if (header.fileType !== 'caff') {
|
|
43
|
+
throw new CafContentError(`Invalid file-type, expected 'caff', found '${header.fileType}'`);
|
|
44
|
+
}
|
|
45
|
+
this.metadata.setFormat('container', 'CAF');
|
|
46
|
+
this.metadata.setAudioOnly();
|
|
47
|
+
while (!this.reachedEndOfFile && !this.endOfFileReached()) {
|
|
48
|
+
let chunkHeader;
|
|
49
|
+
try {
|
|
50
|
+
chunkHeader = await this.tokenizer.readToken(CafToken.ChunkHeader);
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
if (error instanceof strtok3.EndOfStreamError) {
|
|
54
|
+
debug('End-of-stream');
|
|
55
|
+
break;
|
|
56
|
+
}
|
|
57
|
+
throw error;
|
|
58
|
+
}
|
|
59
|
+
debug(`Reading CAF chunk type=${chunkHeader.chunkType} size=${chunkHeader.chunkSize}`);
|
|
60
|
+
if (this.expectedFirstChunk) {
|
|
61
|
+
this.expectedFirstChunk = false;
|
|
62
|
+
if (chunkHeader.chunkType !== 'desc') {
|
|
63
|
+
throw new CafContentError(`Expected an Audio Description chunk, found '${chunkHeader.chunkType}'`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
await this.readChunk(chunkHeader);
|
|
67
|
+
}
|
|
68
|
+
if (!this.desc) {
|
|
69
|
+
throw new CafContentError('Missing Audio Description chunk');
|
|
70
|
+
}
|
|
71
|
+
this.applyDerivedFormat();
|
|
72
|
+
}
|
|
73
|
+
endOfFileReached() {
|
|
74
|
+
const { size } = this.tokenizer.fileInfo;
|
|
75
|
+
return size !== undefined && size - this.tokenizer.position < CafToken.ChunkHeader.len;
|
|
76
|
+
}
|
|
77
|
+
async readChunk(header) {
|
|
78
|
+
switch (header.chunkType) {
|
|
79
|
+
case 'desc':
|
|
80
|
+
return this.readAudioDescription(header.chunkSize);
|
|
81
|
+
case 'pakt':
|
|
82
|
+
return this.readPacketTable(header.chunkSize);
|
|
83
|
+
case 'data':
|
|
84
|
+
return this.readAudioData(header.chunkSize);
|
|
85
|
+
case 'info':
|
|
86
|
+
return this.readInfo(header.chunkSize);
|
|
87
|
+
case 'chan':
|
|
88
|
+
return this.readChannelLayout(header.chunkSize);
|
|
89
|
+
default:
|
|
90
|
+
debug(`Ignore chunk type=${header.chunkType} size=${header.chunkSize}`);
|
|
91
|
+
return this.skipUnknownChunk(header.chunkSize);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
async readAudioDescription(chunkSize) {
|
|
95
|
+
if (chunkSize < CafToken.AudioDescription.len) {
|
|
96
|
+
throw new CafContentError(`Audio Description chunk size ${chunkSize} is smaller than ${CafToken.AudioDescription.len}`);
|
|
97
|
+
}
|
|
98
|
+
this.desc = await this.tokenizer.readToken(CafToken.AudioDescription);
|
|
99
|
+
await this.tokenizer.ignore(chunkSize - CafToken.AudioDescription.len);
|
|
100
|
+
this.applyAudioDescription(this.desc);
|
|
101
|
+
}
|
|
102
|
+
applyAudioDescription(desc) {
|
|
103
|
+
this.metadata.setFormat('sampleRate', desc.sampleRate);
|
|
104
|
+
this.metadata.setFormat('numberOfChannels', desc.channelsPerFrame);
|
|
105
|
+
if (desc.bitsPerChannel > 0) {
|
|
106
|
+
this.metadata.setFormat('bitsPerSample', desc.bitsPerChannel);
|
|
107
|
+
}
|
|
108
|
+
const codec = codecByFormatId[desc.formatId];
|
|
109
|
+
this.metadata.setFormat('codec', codec ? codec.codec : desc.formatId);
|
|
110
|
+
if (codec) {
|
|
111
|
+
this.metadata.setFormat('lossless', codec.lossless);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
async readPacketTable(chunkSize) {
|
|
115
|
+
const desc = this.desc;
|
|
116
|
+
if (!desc) {
|
|
117
|
+
throw new CafContentError('Packet Table chunk found before the Audio Description chunk');
|
|
118
|
+
}
|
|
119
|
+
if (chunkSize < CafToken.PacketTableHeader.len) {
|
|
120
|
+
throw new CafContentError(`Packet Table chunk size ${chunkSize} is smaller than ${CafToken.PacketTableHeader.len}`);
|
|
121
|
+
}
|
|
122
|
+
this.validateChunkAvailability(chunkSize, 'Packet Table');
|
|
123
|
+
const packetTable = await this.tokenizer.readToken(CafToken.PacketTableHeader);
|
|
124
|
+
const body = await this.readChunkBody(chunkSize - CafToken.PacketTableHeader.len, 'Packet Table');
|
|
125
|
+
CafToken.decodePacketTableEntries(body, desc, packetTable.numberPackets);
|
|
126
|
+
this.packetTable = packetTable;
|
|
127
|
+
}
|
|
128
|
+
async readAudioData(chunkSize) {
|
|
129
|
+
if (chunkSize >= 0 && chunkSize < editCountLen) {
|
|
130
|
+
throw new CafContentError(`Audio Data chunk size ${chunkSize} is too small to hold mEditCount`);
|
|
131
|
+
}
|
|
132
|
+
if (chunkSize >= 0) {
|
|
133
|
+
this.validateChunkAvailability(chunkSize, 'Audio Data');
|
|
134
|
+
}
|
|
135
|
+
if ((await this.tokenizer.ignore(editCountLen)) !== editCountLen) {
|
|
136
|
+
throw new strtok3.EndOfStreamError();
|
|
137
|
+
}
|
|
138
|
+
if (chunkSize === -1) {
|
|
139
|
+
this.reachedEndOfFile = true; // Runs to the end of the file, nothing can follow
|
|
140
|
+
const { size } = this.tokenizer.fileInfo;
|
|
141
|
+
if (size === undefined) {
|
|
142
|
+
debug('Audio Data chunk has an unknown size, cannot derive the bitrate');
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
this.dataBytes = size - this.tokenizer.position;
|
|
146
|
+
await this.tokenizer.ignore(this.dataBytes);
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
const dataBytes = chunkSize - editCountLen;
|
|
150
|
+
if ((await this.tokenizer.ignore(dataBytes)) !== dataBytes) {
|
|
151
|
+
throw new strtok3.EndOfStreamError();
|
|
152
|
+
}
|
|
153
|
+
this.dataBytes = dataBytes;
|
|
154
|
+
}
|
|
155
|
+
async readInfo(chunkSize) {
|
|
156
|
+
if (chunkSize < numEntriesLen) {
|
|
157
|
+
throw new CafContentError(`Information chunk size ${chunkSize} is too small to hold mNumEntries`);
|
|
158
|
+
}
|
|
159
|
+
const body = await this.readChunkBody(chunkSize, 'Information');
|
|
160
|
+
for (const { key, value } of CafToken.parseInfoEntries(body)) {
|
|
161
|
+
debug(`info key=${key}`);
|
|
162
|
+
await this.metadata.addTag('CAF', key, value);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
async readChannelLayout(chunkSize) {
|
|
166
|
+
if (chunkSize < CafToken.ChannelLayoutHeader.len) {
|
|
167
|
+
throw new CafContentError(`Channel Layout chunk size ${chunkSize} is smaller than ${CafToken.ChannelLayoutHeader.len}`);
|
|
168
|
+
}
|
|
169
|
+
this.validateChunkAvailability(chunkSize, 'Channel Layout');
|
|
170
|
+
const header = await this.tokenizer.readToken(CafToken.ChannelLayoutHeader);
|
|
171
|
+
const descriptionLen = header.numberChannelDescriptions * CafToken.channelDescriptionLen;
|
|
172
|
+
const bodyLen = CafToken.ChannelLayoutHeader.len + descriptionLen;
|
|
173
|
+
if (bodyLen > chunkSize) {
|
|
174
|
+
throw new CafContentError(`Channel Layout chunk declares ${header.numberChannelDescriptions} description(s) which exceed its size of ${chunkSize}`);
|
|
175
|
+
}
|
|
176
|
+
const descriptions = descriptionLen > 0
|
|
177
|
+
? CafToken.parseChannelDescriptions(await this.readChunkBody(descriptionLen, 'Channel Layout'), header.numberChannelDescriptions)
|
|
178
|
+
: [];
|
|
179
|
+
await this.addChannelLayout(header, descriptions);
|
|
180
|
+
await this.tokenizer.ignore(chunkSize - bodyLen);
|
|
181
|
+
}
|
|
182
|
+
async addChannelLayout(header, descriptions) {
|
|
183
|
+
await this.metadata.addTag('CAF', 'channelLayoutTag', header.channelLayoutTag);
|
|
184
|
+
await this.metadata.addTag('CAF', 'channelLayoutBitmap', header.channelBitmap);
|
|
185
|
+
if (descriptions.length > 0) {
|
|
186
|
+
// No common tag describes channel order so keep the complete descriptions
|
|
187
|
+
await this.metadata.addTag('CAF', 'channelLayoutDescriptions', descriptions);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Rejects a whole chunk whose declared size exceeds the remaining input.
|
|
192
|
+
* Only applicable when the total input size is known.
|
|
193
|
+
*/
|
|
194
|
+
validateChunkAvailability(chunkSize, chunkType) {
|
|
195
|
+
const { size } = this.tokenizer.fileInfo;
|
|
196
|
+
if (size !== undefined) {
|
|
197
|
+
const available = size - this.tokenizer.position;
|
|
198
|
+
if (chunkSize > available) {
|
|
199
|
+
throw new CafContentError(`${chunkType} chunk size ${chunkSize} exceeds available input size ${available}`);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Reads a chunk body without trusting its declared size.
|
|
205
|
+
* @param chunkSize Declared body size
|
|
206
|
+
* @param chunkType Chunk name for error messages
|
|
207
|
+
*/
|
|
208
|
+
async readChunkBody(chunkSize, chunkType) {
|
|
209
|
+
this.validateChunkAvailability(chunkSize, chunkType);
|
|
210
|
+
return this.readToken(new Token.Uint8ArrayType(chunkSize));
|
|
211
|
+
}
|
|
212
|
+
async skipUnknownChunk(chunkSize) {
|
|
213
|
+
if (chunkSize < 0) {
|
|
214
|
+
throw new CafContentError(`Chunk size ${chunkSize} may only be -1 for an Audio Data chunk`);
|
|
215
|
+
}
|
|
216
|
+
await this.tokenizer.ignore(chunkSize);
|
|
217
|
+
}
|
|
218
|
+
// Deferred: the packet table and audio payload may arrive after the description
|
|
219
|
+
applyDerivedFormat() {
|
|
220
|
+
const desc = this.desc;
|
|
221
|
+
if (!desc || desc.sampleRate <= 0) {
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
const frames = this.deriveFrameCount(desc);
|
|
225
|
+
if (frames === undefined) {
|
|
226
|
+
debug('Unable to derive the number of sample frames');
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
const duration = frames / desc.sampleRate;
|
|
230
|
+
this.metadata.setFormat('numberOfSamples', frames);
|
|
231
|
+
this.metadata.setFormat('duration', duration);
|
|
232
|
+
if (this.dataBytes !== undefined) {
|
|
233
|
+
this.metadata.setFormat('bitrate', (8 * this.dataBytes) / duration);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
deriveFrameCount(desc) {
|
|
237
|
+
const packetTable = this.packetTable;
|
|
238
|
+
if (packetTable) {
|
|
239
|
+
// Signed field, writers emit negative values. Already excludes priming and remainder frames.
|
|
240
|
+
if (packetTable.numberValidFrames > 0) {
|
|
241
|
+
return packetTable.numberValidFrames;
|
|
242
|
+
}
|
|
243
|
+
debug(`Ignoring packet table with mNumberValidFrames=${packetTable.numberValidFrames}`);
|
|
244
|
+
}
|
|
245
|
+
if (desc.bytesPerPacket > 0 && desc.framesPerPacket > 0 && this.dataBytes !== undefined) {
|
|
246
|
+
const frames = (this.dataBytes * desc.framesPerPacket) / desc.bytesPerPacket;
|
|
247
|
+
if (frames > 0) {
|
|
248
|
+
return frames;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
return undefined;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { CommonTagMapper } from '../common/GenericTagMapper.js';
|
|
2
|
+
import type { IGenericTag } from '../common/GenericTagTypes.js';
|
|
3
|
+
import type { IWarningCollector } from '../common/MetadataCollector.js';
|
|
4
|
+
import type { ITag } from '../type.js';
|
|
5
|
+
export declare class CafTagMapper extends CommonTagMapper {
|
|
6
|
+
constructor();
|
|
7
|
+
mapGenericTag(tag: ITag, warnings: IWarningCollector): IGenericTag | null;
|
|
8
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { CommonTagMapper } from '../common/GenericTagMapper.js';
|
|
2
|
+
// "time signature", "nominal bit rate" and "channel layout" have no common
|
|
3
|
+
// equivalent and stay native. Undocumented keys are always kept natively too.
|
|
4
|
+
const infoTagMap = {
|
|
5
|
+
tempo: 'bpm',
|
|
6
|
+
'key signature': 'key',
|
|
7
|
+
artist: 'artist',
|
|
8
|
+
album: 'album',
|
|
9
|
+
'track number': 'track',
|
|
10
|
+
year: 'year',
|
|
11
|
+
composer: 'composer',
|
|
12
|
+
lyricist: 'lyricist',
|
|
13
|
+
genre: 'genre',
|
|
14
|
+
title: 'title',
|
|
15
|
+
'recorded date': 'date',
|
|
16
|
+
comments: 'comment',
|
|
17
|
+
copyright: 'copyright',
|
|
18
|
+
'source encoder': 'encodersettings',
|
|
19
|
+
'encoding application': 'encodedby'
|
|
20
|
+
};
|
|
21
|
+
const commaSeparatedKeys = new Set(['artist', 'composer', 'lyricist', 'genre']);
|
|
22
|
+
export class CafTagMapper extends CommonTagMapper {
|
|
23
|
+
constructor() {
|
|
24
|
+
super(['CAF'], infoTagMap);
|
|
25
|
+
}
|
|
26
|
+
mapGenericTag(tag, warnings) {
|
|
27
|
+
if (commaSeparatedKeys.has(tag.id) && typeof tag.value === 'string') {
|
|
28
|
+
tag = {
|
|
29
|
+
id: tag.id,
|
|
30
|
+
value: tag.value
|
|
31
|
+
.split(',')
|
|
32
|
+
.map(value => value.trim())
|
|
33
|
+
.filter(value => value.length > 0)
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
return super.mapGenericTag(tag, warnings);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { IGetToken } from 'strtok3';
|
|
2
|
+
declare const CafContentError_base: {
|
|
3
|
+
new (message: string): {
|
|
4
|
+
message: string;
|
|
5
|
+
stack?: string;
|
|
6
|
+
cause?: unknown;
|
|
7
|
+
name: "UnexpectedFileContentError";
|
|
8
|
+
readonly fileType: string;
|
|
9
|
+
toString(): string;
|
|
10
|
+
};
|
|
11
|
+
captureStackTrace(targetObject: object, constructorOpt?: Function): void;
|
|
12
|
+
prepareStackTrace(err: Error, stackTraces: NodeJS.CallSite[]): any;
|
|
13
|
+
stackTraceLimit: number;
|
|
14
|
+
};
|
|
15
|
+
export declare class CafContentError extends CafContentError_base {
|
|
16
|
+
}
|
|
17
|
+
export interface ICafFileHeader {
|
|
18
|
+
fileType: string;
|
|
19
|
+
fileVersion: number;
|
|
20
|
+
fileFlags: number;
|
|
21
|
+
}
|
|
22
|
+
export declare const FileHeader: IGetToken<ICafFileHeader>;
|
|
23
|
+
export interface ICafChunkHeader {
|
|
24
|
+
chunkType: string;
|
|
25
|
+
/** Size of the data section, -1 if the Audio Data chunk runs to the end of file */
|
|
26
|
+
chunkSize: number;
|
|
27
|
+
}
|
|
28
|
+
export declare const ChunkHeader: IGetToken<ICafChunkHeader>;
|
|
29
|
+
export interface IAudioDescription {
|
|
30
|
+
sampleRate: number;
|
|
31
|
+
formatId: string;
|
|
32
|
+
formatFlags: number;
|
|
33
|
+
bytesPerPacket: number;
|
|
34
|
+
framesPerPacket: number;
|
|
35
|
+
channelsPerFrame: number;
|
|
36
|
+
bitsPerChannel: number;
|
|
37
|
+
}
|
|
38
|
+
/** CAFAudioFormat: Float64 mSampleRate followed by six UInt32 fields */
|
|
39
|
+
export declare const AudioDescription: IGetToken<IAudioDescription>;
|
|
40
|
+
export interface IPacketTableHeader {
|
|
41
|
+
numberPackets: number;
|
|
42
|
+
numberValidFrames: number;
|
|
43
|
+
primingFrames: number;
|
|
44
|
+
remainderFrames: number;
|
|
45
|
+
}
|
|
46
|
+
/** Fixed 24 byte part of a Packet Table chunk */
|
|
47
|
+
export declare const PacketTableHeader: IGetToken<IPacketTableHeader>;
|
|
48
|
+
/**
|
|
49
|
+
* Decodes the base-128, MSB-first integers that follow a Packet Table header,
|
|
50
|
+
* high-order bit set meaning the number continues in the next byte.
|
|
51
|
+
* @returns number of bytes occupied by the entries
|
|
52
|
+
*/
|
|
53
|
+
export declare function decodePacketTableEntries(body: Uint8Array, desc: IAudioDescription, numberPackets: number): number;
|
|
54
|
+
export interface IChannelLayoutHeader {
|
|
55
|
+
channelLayoutTag: number;
|
|
56
|
+
channelBitmap: number;
|
|
57
|
+
numberChannelDescriptions: number;
|
|
58
|
+
}
|
|
59
|
+
/** Leading 12 bytes of a Channel Layout chunk, mChannelBitmap is always present */
|
|
60
|
+
export declare const ChannelLayoutHeader: IGetToken<IChannelLayoutHeader>;
|
|
61
|
+
export interface IChannelDescription {
|
|
62
|
+
channelLabel: number;
|
|
63
|
+
channelFlags: number;
|
|
64
|
+
coordinates: [number, number, number];
|
|
65
|
+
}
|
|
66
|
+
export declare const channelDescriptionLen = 20;
|
|
67
|
+
/** Each CAFChannelDescription has a UInt32 label, UInt32 flags and three Float32 coordinates */
|
|
68
|
+
export declare function parseChannelDescriptions(body: Uint8Array, count: number): IChannelDescription[];
|
|
69
|
+
export interface IInfoEntry {
|
|
70
|
+
key: string;
|
|
71
|
+
value: string;
|
|
72
|
+
}
|
|
73
|
+
/** Reads the NUL terminated UTF-8 pairs, the declared entry count is authoritative */
|
|
74
|
+
export declare function parseInfoEntries(body: Uint8Array): IInfoEntry[];
|
|
75
|
+
export {};
|