Fix WAV files with embedded ID3 metadata returning empty tags (#110)

* Read ID3v2 tags from WAV file

* remove js import

Co-authored-by: David P. <[email protected]>

* revert slicing in wave-demuxer.ts and add id3.ts for further sharing

* Add `writeId3Tag` to the `WavOutputFormat` constructor options

* add more context in private and public APIs that it's non-standard

* compare actual values in the test with real file

* remove unnecesarry double slice requesting

* trim test audio file (i tried my best to preserve ID3, including artwork)

* Couple of code cleanups

* const -> using

* Fix linting errors

---------

Co-authored-by: David P. <[email protected]>
This commit is contained in:
Igor Samokhovets
2025-09-22 14:06:06 +02:00
committed by GitHub
co-authored by David P.
parent cab8319051
commit 6aee4e6a6a
11 changed files with 1099 additions and 898 deletions
+18
View File
@@ -15,6 +15,7 @@ import { MetadataTags } from '../tags';
import { assert, UNDETERMINED_LANGUAGE } from '../misc';
import { EncodedPacket, PLACEHOLDER_DATA } from '../packet';
import { readAscii, readBytes, Reader, readU16, readU32, readU64 } from '../reader';
import { parseId3V2Tag, readId3V2Header } from '../id3';
export enum WaveFormat {
PCM = 0x0001,
@@ -107,6 +108,8 @@ export class WaveDemuxer extends Demuxer {
totalFileSize = Math.min(riffChunkSize + 8, this.reader.fileSize ?? Infinity);
} else if (chunkId === 'LIST') {
await this.parseListChunk(startPos, chunkSize, littleEndian);
} else if (chunkId === 'ID3 ' || chunkId === 'id3 ') {
await this.parseId3Chunk(startPos, chunkSize);
}
currentPos = startPos + chunkSize + (chunkSize & 1); // Handle padding
@@ -271,6 +274,21 @@ export class WaveDemuxer extends Demuxer {
}
}
private async parseId3Chunk(startPos: number, size: number) {
// Parse ID3 tag embedded in WAV file (non-default, but used a lot in practice anyway)
let slice = this.reader.requestSlice(startPos, size);
if (slice instanceof Promise) slice = await slice;
if (!slice) return; // File too short
const id3V2Header = readId3V2Header(slice);
if (id3V2Header) {
// Extract the content portion (skip the 10-byte header)
const contentSlice = slice.slice(startPos + 10, id3V2Header.size);
parseId3V2Tag(contentSlice, id3V2Header, this.metadataTags);
}
}
getCodec(): AudioCodec | null {
assert(this.audioInfo);
+34 -2
View File
@@ -16,6 +16,7 @@ import { EncodedPacket } from '../packet';
import { WavOutputFormat } from '../output-format';
import { assert, assertNever, isIso88591Compatible, keyValueIterator } from '../misc';
import { MetadataTags, metadataTagsAreEmpty } from '../tags';
import { Id3V2Writer } from '../id3';
export class WaveMuxer extends Muxer {
private format: WavOutputFormat;
@@ -159,9 +160,17 @@ export class WaveMuxer extends Muxer {
this.riffWriter.writeU16(blockSize);
this.riffWriter.writeU16(8 * pcmInfo.sampleSize);
// Metadata tags
if (!metadataTagsAreEmpty(this.output._metadataTags)) {
// Metadata exists, let's write an INFO chunk
this.writeInfoChunk(this.output._metadataTags);
const metadataFormat = this.format._options.metadataFormat ?? 'info';
if (metadataFormat === 'info') {
this.writeInfoChunk(this.output._metadataTags);
} else if (metadataFormat === 'id3') {
this.writeId3Chunk(this.output._metadataTags);
} else {
assertNever(metadataFormat);
}
}
// data chunk
@@ -300,6 +309,29 @@ export class WaveMuxer extends Muxer {
}
}
private writeId3Chunk(metadata: MetadataTags) {
const startPos = this.writer.getPos();
// Write RIFF chunk header
this.riffWriter.writeAscii('ID3 ');
this.riffWriter.writeU32(0); // Size placeholder
const id3Writer = new Id3V2Writer(this.writer);
const id3TagSize = id3Writer.writeId3V2Tag(metadata);
const endPos = this.writer.getPos();
// Update RIFF chunk size
this.writer.seek(startPos + 4);
this.riffWriter.writeU32(id3TagSize);
this.writer.seek(endPos);
// Add padding byte if chunk size is odd
if (id3TagSize & 1) {
this.writer.write(new Uint8Array(1));
}
}
async finalize() {
const release = await this.mutex.acquire();