From dd7804e5abb70388f84701b2926914826be4a9c0 Mon Sep 17 00:00:00 2001 From: Vanilagy <1696106+Vanilagy@users.noreply.github.com> Date: Fri, 5 Sep 2025 16:31:26 +0200 Subject: [PATCH] Add metadata read/write support for WAVE --- dev/convert.html | 4 +- src/adts/adts-demuxer.ts | 4 ++ src/wave/wave-demuxer.ts | 109 ++++++++++++++++++++++++++++ src/wave/wave-muxer.ts | 150 +++++++++++++++++++++++++++++++++++++-- 4 files changed, 259 insertions(+), 8 deletions(-) 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); }