From d3a030c3070bac7426fe4e61fa1917a26740f048 Mon Sep 17 00:00:00 2001 From: Vanilagy <1696106+Vanilagy@users.noreply.github.com> Date: Fri, 5 Sep 2025 15:39:29 +0200 Subject: [PATCH] Add metadata read/write support for Ogg --- dev/convert.html | 4 +- src/input-track.ts | 6 +- src/isobmff/isobmff-boxes.ts | 4 +- src/isobmff/isobmff-demuxer.ts | 50 ++++---- src/matroska/matroska-demuxer.ts | 2 +- src/matroska/matroska-muxer.ts | 4 +- src/misc.ts | 21 ++++ src/mp3/mp3-muxer.ts | 2 +- src/mp3/mp3-reader.ts | 6 +- src/ogg/ogg-demuxer.ts | 171 ++++++++++++++++++++++++- src/ogg/ogg-muxer.ts | 210 +++++++++++++++++++++++++++++-- 11 files changed, 431 insertions(+), 49 deletions(-) diff --git a/dev/convert.html b/dev/convert.html index 2b0e378..53d7903 100644 --- a/dev/convert.html +++ b/dev/convert.html @@ -24,7 +24,7 @@ chunked: true, chunkSize: 2**20 }); - const outputFormat = new Mediabunny.MkvOutputFormat({}); + const outputFormat = new Mediabunny.OggOutputFormat({}); const button = document.createElement('button'); button.textContent = 'Cancel'; @@ -61,7 +61,7 @@ discNumberMax: 8, lyrics: "There's no way\nThat it's not going there", raw: { - //'TBRU': 'this is a BRUH metadatum', + 'ENCODER': 'Mediabunny epic own encoder' } }); diff --git a/src/input-track.ts b/src/input-track.ts index b093b0d..7f3e56e 100644 --- a/src/input-track.ts +++ b/src/input-track.ts @@ -258,7 +258,8 @@ export class InputVideoTrack extends InputTrack { /** * Returns the [decoder configuration](https://www.w3.org/TR/webcodecs/#video-decoder-config) for decoding the - * track's packets using a VideoDecoder. Returns null if the track's codec is unknown. + * track's packets using a [`VideoDecoder`](https://developer.mozilla.org/en-US/docs/Web/API/VideoDecoder). Returns + * null if the track's codec is unknown. */ getDecoderConfig() { return this._backing.getDecoderConfig(); @@ -354,7 +355,8 @@ export class InputAudioTrack extends InputTrack { /** * Returns the [decoder configuration](https://www.w3.org/TR/webcodecs/#audio-decoder-config) for decoding the - * track's packets using an AudioDecoder. Returns null if the track's codec is unknown. + * track's packets using an [`AudioDecoder`](https://developer.mozilla.org/en-US/docs/Web/API/AudioDecoder). Returns + * null if the track's codec is unknown. */ getDecoderConfig() { return this._backing.getDecoderConfig(); diff --git a/src/isobmff/isobmff-boxes.ts b/src/isobmff/isobmff-boxes.ts index f42e92e..823ceda 100644 --- a/src/isobmff/isobmff-boxes.ts +++ b/src/isobmff/isobmff-boxes.ts @@ -1322,7 +1322,7 @@ const addQuickTimeMetadataboxes = (boxes: Box[], metadata: MediaMetadata) => { }; break; case 'date': { - boxes.push(metadataStringBoxShort('©day', value.toISOString().split('T')[0]!)); + boxes.push(metadataStringBoxShort('©day', value.toISOString().slice(0, 10))); }; break; case 'comment': { @@ -1423,7 +1423,7 @@ const meta = (metadata: MediaMetadata) => { }; break; case 'date': { - boxes.push(metadataStringBoxLong('©day', value.toISOString().split('T')[0]!)); + boxes.push(metadataStringBoxLong('©day', value.toISOString().slice(0, 10))); }; break; case 'images': { diff --git a/src/isobmff/isobmff-demuxer.ts b/src/isobmff/isobmff-demuxer.ts index aa6e5ba..431bd30 100644 --- a/src/isobmff/isobmff-demuxer.ts +++ b/src/isobmff/isobmff-demuxer.ts @@ -2060,9 +2060,9 @@ export class IsobmffDemuxer extends Demuxer { this.metadata.raw ??= {}; if (UDTA_STRING_KEYS.has(boxInfo.name)) { - this.metadata.raw[boxInfo.name] = readMetadataStringShort(slice); + this.metadata.raw[boxInfo.name] ??= readMetadataStringShort(slice); } else { - this.metadata.raw[boxInfo.name] = readBytes(slice, boxInfo.contentSize); + this.metadata.raw[boxInfo.name] ??= readBytes(slice, boxInfo.contentSize); } slice.filePos = startPos; @@ -2079,37 +2079,37 @@ export class IsobmffDemuxer extends Demuxer { if (this.currentTrack) { this.currentTrack.name = textDecoder.decode(readBytes(slice, boxInfo.contentSize)); } else { - this.metadata.title = readMetadataStringShort(slice); + this.metadata.title ??= readMetadataStringShort(slice); } }; break; case '©des': { if (!this.currentTrack) { - this.metadata.description = readMetadataStringShort(slice); + this.metadata.description ??= readMetadataStringShort(slice); } }; break; case '©ART': { if (!this.currentTrack) { - this.metadata.artist = readMetadataStringShort(slice); + this.metadata.artist ??= readMetadataStringShort(slice); } }; break; case '©alb': { if (!this.currentTrack) { - this.metadata.album = readMetadataStringShort(slice); + this.metadata.album ??= readMetadataStringShort(slice); } }; break; case 'albr': { if (!this.currentTrack) { - this.metadata.albumArtist = readMetadataStringShort(slice); + this.metadata.albumArtist ??= readMetadataStringShort(slice); } }; break; case '©gen': { if (!this.currentTrack) { - this.metadata.genre = readMetadataStringShort(slice); + this.metadata.genre ??= readMetadataStringShort(slice); } }; break; @@ -2117,20 +2117,20 @@ export class IsobmffDemuxer extends Demuxer { if (!this.currentTrack) { const date = new Date(readMetadataStringShort(slice)); if (!Number.isNaN(date.getTime())) { - this.metadata.date = date; + this.metadata.date ??= date; } } }; break; case '©cmt': { if (!this.currentTrack) { - this.metadata.comment = readMetadataStringShort(slice); + this.metadata.comment ??= readMetadataStringShort(slice); } }; break; case '©lyr': { if (!this.currentTrack) { - this.metadata.lyrics = readMetadataStringShort(slice); + this.metadata.lyrics ??= readMetadataStringShort(slice); } }; break; } @@ -2200,7 +2200,7 @@ export class IsobmffDemuxer extends Demuxer { const data = readDataBox(slice); this.metadata.raw ??= {}; - this.metadata.raw[metadataKey] = data; + this.metadata.raw[metadataKey] ??= data; switch (metadataKey) { case '©nam': @@ -2208,7 +2208,7 @@ export class IsobmffDemuxer extends Demuxer { case 'com.apple.quicktime.title': case 'title': { if (typeof data === 'string') { - this.metadata.title = data; + this.metadata.title ??= data; } }; break; @@ -2218,7 +2218,7 @@ export class IsobmffDemuxer extends Demuxer { case 'com.apple.quicktime.description': case 'description': { if (typeof data === 'string') { - this.metadata.description = data; + this.metadata.description ??= data; } }; break; @@ -2226,7 +2226,7 @@ export class IsobmffDemuxer extends Demuxer { case 'com.apple.quicktime.artist': case 'artist': { if (typeof data === 'string') { - this.metadata.artist = data; + this.metadata.artist ??= data; } }; break; @@ -2235,14 +2235,14 @@ export class IsobmffDemuxer extends Demuxer { case 'com.apple.quicktime.album': case 'album': { if (typeof data === 'string') { - this.metadata.album = data; + this.metadata.album ??= data; } }; break; case 'aART': case 'album_artist': { if (typeof data === 'string') { - this.metadata.albumArtist = data; + this.metadata.albumArtist ??= data; } }; break; @@ -2250,7 +2250,7 @@ export class IsobmffDemuxer extends Demuxer { case 'com.apple.quicktime.comment': case 'comment': { if (typeof data === 'string') { - this.metadata.comment = data; + this.metadata.comment ??= data; } }; break; @@ -2259,14 +2259,14 @@ export class IsobmffDemuxer extends Demuxer { case 'com.apple.quicktime.genre': case 'genre': { if (typeof data === 'string') { - this.metadata.genre = data; + this.metadata.genre ??= data; } }; break; case '©lyr': case 'lyrics': { if (typeof data === 'string') { - this.metadata.lyrics = data; + this.metadata.lyrics ??= data; } }; break; @@ -2277,7 +2277,7 @@ export class IsobmffDemuxer extends Demuxer { if (typeof data === 'string') { const date = new Date(data); if (!Number.isNaN(date.getTime())) { - this.metadata.date = date; + this.metadata.date ??= date; } } }; break; @@ -2309,10 +2309,10 @@ export class IsobmffDemuxer extends Demuxer { const trackNumberMax = view.getUint16(4, false); if (trackNumber > 0) { - this.metadata.trackNumber = trackNumber; + this.metadata.trackNumber ??= trackNumber; } if (trackNumberMax > 0) { - this.metadata.trackNumberMax = trackNumberMax; + this.metadata.trackNumberMax ??= trackNumberMax; } } }; break; @@ -2326,10 +2326,10 @@ export class IsobmffDemuxer extends Demuxer { const discNumberMax = view.getUint16(4, false); if (discNumber > 0) { - this.metadata.discNumber = discNumber; + this.metadata.discNumber ??= discNumber; } if (discNumberMax > 0) { - this.metadata.discNumberMax = discNumberMax; + this.metadata.discNumberMax ??= discNumberMax; } } }; break; diff --git a/src/matroska/matroska-demuxer.ts b/src/matroska/matroska-demuxer.ts index 4dfab32..9db5834 100644 --- a/src/matroska/matroska-demuxer.ts +++ b/src/matroska/matroska-demuxer.ts @@ -1473,7 +1473,7 @@ export class MatroskaDemuxer extends Demuxer { const metadata = this.currentSegment.metadata; metadata.raw ??= {}; - metadata.raw[name] = value; + metadata.raw[name] ??= value; if (typeof value === 'string') { switch (name.toLowerCase()) { diff --git a/src/matroska/matroska-muxer.ts b/src/matroska/matroska-muxer.ts index f6fa690..60220f2 100644 --- a/src/matroska/matroska-muxer.ts +++ b/src/matroska/matroska-muxer.ts @@ -65,7 +65,7 @@ import { parseOpusIdentificationHeader } from '../codec-data'; const MIN_CLUSTER_TIMESTAMP_MS = -(2 ** 15); const MAX_CLUSTER_TIMESTAMP_MS = 2 ** 15 - 1; -const APP_NAME = 'https://github.com/Vanilagy/mediabunny'; +const APP_NAME = 'Mediabunny'; const SEGMENT_SIZE_BYTES = 6; const CLUSTER_SIZE_BYTES = 5; @@ -470,7 +470,7 @@ export class MatroskaMuxer extends Muxer { }; break; case 'date': { - addSimpleTag('DATE', value.toISOString().split('T')[0]!); + addSimpleTag('DATE', value.toISOString().slice(0, 10)); writtenTags.add('DATE'); }; break; diff --git a/src/misc.ts b/src/misc.ts index fcdd4ca..3126f03 100644 --- a/src/misc.ts +++ b/src/misc.ts @@ -702,3 +702,24 @@ export const imageMimeTypeToExtension = (mimeType: string) => { return null; } }; + +export const base64ToBytes = (base64: string) => { + const decoded = atob(base64); + const bytes = new Uint8Array(decoded.length); + + for (let i = 0; i < decoded.length; i++) { + bytes[i] = decoded.charCodeAt(i); + } + + return bytes; +}; + +export const bytesToBase64 = (bytes: Uint8Array) => { + let string = ''; + + for (let i = 0; i < bytes.length; i++) { + string += String.fromCharCode(bytes[i]!); + } + + return btoa(string); +}; diff --git a/src/mp3/mp3-muxer.ts b/src/mp3/mp3-muxer.ts index 03870a9..5589a49 100644 --- a/src/mp3/mp3-muxer.ts +++ b/src/mp3/mp3-muxer.ts @@ -183,7 +183,7 @@ export class Mp3Muxer extends Muxer { }; break; case 'date': { - this.mp3Writer.writeId3V2TextFrame('TDRC', value.toISOString().split('.')[0]!); + this.mp3Writer.writeId3V2TextFrame('TDRC', value.toISOString().slice(0, 10)); writtenTags.add('TDRC'); }; break; diff --git a/src/mp3/mp3-reader.ts b/src/mp3/mp3-reader.ts index 5d581f3..9a6757b 100644 --- a/src/mp3/mp3-reader.ts +++ b/src/mp3/mp3-reader.ts @@ -92,7 +92,7 @@ export const readNextFrameHeader = async (reader: Reader, startPos: number, unti export const parseId3V1Tag = (slice: FileSlice, metadata: MediaMetadata) => { const startPos = slice.filePos; metadata.raw ??= {}; - metadata.raw['TAG'] = readBytes(slice, ID3_V1_TAG_SIZE - 3); // Dump the whole tag into the raw metadata + metadata.raw['TAG'] ??= readBytes(slice, ID3_V1_TAG_SIZE - 3); // Dump the whole tag into the raw metadata slice.filePos = startPos; const title = readId3V1String(slice, 30); @@ -242,10 +242,10 @@ export const parseId3V2Tag = (slice: FileSlice, header: Id3V2Header, metadata: M metadata.raw ??= {}; if (frame.id[0] === 'T') { // It's a text frame, let's decode as text - metadata.raw[frame.id] = reader.readId3V2EncodingAndText(frameEndPos); + metadata.raw[frame.id] ??= reader.readId3V2EncodingAndText(frameEndPos); } else { // For the others, let's just get the bytes - metadata.raw[frame.id] = reader.readBytes(frame.size); + metadata.raw[frame.id] ??= reader.readBytes(frame.size); } reader.pos = frameStartPos; diff --git a/src/ogg/ogg-demuxer.ts b/src/ogg/ogg-demuxer.ts index 2632e49..4fb4c54 100644 --- a/src/ogg/ogg-demuxer.ts +++ b/src/ogg/ogg-demuxer.ts @@ -12,13 +12,16 @@ 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, AsyncMutex, + base64ToBytes, binarySearchLessOrEqual, findLast, last, roundToPrecision, + textDecoder, toDataView, UNDETERMINED_LANGUAGE, } from '../misc'; @@ -57,6 +60,7 @@ export class OggDemuxer extends Demuxer { metadataPromise: Promise | null = null; bitstreams: LogicalBitstream[] = []; tracks: InputAudioTrack[] = []; + metadata: MediaMetadata = {}; constructor(input: Input) { super(input); @@ -218,6 +222,8 @@ export class OggDemuxer extends Demuxer { ], modeBlockflags: parseModesFromVorbisSetupPacket(thirdPacket.data).modeBlockflags, }; + + this.readVorbisComments(secondPacket.data.subarray(7)); // Skip header type and 'vorbis' } async readOpusMetadata(firstPacket: Packet, bitstream: LogicalBitstream) { @@ -237,8 +243,6 @@ export class OggDemuxer extends Demuxer { return; } - // We don't make use of the comment header's data - bitstream.codecInfo.codec = 'opus'; bitstream.description = firstPacket.data; bitstream.lastMetadataPacket = secondPacket; @@ -250,6 +254,164 @@ export class OggDemuxer extends Demuxer { bitstream.codecInfo.opusInfo = { preSkip: header.preSkip, }; + + this.readVorbisComments(secondPacket.data.subarray(8)); // Skip 'OpusTags' + } + + readVorbisComments(bytes: Uint8Array) { + // https://datatracker.ietf.org/doc/html/rfc7845#section-5.2 + + const commentView = toDataView(bytes); + let commentPos = 0; + + const vendorStringLength = commentView.getUint32(commentPos, true); + commentPos += 4; + + const vendorString = textDecoder.decode( + bytes.subarray(commentPos, commentPos + vendorStringLength), + ); + commentPos += vendorStringLength; + + if (vendorStringLength > 0) { + // Expose the vendor string in the raw metadata + this.metadata.raw ??= {}; + this.metadata.raw['vendor'] ??= vendorString; + } + + const listLength = commentView.getUint32(commentPos, true); + commentPos += 4; + + // Loop over all metadata tags + for (let i = 0; i < listLength; i++) { + const stringLength = commentView.getUint32(commentPos, true); + commentPos += 4; + + const string = textDecoder.decode( + bytes.subarray(commentPos, commentPos + stringLength), + ); + commentPos += stringLength; + + const separatorIndex = string.indexOf('='); + if (separatorIndex === -1) { + continue; + } + + const key = string.slice(0, separatorIndex).toUpperCase(); + const value = string.slice(separatorIndex + 1); + + this.metadata.raw ??= {}; + this.metadata.raw[key] ??= value; + + switch (key) { + case 'TITLE': { + this.metadata.title ??= value; + }; break; + + case 'DESCRIPTION': { + this.metadata.description ??= value; + }; break; + + case 'ARTIST': { + this.metadata.artist ??= value; + }; break; + + case 'ALBUM': { + this.metadata.album ??= value; + }; break; + + case 'ALBUMARTIST': { + this.metadata.albumArtist ??= value; + }; break; + + case 'COMMENT': { + this.metadata.comment ??= value; + }; break; + + case 'LYRICS': { + this.metadata.lyrics ??= value; + }; break; + + case 'TRACKNUMBER': { + 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 'TRACKTOTAL': { + const trackNumMax = Number.parseInt(value, 10); + if (Number.isInteger(trackNumMax) && trackNumMax > 0) { + this.metadata.trackNumberMax ??= trackNumMax; + } + }; break; + + case 'DISCNUMBER': { + const parts = value.split('/'); + const discNum = Number.parseInt(parts[0]!, 10); + const discNumMax = parts[1] && Number.parseInt(parts[1], 10); + + if (Number.isInteger(discNum) && discNum > 0) { + this.metadata.discNumber ??= discNum; + } + if (discNumMax && Number.isInteger(discNumMax) && discNumMax > 0) { + this.metadata.discNumberMax ??= discNumMax; + } + }; break; + + case 'DISCTOTAL': { + const trackNumMax = Number.parseInt(value, 10); + if (Number.isInteger(trackNumMax) && trackNumMax > 0) { + this.metadata.trackNumberMax ??= trackNumMax; + } + }; break; + + case 'DATE': { + const date = new Date(value); + if (!Number.isNaN(date.getTime())) { + this.metadata.date ??= date; + } + }; break; + + case 'GENRE': { + this.metadata.genre ??= value; + }; break; + + case 'METADATA_BLOCK_PICTURE': { + // https://datatracker.ietf.org/doc/rfc9639/ Section 8.8 + const decoded = base64ToBytes(value); + + const view = toDataView(decoded); + const pictureType = view.getUint32(0, false); + const mediaTypeLength = view.getUint32(4, false); + const mediaType = String.fromCharCode(...decoded.subarray(8, 8 + mediaTypeLength)); // ASCII + const descriptionLength = view.getUint32(8 + mediaTypeLength, false); + const description = textDecoder.decode(decoded.subarray( + 12 + mediaTypeLength, + 12 + mediaTypeLength + descriptionLength, + )); + const dataLength = view.getUint32(mediaTypeLength + descriptionLength + 28); + const data = decoded.subarray( + mediaTypeLength + descriptionLength + 32, + mediaTypeLength + descriptionLength + 32 + dataLength, + ); + + this.metadata.images ??= []; + this.metadata.images.push({ + data, + mimeType: mediaType, + kind: pictureType === 3 ? 'coverFront' : pictureType === 4 ? 'coverBack' : 'unknown', + name: undefined, + description: description || undefined, + }); + }; break; + } + } } async readPacket(startPage: Page, startSegmentIndex: number): Promise { @@ -388,6 +550,11 @@ export class OggDemuxer extends Demuxer { const trackDurations = await Promise.all(tracks.map(x => x.computeDuration())); return Math.max(0, ...trackDurations); } + + async getMetadata() { + await this.readMetadata(); + return this.metadata; + } } type EncodedPacketMetadata = { diff --git a/src/ogg/ogg-muxer.ts b/src/ogg/ogg-muxer.ts index 1b18333..12a8a06 100644 --- a/src/ogg/ogg-muxer.ts +++ b/src/ogg/ogg-muxer.ts @@ -8,9 +8,20 @@ import { OPUS_INTERNAL_SAMPLE_RATE, validateAudioChunkMetadata } from '../codec'; import { parseModesFromVorbisSetupPacket, parseOpusIdentificationHeader } from '../codec-data'; -import { assert, promiseWithResolvers, setInt64, toDataView, toUint8Array } from '../misc'; +import { + assert, + assertNever, + bytesToBase64, + keyValueIterator, + promiseWithResolvers, + setInt64, + textEncoder, + toDataView, + toUint8Array, +} from '../misc'; import { Muxer } from '../muxer'; -import { Output, OutputAudioTrack } from '../output'; +import { Output, + OutputAudioTrack } from '../output'; import { OggOutputFormat } from '../output-format'; import { EncodedPacket } from '../packet'; import { Writer } from '../writer'; @@ -176,9 +187,20 @@ export class OggMuxer extends Muxer { } const identificationHeader = bytes.subarray(pos, pos += identificationHeaderLength); - const commentHeader = bytes.subarray(pos, pos += commentHeaderLength); + pos += commentHeaderLength; // Skip the comment header, we'll build our own const setupHeader = bytes.subarray(pos); + const commentHeaderHeader = new Uint8Array(7); + commentHeaderHeader[0] = 3; // Packet type + commentHeaderHeader[1] = 0x76; // 'v' + commentHeaderHeader[2] = 0x6f; // 'o' + commentHeaderHeader[3] = 0x72; // 'r' + commentHeaderHeader[4] = 0x62; // 'b' + commentHeaderHeader[5] = 0x69; // 'i' + commentHeaderHeader[6] = 0x73; // 's' + + const commentHeader = this.createVorbisComments(commentHeaderHeader); + trackData.packetQueue.push({ data: identificationHeader, endGranulePosition: 0, @@ -213,12 +235,11 @@ export class OggMuxer extends Muxer { const identificationHeader = toUint8Array(meta.decoderConfig.description); - const commentHeader = new Uint8Array(8 + 4 + 4); - const view = new DataView(commentHeader.buffer); - view.setUint32(0, 0x4f707573, false); // 'Opus' - view.setUint32(4, 0x54616773, false); // 'Tags' - view.setUint32(8, 0, true); // Vendor String Length - view.setUint32(12, 0, true); // User Comment List Length + const commentHeaderHeader = new Uint8Array(8); + const commentHeaderHeaderView = toDataView(commentHeaderHeader); + commentHeaderHeaderView.setUint32(0, 0x4f707573, false); // 'Opus' + commentHeaderHeaderView.setUint32(4, 0x54616773, false); // 'Tags' + const commentHeader = this.createVorbisComments(commentHeaderHeader); trackData.packetQueue.push({ data: identificationHeader, @@ -238,6 +259,177 @@ export class OggMuxer extends Muxer { } } + createVorbisComments(headerBytes: Uint8Array) { + // https://datatracker.ietf.org/doc/html/rfc7845#section-5.2 + + const metadata = this.output._metadata; + const commentHeaderParts = [ + headerBytes, + ]; + + let vendorString = ''; + if (typeof metadata.raw?.['vendor'] === 'string') { + vendorString = metadata.raw?.['vendor']; + } + const encodedVendorString = textEncoder.encode(vendorString); + + let currentBuffer = new Uint8Array(4 + encodedVendorString.length); + let currentView = new DataView(currentBuffer.buffer); + currentView.setUint32(0, encodedVendorString.length, true); + currentBuffer.set(encodedVendorString, 4); + + commentHeaderParts.push(currentBuffer); + + const writtenTags = new Set(); + const addCommentTag = (key: string, value: string) => { + const joined = `${key}=${value}`; + const encoded = textEncoder.encode(joined); + + currentBuffer = new Uint8Array(4 + encoded.length); + currentView = new DataView(currentBuffer.buffer); + + currentView.setUint32(0, encoded.length, true); + currentBuffer.set(encoded, 4); + + commentHeaderParts.push(currentBuffer); + writtenTags.add(key); + }; + + for (const { key, value } of keyValueIterator(metadata)) { + switch (key) { + case 'title': { + addCommentTag('TITLE', value); + }; break; + + case 'description': { + addCommentTag('DESCRIPTION', value); + }; break; + + case 'artist': { + addCommentTag('ARTIST', value); + }; break; + + case 'album': { + addCommentTag('ALBUM', value); + }; break; + + case 'albumArtist': { + addCommentTag('ALBUMARTIST', value); + }; break; + + case 'genre': { + addCommentTag('GENRE', value); + }; break; + + case 'date': { + addCommentTag('DATE', value.toISOString().slice(0, 10)); + }; break; + + case 'comment': { + addCommentTag('COMMENT', value); + }; break; + + case 'lyrics': { + addCommentTag('LYRICS', value); + }; break; + + case 'discNumber': { + addCommentTag('TRACKNUMBER', value.toString()); + }; break; + + case 'discNumberMax': { + addCommentTag('TRACKTOTAL', value.toString()); + }; break; + + case 'trackNumber': { + addCommentTag('DISCNUMBER', value.toString()); + }; break; + + case 'trackNumberMax': { + addCommentTag('DISCTOTAL', value.toString()); + }; break; + + case 'images': { + for (const image of value) { + // https://datatracker.ietf.org/doc/rfc9639/ Section 8.8 + const pictureType = image.kind === 'coverFront' ? 3 : image.kind === 'coverBack' ? 4 : 0; + const encodedMediaType = new Uint8Array(image.mimeType.length); + + for (let i = 0; i < image.mimeType.length; i++) { + encodedMediaType[i] = image.mimeType.charCodeAt(i); + } + + const encodedDescription = textEncoder.encode(image.description ?? ''); + + const buffer = new Uint8Array( + 4 // Picture type + + 4 // MIME type length + + encodedMediaType.length // MIME type + + 4 // Description length + + encodedDescription.length // Description + + 16 // Width, height, color depth, number of colors + + 4 // Picture data length + + image.data.length, // Picture data + ); + const view = toDataView(buffer); + + view.setUint32(0, pictureType, false); + view.setUint32(4, encodedMediaType.length, false); + buffer.set(encodedMediaType, 8); + view.setUint32(8 + encodedMediaType.length, encodedDescription.length, false); + buffer.set(encodedDescription, 12 + encodedMediaType.length); + // Skip a bunch of fields (width, height, color depth, number of colors) + view.setUint32( + 28 + encodedMediaType.length + encodedDescription.length, image.data.length, false, + ); + buffer.set( + image.data, + 32 + encodedMediaType.length + encodedDescription.length, + ); + + const encoded = bytesToBase64(buffer); + addCommentTag('METADATA_BLOCK_PICTURE', encoded); + } + }; break; + + case 'raw': { + // Handled later + }; break; + + default: assertNever(key); + } + } + + if (metadata.raw) { + for (const key in metadata.raw) { + const value = metadata.raw[key]; + if (key === 'vendor' || value == null || writtenTags.has(key)) { + continue; + } + + if (typeof value === 'string') { + addCommentTag(key, value); + } + } + } + + const listLengthBuffer = new Uint8Array(4); + toDataView(listLengthBuffer).setUint32(0, writtenTags.size, true); + commentHeaderParts.splice(2, 0, listLengthBuffer); // Insert after the header and vendor section + + // Merge all comment header parts into a single buffer + const commentHeaderLength = commentHeaderParts.reduce((a, b) => a + b.length, 0); + const commentHeader = new Uint8Array(commentHeaderLength); + + let pos = 0; + for (const part of commentHeaderParts) { + commentHeader.set(part, pos); + pos += part.length; + } + + return commentHeader; + } + async addEncodedAudioPacket(track: OutputAudioTrack, packet: EncodedPacket, meta?: EncodedAudioChunkMetadata) { const release = await this.mutex.acquire();