diff --git a/packages/aac-encoder/README.md b/packages/aac-encoder/README.md index 69062c2..9efa6c7 100644 --- a/packages/aac-encoder/README.md +++ b/packages/aac-encoder/README.md @@ -81,6 +81,9 @@ const output = new Output({ const conversion = await Conversion.init({ input, output, + audio: { + codec: 'aac', + }, }); await conversion.execute(); diff --git a/src/id3.ts b/src/id3.ts index 254fc6f..19a1e60 100644 --- a/src/id3.ts +++ b/src/id3.ts @@ -16,6 +16,7 @@ import { assertNever, keyValueIterator, toDataView, + isRecordStringString, } from './misc'; import { FileSlice, readAscii, readBytes, readU32Be, readU8 } from './reader'; import { Writer } from './writer'; @@ -225,7 +226,14 @@ export const parseId3V2Tag = (slice: FileSlice, header: Id3V2Header, tags: Metad } tags.raw ??= {}; - if (frame.id[0] === 'T') { + if (frame.id === 'TXXX') { + const txxx = tags.raw['TXXX'] ??= {}; + const encoding = reader.readId3V2TextEncoding(); + const description = reader.readId3V2Text(encoding, frameEndPos); + const value = reader.readId3V2Text(encoding, frameEndPos); + + (txxx as Record)[description] ??= value; + } else if (frame.id[0] === 'T') { // It's a text frame, let's decode as text tags.raw[frame.id] ??= reader.readId3V2EncodingAndText(frameEndPos); } else { @@ -761,13 +769,51 @@ export class Id3V2Writer { let bytes: Uint8Array; if (typeof value === 'string') { - const encoded = textEncoder.encode(value); - bytes = new Uint8Array(encoded.byteLength + 2); - bytes[0] = Id3V2TextEncoding.UTF_8; - bytes.set(encoded, 1); - // Last byte is the null terminator + const useIso88591 = isIso88591Compatible(value); + if (useIso88591) { + bytes = new Uint8Array(value.length + 2); + bytes[0] = Id3V2TextEncoding.ISO_8859_1; + for (let i = 0; i < value.length; i++) { + bytes[i + 1] = value.charCodeAt(i); + } + // Last byte is the null terminator + } else { + const encoded = textEncoder.encode(value); + bytes = new Uint8Array(encoded.byteLength + 2); + bytes[0] = Id3V2TextEncoding.UTF_8; + bytes.set(encoded, 1); + // Last byte is the null terminator + } } else if (value instanceof Uint8Array) { bytes = value; + } else if (key === 'TXXX' && isRecordStringString(value)) { + for (const description in value) { + const frameValue = value[description]!; + const useIso88591 = isIso88591Compatible(description) && isIso88591Compatible(frameValue); + + const encodedDescription = useIso88591 ? null : textEncoder.encode(description); + const encodedValue = useIso88591 ? null : textEncoder.encode(frameValue); + const descriptionDataLength = useIso88591 ? description.length : encodedDescription!.byteLength; + const valueDataLength = useIso88591 ? frameValue.length : encodedValue!.byteLength; + + const frameSize = 1 + descriptionDataLength + 1 + valueDataLength + 1; + + this.writeAscii('TXXX'); + this.writeSynchsafeU32(frameSize); + this.writeU16(0x0000); + + this.writeU8(useIso88591 ? Id3V2TextEncoding.ISO_8859_1 : Id3V2TextEncoding.UTF_8); + if (useIso88591) { + this.writeIsoString(description); + this.writeIsoString(frameValue); + } else { + this.writer.write(encodedDescription!); + this.writeU8(0x00); + this.writer.write(encodedValue!); + this.writeU8(0x00); + } + } + continue; } else { continue; } @@ -821,7 +867,8 @@ export class Id3V2Writer { for (let i = 0; i < text.length; i++) { bytes[i] = text.charCodeAt(i); } - bytes[text.length] = 0x00; + // Last byte is the null terminator + this.writer.write(bytes); } diff --git a/src/metadata.ts b/src/metadata.ts index 66adc11..d01246f 100644 --- a/src/metadata.ts +++ b/src/metadata.ts @@ -6,6 +6,8 @@ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ +import { isRecordStringString } from './misc'; + /** * Represents descriptive (non-technical) metadata about a media file, such as title, author, date, cover art, or other * attached files. Common tags are normalized by Mediabunny into a uniform format, while the `raw` field can be used to @@ -69,8 +71,9 @@ export type MetadataTags = { * - WebM/Matroska: `SimpleTag` elements whose target is 50 (MOVIE), either containing string or `Uint8Array` * values. Additionally, all attached files (such as font files) are included here, where the key corresponds to * the FileUID and the value is an {@link AttachedFile}. - * - MP3: The ID3v2 tags, or a single `'TAG'` key with the contents of the ID3v1 tag. - * - ADTS: The ID3v2 tags. + * - MP3: The ID3v2 tags, or a single `'TAG'` key with the contents of the ID3v1 tag. The ID3v2 `'TXXX'` + * user-defined text frames are exposed as a `Record`. + * - ADTS: The ID3v2 tags, just like in MP3. * - Ogg: The key-value string pairs from the Vorbis-style comment header (see RFC 7845, Section 5.2). * Additionally, the `'vendor'` key refers to the vendor string within this header. * - WAVE: The individual metadata chunks within the RIFF INFO chunk. Values are always ISO 8859-1 strings. @@ -78,7 +81,7 @@ export type MetadataTags = { * Additionally, the `'vendor'` key refers to the vendor string within this header. * - MPEG-TS: Not supported. */ - raw?: Record; + raw?: Record | null>; }; /** @@ -236,9 +239,11 @@ export const validateMetadataTags = (tags: MetadataTags) => { && !(value instanceof Uint8Array) && !(value instanceof RichImageData) && !(value instanceof AttachedFile) + && !isRecordStringString(value) ) { throw new TypeError( - 'Each value in tags.raw must be a string, Uint8Array, RichImageData, AttachedFile, or null.', + 'Each value in tags.raw must be a string, Uint8Array, RichImageData, AttachedFile, ' + + 'Record, or null.', ); } } diff --git a/src/misc.ts b/src/misc.ts index acc26af..ee90e1c 100644 --- a/src/misc.ts +++ b/src/misc.ts @@ -1309,3 +1309,10 @@ export class ConcurrentRunner { await Promise.all(this._queue); } } + +export const isRecordStringString = (value: unknown): value is Record => { + return value !== null + && typeof value === 'object' + && Object.getPrototypeOf(value) === Object.prototype + && Object.values(value).every(x => typeof x === 'string'); +}; diff --git a/test/node/metadata-tags.test.ts b/test/node/metadata-tags.test.ts index af68b7b..cb231a1 100644 --- a/test/node/metadata-tags.test.ts +++ b/test/node/metadata-tags.test.ts @@ -338,6 +338,12 @@ test('Read and write metadata, MP3', async () => { ...songMetadata, raw: { TXXY: 'ID3v2 goated', + TXXZ: '鱼', + TXXX: { + foo: 'bar', + baz: 'qux', + 你: '好', + }, }, }); @@ -377,6 +383,12 @@ test('Read and write metadata, MP3', async () => { expect(readTags.raw!['TIT2']).toBe(songMetadata.title); expect(readTags.raw!['APIC']).instanceOf(Uint8Array); expect(readTags.raw!['TXXY']).toBe('ID3v2 goated'); + expect(readTags.raw!['TXXZ']).toBe('鱼'); + expect(readTags.raw!['TXXX']).toEqual({ + foo: 'bar', + baz: 'qux', + 你: '好', + }); }); test('Read and write metadata, Ogg', async () => {