diff --git a/dev/convert.html b/dev/convert.html
index 53d7903..a58ddec 100644
--- a/dev/convert.html
+++ b/dev/convert.html
@@ -24,7 +24,7 @@
chunked: true,
chunkSize: 2**20
});
- const outputFormat = new Mediabunny.OggOutputFormat({});
+ const outputFormat = new Mediabunny.WavOutputFormat({});
const button = document.createElement('button');
button.textContent = 'Cancel';
@@ -129,7 +129,7 @@
}),
trim: {
start: 0,
- end: 1
+ end: 10
},
});
console.log(conversion);
diff --git a/src/adts/adts-demuxer.ts b/src/adts/adts-demuxer.ts
index 794160c..63a5ceb 100644
--- a/src/adts/adts-demuxer.ts
+++ b/src/adts/adts-demuxer.ts
@@ -125,6 +125,10 @@ export class AdtsDemuxer extends Demuxer {
return track.computeDuration();
}
+
+ async getMetadata() {
+ return {}; // No metadata in this one
+ }
}
class AdtsAudioTrackBacking implements InputAudioTrackBacking {
diff --git a/src/wave/wave-demuxer.ts b/src/wave/wave-demuxer.ts
index 43795f5..6f4799e 100644
--- a/src/wave/wave-demuxer.ts
+++ b/src/wave/wave-demuxer.ts
@@ -11,6 +11,7 @@ import { Demuxer } from '../demuxer';
import { Input } from '../input';
import { InputAudioTrack, InputAudioTrackBacking } from '../input-track';
import { PacketRetrievalOptions } from '../media-sink';
+import { MediaMetadata } from '../metadata';
import { assert, UNDETERMINED_LANGUAGE } from '../misc';
import { EncodedPacket, PLACEHOLDER_DATA } from '../packet';
import { readAscii, readBytes, Reader, readU16, readU32, readU64 } from '../reader';
@@ -39,6 +40,7 @@ export class WaveDemuxer extends Demuxer {
tracks: InputAudioTrack[] = [];
lastKnownPacketIndex = 0;
+ metadata: MediaMetadata = {};
constructor(input: Input) {
super(input);
@@ -92,6 +94,10 @@ export class WaveDemuxer extends Demuxer {
this.dataStart = slice.filePos;
this.dataSize = Math.min(dataChunkSize, (totalFileSize ?? Infinity) - this.dataStart);
+
+ if (this.reader.fileSize === null) {
+ break; // Stop once we hit the data chunk
+ }
} else if (chunkId === 'ds64') {
// File and data chunk sizes are defined in here instead
@@ -99,6 +105,8 @@ export class WaveDemuxer extends Demuxer {
dataChunkSize = readU64(slice, littleEndian);
totalFileSize = Math.min(riffChunkSize + 8, this.reader.fileSize ?? Infinity);
+ } else if (chunkId === 'LIST') {
+ await this.parseListChunk(startPos, chunkSize, littleEndian);
}
currentPos = startPos + chunkSize + (chunkSize & 1); // Handle padding
@@ -167,6 +175,102 @@ export class WaveDemuxer extends Demuxer {
};
}
+ private async parseListChunk(startPos: number, size: number, littleEndian: boolean) {
+ let slice = this.reader.requestSlice(startPos, size);
+ if (slice instanceof Promise) slice = await slice;
+ if (!slice) return; // File too short
+
+ const infoType = readAscii(slice, 4);
+ if (infoType !== 'INFO' && infoType !== 'INF0') { // exiftool.org claims INF0 can happen
+ return; // Not an INFO chunk
+ }
+
+ let currentPos = slice.filePos;
+ while (currentPos <= startPos + size - 8) {
+ slice.filePos = currentPos;
+
+ const chunkName = readAscii(slice, 4);
+ const chunkSize = readU32(slice, littleEndian);
+ const bytes = readBytes(slice, chunkSize);
+
+ let stringLength = 0;
+ for (let i = 0; i < bytes.length; i++) {
+ if (bytes[i] === 0) {
+ break;
+ }
+
+ stringLength++;
+ }
+
+ const value = String.fromCharCode(...bytes.subarray(0, stringLength));
+
+ this.metadata.raw ??= {};
+ this.metadata.raw[chunkName] = value;
+
+ switch (chunkName) {
+ case 'INAM':
+ case 'TITL': {
+ this.metadata.title ??= value;
+ }; break;
+
+ case 'TIT3': {
+ this.metadata.description ??= value;
+ }; break;
+
+ case 'IART': {
+ this.metadata.artist ??= value;
+ }; break;
+
+ case 'IPRD': {
+ this.metadata.album ??= value;
+ }; break;
+
+ case 'IPRT':
+ case 'ITRK':
+ case 'TRCK': {
+ const parts = value.split('/');
+ const trackNum = Number.parseInt(parts[0]!, 10);
+ const trackNumMax = parts[1] && Number.parseInt(parts[1], 10);
+
+ if (Number.isInteger(trackNum) && trackNum > 0) {
+ this.metadata.trackNumber ??= trackNum;
+ }
+ if (trackNumMax && Number.isInteger(trackNumMax) && trackNumMax > 0) {
+ this.metadata.trackNumberMax ??= trackNumMax;
+ }
+ }; break;
+
+ case 'ICRD':
+ case 'IDIT': {
+ const date = new Date(value);
+ if (!Number.isNaN(date.getTime())) {
+ this.metadata.date ??= date;
+ }
+ }; break;
+
+ case 'YEAR': {
+ const year = Number.parseInt(value, 10);
+ if (Number.isInteger(year) && year > 0) {
+ this.metadata.date ??= new Date(year, 0, 1);
+ }
+ }; break;
+
+ case 'IGNR':
+ case 'GENR': {
+ this.metadata.genre ??= value;
+ }; break;
+
+ case 'ICMT':
+ case 'CMNT':
+ case 'COMM': {
+ this.metadata.comment ??= value;
+ }; break;
+ }
+
+ currentPos += 8 + chunkSize + (chunkSize & 1); // Handle padding
+ }
+ }
+
getCodec(): AudioCodec | null {
assert(this.audioInfo);
@@ -214,6 +318,11 @@ export class WaveDemuxer extends Demuxer {
await this.readMetadata();
return this.tracks;
}
+
+ async getMetadata() {
+ await this.readMetadata();
+ return this.metadata;
+ }
}
const PACKET_SIZE_IN_FRAMES = 2048;
diff --git a/src/wave/wave-muxer.ts b/src/wave/wave-muxer.ts
index 878939f..33efd3f 100644
--- a/src/wave/wave-muxer.ts
+++ b/src/wave/wave-muxer.ts
@@ -14,7 +14,8 @@ import { RiffWriter } from './riff-writer';
import { Writer } from '../writer';
import { EncodedPacket } from '../packet';
import { WavOutputFormat } from '../output-format';
-import { assert } from '../misc';
+import { assert, assertNever, keyValueIterator } from '../misc';
+import { MediaMetadata, mediaMetadataIsEmpty } from '../metadata';
export class WaveMuxer extends Muxer {
private format: WavOutputFormat;
@@ -26,6 +27,12 @@ export class WaveMuxer extends Muxer {
private sampleRate: number | null = null;
private sampleCount = 0;
+ private riffSizePos: number | null = null;
+ private dataSizePos: number | null = null;
+ private ds64RiffSizePos: number | null = null;
+ private ds64DataSizePos: number | null = null;
+ private ds64SampleCountPos: number | null = null;
+
constructor(output: Output, format: WavOutputFormat) {
super(output);
@@ -119,6 +126,7 @@ export class WaveMuxer extends Muxer {
if (this.isRf64) {
this.riffWriter.writeU32(0xffffffff); // Not used in RF64
} else {
+ this.riffSizePos = this.writer.getPos();
this.riffWriter.writeU32(0); // File size placeholder
}
@@ -127,9 +135,16 @@ export class WaveMuxer extends Muxer {
if (this.isRf64) {
this.riffWriter.writeAscii('ds64');
this.riffWriter.writeU32(28); // Chunk size
+
+ this.ds64RiffSizePos = this.writer.getPos();
this.riffWriter.writeU64(0); // RIFF size placeholder
+
+ this.ds64DataSizePos = this.writer.getPos();
this.riffWriter.writeU64(0); // Data size placeholder
+
+ this.ds64SampleCountPos = this.writer.getPos();
this.riffWriter.writeU64(0); // Sample count placeholder
+
this.riffWriter.writeU32(0); // Table length
// Empty table
}
@@ -144,12 +159,18 @@ export class WaveMuxer extends Muxer {
this.riffWriter.writeU16(blockSize);
this.riffWriter.writeU16(8 * pcmInfo.sampleSize);
+ if (!mediaMetadataIsEmpty(this.output._metadata)) {
+ // Metadata exists, let's write an INFO chunk
+ this.writeInfoChunk(this.output._metadata);
+ }
+
// data chunk
this.riffWriter.writeAscii('data');
if (this.isRf64) {
this.riffWriter.writeU32(0xffffffff); // Not used in RF64
} else {
+ this.dataSizePos = this.writer.getPos();
this.riffWriter.writeU32(0); // Data size placeholder
}
@@ -159,6 +180,118 @@ export class WaveMuxer extends Muxer {
}
}
+ private writeInfoChunk(metadata: MediaMetadata) {
+ const startPos = this.writer.getPos();
+
+ this.riffWriter.writeAscii('LIST');
+ this.riffWriter.writeU32(0); // Size placeholder
+ this.riffWriter.writeAscii('INFO');
+
+ const writtenTags = new Set();
+
+ for (const { key, value } of keyValueIterator(metadata)) {
+ switch (key) {
+ case 'title': {
+ this.writeInfoTag('INAM', value);
+ writtenTags.add('INAM');
+ }; break;
+
+ case 'artist': {
+ this.writeInfoTag('IART', value);
+ writtenTags.add('IART');
+ }; break;
+
+ case 'album': {
+ this.writeInfoTag('IPRD', value);
+ writtenTags.add('IPRD');
+ }; break;
+
+ case 'trackNumber': {
+ const string = metadata.trackNumberMax !== undefined
+ ? `${value}/${metadata.trackNumberMax}`
+ : value.toString();
+
+ this.writeInfoTag('ITRK', string);
+ writtenTags.add('ITRK');
+ }; break;
+
+ case 'genre': {
+ this.writeInfoTag('IGNR', value);
+ writtenTags.add('IGNR');
+ }; break;
+
+ case 'date': {
+ this.writeInfoTag('ICRD', value.toISOString().slice(0, 10));
+ writtenTags.add('ICRD');
+ }; break;
+
+ case 'comment': {
+ this.writeInfoTag('ICMT', value);
+ writtenTags.add('ICMT');
+ }; break;
+
+ case 'albumArtist':
+ case 'discNumber':
+ case 'trackNumberMax':
+ case 'discNumberMax':
+ case 'description':
+ case 'lyrics':
+ case 'images': {
+ // Not supported in RIFF INFO
+ }; break;
+
+ case 'raw': {
+ // Handled later
+ }; break;
+
+ default: assertNever(key);
+ }
+ }
+
+ if (metadata.raw) {
+ for (const key in metadata.raw) {
+ const value = metadata.raw[key];
+ if (value == null || key.length !== 4 || writtenTags.has(key)) {
+ continue;
+ }
+
+ if (typeof value === 'string') {
+ this.writeInfoTag(key, value);
+ }
+ }
+ }
+
+ const endPos = this.writer.getPos();
+ const chunkSize = endPos - startPos - 8;
+
+ this.writer.seek(startPos + 4);
+ this.riffWriter.writeU32(chunkSize);
+ this.writer.seek(endPos);
+
+ // Add padding byte if chunk size is odd
+ if (chunkSize & 1) {
+ this.writer.write(new Uint8Array(1));
+ }
+ }
+
+ private writeInfoTag(tag: string, value: string) {
+ const size = value.length + 1; // +1 for null terminator
+ const bytes = new Uint8Array(size);
+
+ for (let i = 0; i < value.length; i++) {
+ bytes[i] = value.charCodeAt(i);
+ }
+
+ this.riffWriter.writeAscii(tag);
+ this.riffWriter.writeU32(size);
+ this.writer.write(bytes);
+
+ // Add padding byte if size is odd
+ if (size & 1) {
+ this.writer.write(new Uint8Array(1));
+ }
+ }
+
async finalize() {
const release = await this.mutex.acquire();
@@ -166,23 +299,28 @@ export class WaveMuxer extends Muxer {
if (this.isRf64) {
// Write riff size
- this.writer.seek(20);
+ assert(this.ds64RiffSizePos !== null);
+ this.writer.seek(this.ds64RiffSizePos);
this.riffWriter.writeU64(endPos - 8);
// Write data size
- this.writer.seek(28);
+ assert(this.ds64DataSizePos !== null);
+ this.writer.seek(this.ds64DataSizePos);
this.riffWriter.writeU64(this.dataSize);
// Write sample count
- this.writer.seek(36);
+ assert(this.ds64SampleCountPos !== null);
+ this.writer.seek(this.ds64SampleCountPos);
this.riffWriter.writeU64(this.sampleCount);
} else {
// Write file size
- this.writer.seek(4);
+ assert(this.riffSizePos !== null);
+ this.writer.seek(this.riffSizePos);
this.riffWriter.writeU32(endPos - 8);
// Write data chunk size
- this.writer.seek(40);
+ assert(this.dataSizePos !== null);
+ this.writer.seek(this.dataSizePos);
this.riffWriter.writeU32(this.dataSize);
}