diff --git a/src/id3.ts b/src/id3.ts new file mode 100644 index 0000000..f231176 --- /dev/null +++ b/src/id3.ts @@ -0,0 +1,925 @@ +/*! + * Copyright (c) 2025-present, Vanilagy and contributors + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +import { decodeSynchsafe, encodeSynchsafe } from '../shared/mp3-misc'; +import { MetadataTags } from './tags'; +import { + coalesceIndex, + textDecoder, + textEncoder, + isIso88591Compatible, + assertNever, + keyValueIterator, + toDataView, +} from './misc'; +import { FileSlice, readAscii, readBytes, readU32Be, readU8 } from './reader'; +import { Writer } from './writer'; + +export type Id3V2Header = { + majorVersion: number; + revision: number; + flags: number; + size: number; +}; + +export enum Id3V2HeaderFlags { + Unsynchronisation = 1 << 7, + ExtendedHeader = 1 << 6, + ExperimentalIndicator = 1 << 5, + Footer = 1 << 4, +} + +export enum Id3V2TextEncoding { + ISO_8859_1, + UTF_16_WITH_BOM, + UTF_16_BE_NO_BOM, + UTF_8, +} + +export const ID3_V1_TAG_SIZE = 128; +export const ID3_V2_HEADER_SIZE = 10; + +export const ID3_V1_GENRES = [ + 'Blues', 'Classic rock', 'Country', 'Dance', 'Disco', 'Funk', 'Grunge', 'Hip-hop', 'Jazz', + 'Metal', 'New age', 'Oldies', 'Other', 'Pop', 'Rhythm and blues', 'Rap', 'Reggae', 'Rock', + 'Techno', 'Industrial', 'Alternative', 'Ska', 'Death metal', 'Pranks', 'Soundtrack', + 'Euro-techno', 'Ambient', 'Trip-hop', 'Vocal', 'Jazz & funk', 'Fusion', 'Trance', 'Classical', + 'Instrumental', 'Acid', 'House', 'Game', 'Sound clip', 'Gospel', 'Noise', 'Alternative rock', + 'Bass', 'Soul', 'Punk', 'Space', 'Meditative', 'Instrumental pop', 'Instrumental rock', + 'Ethnic', 'Gothic', 'Darkwave', 'Techno-industrial', 'Electronic', 'Pop-folk', 'Eurodance', + 'Dream', 'Southern rock', 'Comedy', 'Cult', 'Gangsta', 'Top 40', 'Christian rap', 'Pop/funk', + 'Jungle music', 'Native US', 'Cabaret', 'New wave', 'Psychedelic', 'Rave', 'Showtunes', + 'Trailer', 'Lo-fi', 'Tribal', 'Acid punk', 'Acid jazz', 'Polka', 'Retro', 'Musical', + 'Rock \'n\' roll', 'Hard rock', 'Folk', 'Folk rock', 'National folk', 'Swing', 'Fast fusion', + 'Bebop', 'Latin', 'Revival', 'Celtic', 'Bluegrass', 'Avantgarde', 'Gothic rock', + 'Progressive rock', 'Psychedelic rock', 'Symphonic rock', 'Slow rock', 'Big band', 'Chorus', + 'Easy listening', 'Acoustic', 'Humour', 'Speech', 'Chanson', 'Opera', 'Chamber music', + 'Sonata', 'Symphony', 'Booty bass', 'Primus', 'Porn groove', 'Satire', 'Slow jam', 'Club', + 'Tango', 'Samba', 'Folklore', 'Ballad', 'Power ballad', 'Rhythmic Soul', 'Freestyle', 'Duet', + 'Punk rock', 'Drum solo', 'A cappella', 'Euro-house', 'Dance hall', 'Goa music', 'Drum & bass', + 'Club-house', 'Hardcore techno', 'Terror', 'Indie', 'Britpop', 'Negerpunk', 'Polsk punk', + 'Beat', 'Christian gangsta rap', 'Heavy metal', 'Black metal', 'Crossover', + 'Contemporary Christian', 'Christian rock', 'Merengue', 'Salsa', 'Thrash metal', 'Anime', + 'Jpop', 'Synthpop', 'Christmas', 'Art rock', 'Baroque', 'Bhangra', 'Big beat', 'Breakbeat', + 'Chillout', 'Downtempo', 'Dub', 'EBM', 'Eclectic', 'Electro', 'Electroclash', 'Emo', + 'Experimental', 'Garage', 'Global', 'IDM', 'Illbient', 'Industro-Goth', 'Jam Band', + 'Krautrock', 'Leftfield', 'Lounge', 'Math rock', 'New romantic', 'Nu-breakz', 'Post-punk', + 'Post-rock', 'Psytrance', 'Shoegaze', 'Space rock', 'Trop rock', 'World music', 'Neoclassical', + 'Audiobook', 'Audio theatre', 'Neue Deutsche Welle', 'Podcast', 'Indie rock', 'G-Funk', + 'Dubstep', 'Garage rock', 'Psybient', +]; + +export const parseId3V1Tag = (slice: FileSlice, tags: MetadataTags) => { + const startPos = slice.filePos; + tags.raw ??= {}; + tags.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); + if (title) tags.title ??= title; + + const artist = readId3V1String(slice, 30); + if (artist) tags.artist ??= artist; + + const album = readId3V1String(slice, 30); + if (album) tags.album ??= album; + + const yearText = readId3V1String(slice, 4); + const year = Number.parseInt(yearText, 10); + if (Number.isInteger(year) && year > 0) { + tags.date ??= new Date(year, 0, 1); + } + + const commentBytes = readBytes(slice, 30); + let comment: string; + + // Check for the ID3v1.1 track number format: + // The 29th byte (index 28) is a null terminator, and the 30th byte is the track number. + if (commentBytes[28] === 0 && commentBytes[29] !== 0) { + const trackNum = commentBytes[29]!; + if (trackNum > 0) { + tags.trackNumber ??= trackNum; + } + + slice.skip(-30); + comment = readId3V1String(slice, 28); + slice.skip(2); + } else { + slice.skip(-30); + comment = readId3V1String(slice, 30); + } + + if (comment) tags.comment ??= comment; + + const genreIndex = readU8(slice); + if (genreIndex < ID3_V1_GENRES.length) { + tags.genre ??= ID3_V1_GENRES[genreIndex]; + } +}; + +export const readId3V1String = (slice: FileSlice, length: number) => { + const bytes = readBytes(slice, length); + + const endIndex = coalesceIndex(bytes.indexOf(0), bytes.length); + const relevantBytes = bytes.subarray(0, endIndex); + + // Decode as ISO-8859-1 + let str = ''; + for (let i = 0; i < relevantBytes.length; i++) { + str += String.fromCharCode(relevantBytes[i]!); + } + + return str.trimEnd(); // String also may be padded with spaces +}; + +export const readId3V2Header = (slice: FileSlice): Id3V2Header | null => { + const startPos = slice.filePos; + + const tag = readAscii(slice, 3); + const majorVersion = readU8(slice); + const revision = readU8(slice); + const flags = readU8(slice); + const sizeRaw = readU32Be(slice); + + if (tag !== 'ID3' || majorVersion === 0xff || revision === 0xff || (sizeRaw & 0x80808080) !== 0) { + slice.filePos = startPos; + return null; + } + + const size = decodeSynchsafe(sizeRaw); + + return { majorVersion, revision, flags, size }; +}; + +export const parseId3V2Tag = (slice: FileSlice, header: Id3V2Header, tags: MetadataTags) => { + // https://id3.org/id3v2.3.0 + + if (![2, 3, 4].includes(header.majorVersion)) { + console.warn(`Unsupported ID3v2 major version: ${header.majorVersion}`); + return; + } + + const bytes = readBytes(slice, header.size); + const reader = new Id3V2Reader(header, bytes); + + if (header.flags & Id3V2HeaderFlags.Footer) { + reader.removeFooter(); + } + + if ((header.flags & Id3V2HeaderFlags.Unsynchronisation) && header.majorVersion === 3) { + reader.ununsynchronizeAll(); + } + + if (header.flags & Id3V2HeaderFlags.ExtendedHeader) { + const extendedHeaderSize = reader.readU32(); + + if (header.majorVersion === 3) { + reader.pos += extendedHeaderSize; // The extended header size excludes itself + } else { + reader.pos += extendedHeaderSize - 4; // The extended header size includes itself + } + } + + while (reader.pos <= reader.bytes.length - reader.frameHeaderSize()) { + const frame = reader.readId3V2Frame(); + if (!frame) { + break; + } + + const frameStartPos = reader.pos; + const frameEndPos = reader.pos + frame.size; + + let frameEncrypted = false; + let frameCompressed = false; + let frameUnsynchronized = false; + + if (header.majorVersion === 3) { + frameEncrypted = !!(frame.flags & (1 << 6)); + frameCompressed = !!(frame.flags & (1 << 7)); + } else if (header.majorVersion === 4) { + frameEncrypted = !!(frame.flags & (1 << 2)); + frameCompressed = !!(frame.flags & (1 << 3)); + frameUnsynchronized = !!(frame.flags & (1 << 1)) + || !!(header.flags & Id3V2HeaderFlags.Unsynchronisation); + } + + if (frameEncrypted) { + console.warn(`Skipping encrypted ID3v2 frame ${frame.id}`); + reader.pos = frameEndPos; + continue; + } + + if (frameCompressed) { + console.warn(`Skipping compressed ID3v2 frame ${frame.id}`); // Maybe someday? Idk + reader.pos = frameEndPos; + continue; + } + + if (frameUnsynchronized) { + reader.ununsynchronizeRegion(reader.pos, frameEndPos); + } + + tags.raw ??= {}; + if (frame.id[0] === 'T') { + // It's a text frame, let's decode as text + tags.raw[frame.id] ??= reader.readId3V2EncodingAndText(frameEndPos); + } else { + // For the others, let's just get the bytes + tags.raw[frame.id] ??= reader.readBytes(frame.size); + } + + reader.pos = frameStartPos; + + switch (frame.id) { + case 'TIT2': + case 'TT2': { + tags.title ??= reader.readId3V2EncodingAndText(frameEndPos); + }; break; + + case 'TIT3': + case 'TT3': { + tags.description ??= reader.readId3V2EncodingAndText(frameEndPos); + }; break; + + case 'TPE1': + case 'TP1': { + tags.artist ??= reader.readId3V2EncodingAndText(frameEndPos); + }; break; + + case 'TALB': + case 'TAL': { + tags.album ??= reader.readId3V2EncodingAndText(frameEndPos); + }; break; + + case 'TPE2': + case 'TP2': { + tags.albumArtist ??= reader.readId3V2EncodingAndText(frameEndPos); + }; break; + + case 'TRCK': + case 'TRK': { + const trackText = reader.readId3V2EncodingAndText(frameEndPos); + const parts = trackText.split('/'); + const trackNum = Number.parseInt(parts[0]!, 10); + const tracksTotal = parts[1] && Number.parseInt(parts[1], 10); + + if (Number.isInteger(trackNum) && trackNum > 0) { + tags.trackNumber ??= trackNum; + } + if (tracksTotal && Number.isInteger(tracksTotal) && tracksTotal > 0) { + tags.tracksTotal ??= tracksTotal; + } + }; break; + + case 'TPOS': + case 'TPA': { + const discText = reader.readId3V2EncodingAndText(frameEndPos); + const parts = discText.split('/'); + const discNum = Number.parseInt(parts[0]!, 10); + const discsTotal = parts[1] && Number.parseInt(parts[1], 10); + + if (Number.isInteger(discNum) && discNum > 0) { + tags.discNumber ??= discNum; + } + if (discsTotal && Number.isInteger(discsTotal) && discsTotal > 0) { + tags.discsTotal ??= discsTotal; + } + }; break; + + case 'TCON': + case 'TCO': { + const genreText = reader.readId3V2EncodingAndText(frameEndPos); + let match = /^\((\d+)\)/.exec(genreText); + if (match) { + const genreNumber = Number.parseInt(match[1]!); + if (ID3_V1_GENRES[genreNumber] !== undefined) { + tags.genre ??= ID3_V1_GENRES[genreNumber]; + break; + } + } + + match = /^\d+$/.exec(genreText); + if (match) { + const genreNumber = Number.parseInt(match[0]); + if (ID3_V1_GENRES[genreNumber] !== undefined) { + tags.genre ??= ID3_V1_GENRES[genreNumber]; + break; + } + } + + tags.genre ??= genreText; + }; break; + + case 'TDRC': + case 'TDAT': { + const dateText = reader.readId3V2EncodingAndText(frameEndPos); + const date = new Date(dateText); + + if (!Number.isNaN(date.getTime())) { + tags.date ??= date; + } + }; break; + + case 'TYER': + case 'TYE': { + const yearText = reader.readId3V2EncodingAndText(frameEndPos); + const year = Number.parseInt(yearText, 10); + + if (Number.isInteger(year)) { + tags.date ??= new Date(year, 0, 1); + } + }; break; + + case 'USLT': + case 'ULT': { + const encoding = reader.readU8(); + reader.pos += 3; // Skip language + reader.readId3V2Text(encoding, frameEndPos); // Short content description + tags.lyrics ??= reader.readId3V2Text(encoding, frameEndPos); + }; break; + + case 'COMM': + case 'COM': { + const encoding = reader.readU8(); + reader.pos += 3; // Skip language + reader.readId3V2Text(encoding, frameEndPos); // Short content description + tags.comment ??= reader.readId3V2Text(encoding, frameEndPos); + }; break; + + case 'APIC': + case 'PIC': { + const encoding = reader.readId3V2TextEncoding(); + + let mimeType: string; + if (header.majorVersion === 2) { + const imageFormat = reader.readAscii(3); + mimeType = imageFormat === 'PNG' + ? 'image/png' + : imageFormat === 'JPG' + ? 'image/jpeg' + : 'image/*'; + } else { + mimeType = reader.readId3V2Text(encoding, frameEndPos); + } + + const pictureType = reader.readU8(); + const description = reader.readId3V2Text(encoding, frameEndPos).trimEnd(); // Trim ending spaces + + const imageDataSize = frameEndPos - reader.pos; + if (imageDataSize >= 0) { + const imageData = reader.readBytes(imageDataSize); + + if (!tags.images) tags.images = []; + tags.images.push({ + data: imageData, + mimeType, + kind: pictureType === 3 + ? 'coverFront' + : pictureType === 4 + ? 'coverBack' + : 'unknown', + description, + }); + } + }; break; + + default: { + reader.pos += frame.size; + }; break; + } + + reader.pos = frameEndPos; + } +}; + +// https://id3.org/id3v2.3.0 +export class Id3V2Reader { + pos = 0; + view: DataView; + + constructor(public header: Id3V2Header, public bytes: Uint8Array) { + this.view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + } + + frameHeaderSize() { + return this.header.majorVersion === 2 ? 6 : 10; + } + + ununsynchronizeAll() { + const newBytes: number[] = []; + + for (let i = 0; i < this.bytes.length; i++) { + const value1 = this.bytes[i]!; + newBytes.push(value1); + + if (value1 === 0xff && i !== this.bytes.length - 1) { + const value2 = this.bytes[i]!; + if (value2 === 0x00) { + i++; + } + } + } + + this.bytes = new Uint8Array(newBytes); + this.view = new DataView(this.bytes.buffer); + } + + ununsynchronizeRegion(start: number, end: number) { + const newBytes: number[] = []; + + for (let i = start; i < end; i++) { + const value1 = this.bytes[i]!; + newBytes.push(value1); + + if (value1 === 0xff && i !== end - 1) { + const value2 = this.bytes[i + 1]!; + if (value2 === 0x00) { + i++; + } + } + } + + const before = this.bytes.subarray(0, start); + const after = this.bytes.subarray(end); + + this.bytes = new Uint8Array(before.length + newBytes.length + after.length); + this.bytes.set(before, 0); + this.bytes.set(newBytes, before.length); + this.bytes.set(after, before.length + newBytes.length); + + this.view = new DataView(this.bytes.buffer); + } + + removeFooter() { + this.bytes = this.bytes.subarray(0, this.bytes.length - ID3_V2_HEADER_SIZE); + this.view = new DataView(this.bytes.buffer); + } + + readBytes(length: number) { + const slice = this.bytes.subarray(this.pos, this.pos + length); + this.pos += length; + return slice; + } + + readU8() { + const value = this.view.getUint8(this.pos); + this.pos += 1; + return value; + } + + readU16() { + const value = this.view.getUint16(this.pos, false); + this.pos += 2; + return value; + } + + readU24() { + const high = this.view.getUint16(this.pos, false); + const low = this.view.getUint8(this.pos + 1); + this.pos += 3; + return high * 0x100 + low; + } + + readU32() { + const value = this.view.getUint32(this.pos, false); + this.pos += 4; + return value; + } + + readAscii(length: number) { + let str = ''; + for (let i = 0; i < length; i++) { + str += String.fromCharCode(this.view.getUint8(this.pos + i)); + } + this.pos += length; + return str; + } + + readId3V2Frame() { + if (this.header.majorVersion === 2) { + const id = this.readAscii(3); + if (id === '\x00\x00\x00') { + return null; + } + + const size = this.readU24(); + + return { id, size, flags: 0 }; + } else { + const id = this.readAscii(4); + if (id === '\x00\x00\x00\x00') { + // We've landed in the padding section + return null; + } + + const sizeRaw = this.readU32(); + let size = this.header.majorVersion === 4 + ? decodeSynchsafe(sizeRaw) + : sizeRaw; + const flags = this.readU16(); + const headerEndPos = this.pos; + + // Some files may have incorrectly synchsafed/unsynchsafed sizes. To validate which interpretation is valid, + // we validate a size by skipping ahead and seeing if we land at a valid frame header (or at the end of the + // tag. + + const isSizeValid = (size: number) => { + const nextPos = this.pos + size; + if (nextPos > this.bytes.length) { + return false; + } + + if (nextPos <= this.bytes.length - this.frameHeaderSize()) { + this.pos += size; + const nextId = this.readAscii(4); + if (nextId !== '\x00\x00\x00\x00' && !/[0-9A-Z]{4}/.test(nextId)) { + return false; + } + } + + return true; + }; + + if (!isSizeValid(size)) { + // Flip the synchsafing, and try if this one makes more sense + const otherSize = this.header.majorVersion === 4 + ? sizeRaw + : decodeSynchsafe(sizeRaw); + + if (isSizeValid(otherSize)) { + size = otherSize; + } + } + + this.pos = headerEndPos; + return { id, size, flags }; + } + } + + readId3V2TextEncoding(): Id3V2TextEncoding { + const number = this.readU8(); + if (number > 3) { + throw new Error(`Unsupported text encoding: ${number}`); + } + return number; + } + + readId3V2Text(encoding: Id3V2TextEncoding, until: number): string { + const startPos = this.pos; + const data = this.readBytes(until - this.pos); + + switch (encoding) { + case Id3V2TextEncoding.ISO_8859_1: { + let str = ''; + + for (let i = 0; i < data.length; i++) { + const value = data[i]!; + if (value === 0) { + this.pos = startPos + i + 1; + break; + } + str += String.fromCharCode(value); + } + + return str; + } + + case Id3V2TextEncoding.UTF_16_WITH_BOM: { + if (data[0] === 0xff && data[1] === 0xfe) { + const decoder = new TextDecoder('utf-16le'); + const endIndex = coalesceIndex( + data.findIndex((x, i) => x === 0 && data[i + 1] === 0 && i % 2 === 0), + data.length, + ); + this.pos = startPos + Math.min(endIndex + 2, data.length); + return decoder.decode(data.subarray(2, endIndex)); + } else if (data[0] === 0xfe && data[1] === 0xff) { + const decoder = new TextDecoder('utf-16be'); + const endIndex = coalesceIndex( + data.findIndex((x, i) => x === 0 && data[i + 1] === 0 && i % 2 === 0), + data.length, + ); + this.pos = startPos + Math.min(endIndex + 2, data.length); + return decoder.decode(data.subarray(2, endIndex)); + } else { + // Treat it like UTF-8, some files do this + const endIndex = coalesceIndex(data.findIndex(x => x === 0), data.length); + this.pos = startPos + Math.min(endIndex + 1, data.length); + return textDecoder.decode(data.subarray(0, endIndex)); + } + } + + case Id3V2TextEncoding.UTF_16_BE_NO_BOM: { + const decoder = new TextDecoder('utf-16be'); + const endIndex = coalesceIndex( + data.findIndex((x, i) => x === 0 && data[i + 1] === 0 && i % 2 === 0), + data.length, + ); + this.pos = startPos + Math.min(endIndex + 2, data.length); + return decoder.decode(data.subarray(0, endIndex)); + } + + case Id3V2TextEncoding.UTF_8: { + const endIndex = coalesceIndex(data.findIndex(x => x === 0), data.length); + this.pos = startPos + Math.min(endIndex + 1, data.length); + return textDecoder.decode(data.subarray(0, endIndex)); + } + } + } + + readId3V2EncodingAndText(until: number) { + if (this.pos >= until) { + return ''; + } + + const encoding = this.readId3V2TextEncoding(); + return this.readId3V2Text(encoding, until); + } +} + +export class Id3V2Writer { + writer: Writer; + helper = new Uint8Array(8); + helperView = toDataView(this.helper); + + constructor(writer: Writer) { + this.writer = writer; + } + + writeId3V2Tag(metadata: MetadataTags): number { + const tagStartPos = this.writer.getPos(); + + // Write ID3v2.4 header + this.writeAscii('ID3'); + this.writeU8(0x04); // Version 2.4 + this.writeU8(0x00); // Revision 0 + this.writeU8(0x00); // Flags + this.writeSynchsafeU32(0); // Size placeholder + + const framesStartPos = this.writer.getPos(); + const writtenTags = new Set(); + + // Write all metadata frames + for (const { key, value } of keyValueIterator(metadata)) { + switch (key) { + case 'title': { + this.writeId3V2TextFrame('TIT2', value); + writtenTags.add('TIT2'); + }; break; + + case 'description': { + this.writeId3V2TextFrame('TIT3', value); + writtenTags.add('TIT3'); + }; break; + + case 'artist': { + this.writeId3V2TextFrame('TPE1', value); + writtenTags.add('TPE1'); + }; break; + + case 'album': { + this.writeId3V2TextFrame('TALB', value); + writtenTags.add('TALB'); + }; break; + + case 'albumArtist': { + this.writeId3V2TextFrame('TPE2', value); + writtenTags.add('TPE2'); + }; break; + + case 'trackNumber': { + const string = metadata.tracksTotal !== undefined + ? `${value}/${metadata.tracksTotal}` + : value.toString(); + this.writeId3V2TextFrame('TRCK', string); + writtenTags.add('TRCK'); + }; break; + + case 'discNumber': { + const string = metadata.discsTotal !== undefined + ? `${value}/${metadata.discsTotal}` + : value.toString(); + this.writeId3V2TextFrame('TPOS', string); + writtenTags.add('TPOS'); + }; break; + + case 'genre': { + this.writeId3V2TextFrame('TCON', value); + writtenTags.add('TCON'); + }; break; + + case 'date': { + this.writeId3V2TextFrame('TDRC', value.toISOString().slice(0, 10)); + writtenTags.add('TDRC'); + }; break; + + case 'lyrics': { + this.writeId3V2LyricsFrame(value); + writtenTags.add('USLT'); + }; break; + + case 'comment': { + this.writeId3V2CommentFrame(value); + writtenTags.add('COMM'); + }; break; + + case 'images': { + const pictureTypeMap = { coverFront: 0x03, coverBack: 0x04, unknown: 0x00 }; + for (const image of value) { + const pictureType = pictureTypeMap[image.kind] ?? 0x00; + const description = image.description ?? ''; + this.writeId3V2ApicFrame(image.mimeType, pictureType, description, image.data); + } + }; break; + + case 'tracksTotal': + case 'discsTotal': { + // Handled with trackNumber and discNumber respectively + }; 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; + } + + 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 + } else if (value instanceof Uint8Array) { + bytes = value; + } else { + continue; + } + + this.writeAscii(key); + this.writeSynchsafeU32(bytes.byteLength); + this.writeU16(0x0000); + this.writer.write(bytes); + } + } + + const framesEndPos = this.writer.getPos(); + const framesSize = framesEndPos - framesStartPos; + + // Update the size field in the header (synchsafe) + this.writer.seek(tagStartPos + 6); // Skip 'ID3' + version + revision + flags + this.writeSynchsafeU32(framesSize); + this.writer.seek(framesEndPos); + + return framesSize + 10; // +10 for the header size + } + + writeU8(value: number) { + this.helper[0] = value; + this.writer.write(this.helper.subarray(0, 1)); + } + + writeU16(value: number) { + this.helperView.setUint16(0, value, false); + this.writer.write(this.helper.subarray(0, 2)); + } + + writeU32(value: number) { + this.helperView.setUint32(0, value, false); + this.writer.write(this.helper.subarray(0, 4)); + } + + writeAscii(text: string) { + for (let i = 0; i < text.length; i++) { + this.helper[i] = text.charCodeAt(i); + } + this.writer.write(this.helper.subarray(0, text.length)); + } + + writeSynchsafeU32(value: number) { + this.writeU32(encodeSynchsafe(value)); + } + + writeIsoString(text: string) { + const bytes = new Uint8Array(text.length + 1); + for (let i = 0; i < text.length; i++) { + bytes[i] = text.charCodeAt(i); + } + bytes[text.length] = 0x00; + this.writer.write(bytes); + } + + writeUtf8String(text: string) { + const utf8Data = textEncoder.encode(text); + this.writer.write(utf8Data); + this.writeU8(0x00); + } + + writeId3V2TextFrame(frameId: string, text: string) { + const useIso88591 = isIso88591Compatible(text); + const textDataLength = useIso88591 ? text.length : textEncoder.encode(text).byteLength; + const frameSize = 1 + textDataLength + 1; + + this.writeAscii(frameId); + this.writeSynchsafeU32(frameSize); + this.writeU16(0x0000); + + this.writeU8(useIso88591 ? Id3V2TextEncoding.ISO_8859_1 : Id3V2TextEncoding.UTF_8); + if (useIso88591) { + this.writeIsoString(text); + } else { + this.writeUtf8String(text); + } + } + + writeId3V2LyricsFrame(lyrics: string) { + const useIso88591 = isIso88591Compatible(lyrics); + const shortDescription = ''; + const frameSize = 1 + 3 + shortDescription.length + 1 + lyrics.length + 1; + + this.writeAscii('USLT'); + this.writeSynchsafeU32(frameSize); + this.writeU16(0x0000); + + this.writeU8(useIso88591 ? Id3V2TextEncoding.ISO_8859_1 : Id3V2TextEncoding.UTF_8); + this.writeAscii('und'); + + if (useIso88591) { + this.writeIsoString(shortDescription); + this.writeIsoString(lyrics); + } else { + this.writeUtf8String(shortDescription); + this.writeUtf8String(lyrics); + } + } + + writeId3V2CommentFrame(comment: string) { + const useIso88591 = isIso88591Compatible(comment); + const textDataLength = useIso88591 ? comment.length : textEncoder.encode(comment).byteLength; + const shortDescription = ''; + const frameSize = 1 + 3 + shortDescription.length + 1 + textDataLength + 1; + + this.writeAscii('COMM'); + this.writeSynchsafeU32(frameSize); + this.writeU16(0x0000); + + this.writeU8(useIso88591 ? Id3V2TextEncoding.ISO_8859_1 : Id3V2TextEncoding.UTF_8); + this.writeU8(0x75); // 'u' + this.writeU8(0x6E); // 'n' + this.writeU8(0x64); // 'd' + + if (useIso88591) { + this.writeIsoString(shortDescription); + this.writeIsoString(comment); + } else { + this.writeUtf8String(shortDescription); + this.writeUtf8String(comment); + } + } + + writeId3V2ApicFrame(mimeType: string, pictureType: number, description: string, imageData: Uint8Array) { + const useIso88591 = isIso88591Compatible(mimeType) && isIso88591Compatible(description); + const descriptionDataLength = useIso88591 + ? description.length + : textEncoder.encode(description).byteLength; + const frameSize = 1 + mimeType.length + 1 + 1 + descriptionDataLength + 1 + imageData.byteLength; + + this.writeAscii('APIC'); + this.writeSynchsafeU32(frameSize); + this.writeU16(0x0000); + + this.writeU8(useIso88591 ? Id3V2TextEncoding.ISO_8859_1 : Id3V2TextEncoding.UTF_8); + + if (useIso88591) { + this.writeIsoString(mimeType); + } else { + this.writeUtf8String(mimeType); + } + + this.writeU8(pictureType); + + if (useIso88591) { + this.writeIsoString(description); + } else { + this.writeUtf8String(description); + } + + this.writer.write(imageData); + } +} diff --git a/src/input-format.ts b/src/input-format.ts index 5cfe902..ed57825 100644 --- a/src/input-format.ts +++ b/src/input-format.ts @@ -22,7 +22,8 @@ import { import { MatroskaDemuxer } from './matroska/matroska-demuxer'; import { Mp3Demuxer } from './mp3/mp3-demuxer'; import { FRAME_HEADER_SIZE } from '../shared/mp3-misc'; -import { ID3_V2_HEADER_SIZE, readId3V2Header, readNextFrameHeader } from './mp3/mp3-reader'; +import { ID3_V2_HEADER_SIZE, readId3V2Header } from './id3'; +import { readNextFrameHeader } from './mp3/mp3-reader'; import { OggDemuxer } from './ogg/ogg-demuxer'; import { WaveDemuxer } from './wave/wave-demuxer'; import { MAX_FRAME_HEADER_SIZE, MIN_FRAME_HEADER_SIZE, readFrameHeader } from './adts/adts-reader'; diff --git a/src/mp3/mp3-demuxer.ts b/src/mp3/mp3-demuxer.ts index 5d3c1a4..a8333b5 100644 --- a/src/mp3/mp3-demuxer.ts +++ b/src/mp3/mp3-demuxer.ts @@ -21,8 +21,8 @@ import { parseId3V1Tag, parseId3V2Tag, readId3V2Header, - readNextFrameHeader, -} from './mp3-reader'; +} from '../id3'; +import { readNextFrameHeader } from './mp3-reader'; import { readAscii, readBytes, Reader, readU32Be } from '../reader'; type Sample = { diff --git a/src/mp3/mp3-muxer.ts b/src/mp3/mp3-muxer.ts index 7eb6563..dd310da 100644 --- a/src/mp3/mp3-muxer.ts +++ b/src/mp3/mp3-muxer.ts @@ -6,8 +6,8 @@ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ -import { assert, assertNever, keyValueIterator, textEncoder, toDataView } from '../misc'; -import { metadataTagsAreEmpty, MetadataTags } from '../tags'; +import { assert, toDataView } from '../misc'; +import { metadataTagsAreEmpty } from '../tags'; import { Muxer } from '../muxer'; import { Output, OutputAudioTrack } from '../output'; import { Mp3OutputFormat } from '../output-format'; @@ -15,7 +15,7 @@ import { EncodedPacket } from '../packet'; import { Writer } from '../writer'; import { getXingOffset, INFO, readFrameHeader, XING } from '../../shared/mp3-misc'; import { Mp3Writer, XingFrameData } from './mp3-writer'; -import { Id3V2TextEncoding } from './mp3-reader'; +import { Id3V2Writer } from '../id3'; export class Mp3Muxer extends Muxer { private format: Mp3OutputFormat; @@ -36,7 +36,8 @@ export class Mp3Muxer extends Muxer { async start() { if (!metadataTagsAreEmpty(this.output._metadataTags)) { - this.writeId3v2Tag(this.output._metadataTags); + const id3Writer = new Id3V2Writer(this.writer); + id3Writer.writeId3V2Tag(this.output._metadataTags); } } @@ -123,138 +124,6 @@ export class Mp3Muxer extends Muxer { throw new Error('MP3 does not support subtitles.'); } - writeId3v2Tag(tags: MetadataTags) { - this.mp3Writer.writeAscii('ID3'); - this.mp3Writer.writeU8(0x04); // Version 2.4 - this.mp3Writer.writeU8(0x00); // Revision 0 - this.mp3Writer.writeU8(0x00); // Flags - this.mp3Writer.writeSynchsafeU32(0); // Size placeholder - - const startPos = this.writer.getPos(); - const writtenTags = new Set(); - - for (const { key, value } of keyValueIterator(tags)) { - switch (key) { - case 'title': { - this.mp3Writer.writeId3V2TextFrame('TIT2', value); - writtenTags.add('TIT2'); - }; break; - - case 'description': { - this.mp3Writer.writeId3V2TextFrame('TIT3', value); - writtenTags.add('TIT3'); - }; break; - - case 'artist': { - this.mp3Writer.writeId3V2TextFrame('TPE1', value); - writtenTags.add('TPE1'); - }; break; - - case 'album': { - this.mp3Writer.writeId3V2TextFrame('TALB', value); - writtenTags.add('TALB'); - }; break; - - case 'albumArtist': { - this.mp3Writer.writeId3V2TextFrame('TPE2', value); - writtenTags.add('TPE2'); - }; break; - - case 'trackNumber': { - const string = tags.tracksTotal !== undefined - ? `${value}/${tags.tracksTotal}` - : value.toString(); - - this.mp3Writer.writeId3V2TextFrame('TRCK', string); - writtenTags.add('TRCK'); - }; break; - - case 'discNumber': { - const string = tags.discsTotal !== undefined - ? `${value}/${tags.discsTotal}` - : value.toString(); - - this.mp3Writer.writeId3V2TextFrame('TPOS', string); - writtenTags.add('TPOS'); - }; break; - - case 'genre': { - this.mp3Writer.writeId3V2TextFrame('TCON', value); - writtenTags.add('TCON'); - }; break; - - case 'date': { - this.mp3Writer.writeId3V2TextFrame('TDRC', value.toISOString().slice(0, 10)); - writtenTags.add('TDRC'); - }; break; - - case 'lyrics': { - this.mp3Writer.writeId3V2LyricsFrame(value); - writtenTags.add('USLT'); - }; break; - - case 'comment': { - this.mp3Writer.writeId3V2CommentFrame(value); - writtenTags.add('COMM'); - }; break; - - case 'images': { - const pictureTypeMap = { coverFront: 0x03, coverBack: 0x04, unknown: 0x00 }; - for (const image of value) { - const pictureType = pictureTypeMap[image.kind]; - const description = image.description ?? ''; - this.mp3Writer.writeId3V2ApicFrame(image.mimeType, pictureType, description, image.data); - } - }; break; - - case 'tracksTotal': - case 'discsTotal': { - // Handled with trackNumber and discNumber respectively - }; break; - - case 'raw': { - // Handled later - }; break; - - default: assertNever(key); - } - } - - if (tags.raw) { - for (const key in tags.raw) { - const value = tags.raw[key]; - if (value == null || key.length !== 4 || writtenTags.has(key)) { - continue; - } - - 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 - } else if (value instanceof Uint8Array) { - bytes = value; - } else { - continue; - } - - this.mp3Writer.writeAscii(key); - this.mp3Writer.writeSynchsafeU32(bytes.byteLength); - this.mp3Writer.writeU16(0x0000); - this.writer.write(bytes); - } - } - - const endPos = this.writer.getPos(); - const framesSize = endPos - startPos; - - this.writer.seek(6); - this.mp3Writer.writeSynchsafeU32(framesSize); - this.writer.seek(endPos); - } - async finalize() { if (!this.xingFrameData || this.xingFramePos === null) { return; diff --git a/src/mp3/mp3-reader.ts b/src/mp3/mp3-reader.ts index f857528..f489ca9 100644 --- a/src/mp3/mp3-reader.ts +++ b/src/mp3/mp3-reader.ts @@ -6,64 +6,8 @@ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ -import { decodeSynchsafe, FRAME_HEADER_SIZE, FrameHeader, readFrameHeader } from '../../shared/mp3-misc'; -import { MetadataTags } from '../tags'; -import { coalesceIndex, textDecoder } from '../misc'; -import { FileSlice, readAscii, readBytes, Reader, readU32Be, readU8 } from '../reader'; - -export type Id3V2Header = { - majorVersion: number; - revision: number; - flags: number; - size: number; -}; - -export enum Id3V2HeaderFlags { - Unsynchronisation = 1 << 7, - ExtendedHeader = 1 << 6, - ExperimentalIndicator = 1 << 5, - Footer = 1 << 4, -} - -export enum Id3V2TextEncoding { - ISO_8859_1, - UTF_16_WITH_BOM, - UTF_16_BE_NO_BOM, - UTF_8, -} - -export const ID3_V1_TAG_SIZE = 128; -export const ID3_V2_HEADER_SIZE = 10; - -export const ID3_V1_GENRES = [ - 'Blues', 'Classic rock', 'Country', 'Dance', 'Disco', 'Funk', 'Grunge', 'Hip-hop', 'Jazz', - 'Metal', 'New age', 'Oldies', 'Other', 'Pop', 'Rhythm and blues', 'Rap', 'Reggae', 'Rock', - 'Techno', 'Industrial', 'Alternative', 'Ska', 'Death metal', 'Pranks', 'Soundtrack', - 'Euro-techno', 'Ambient', 'Trip-hop', 'Vocal', 'Jazz & funk', 'Fusion', 'Trance', 'Classical', - 'Instrumental', 'Acid', 'House', 'Game', 'Sound clip', 'Gospel', 'Noise', 'Alternative rock', - 'Bass', 'Soul', 'Punk', 'Space', 'Meditative', 'Instrumental pop', 'Instrumental rock', - 'Ethnic', 'Gothic', 'Darkwave', 'Techno-industrial', 'Electronic', 'Pop-folk', 'Eurodance', - 'Dream', 'Southern rock', 'Comedy', 'Cult', 'Gangsta', 'Top 40', 'Christian rap', 'Pop/funk', - 'Jungle music', 'Native US', 'Cabaret', 'New wave', 'Psychedelic', 'Rave', 'Showtunes', - 'Trailer', 'Lo-fi', 'Tribal', 'Acid punk', 'Acid jazz', 'Polka', 'Retro', 'Musical', - 'Rock \'n\' roll', 'Hard rock', 'Folk', 'Folk rock', 'National folk', 'Swing', 'Fast fusion', - 'Bebop', 'Latin', 'Revival', 'Celtic', 'Bluegrass', 'Avantgarde', 'Gothic rock', - 'Progressive rock', 'Psychedelic rock', 'Symphonic rock', 'Slow rock', 'Big band', 'Chorus', - 'Easy listening', 'Acoustic', 'Humour', 'Speech', 'Chanson', 'Opera', 'Chamber music', - 'Sonata', 'Symphony', 'Booty bass', 'Primus', 'Porn groove', 'Satire', 'Slow jam', 'Club', - 'Tango', 'Samba', 'Folklore', 'Ballad', 'Power ballad', 'Rhythmic Soul', 'Freestyle', 'Duet', - 'Punk rock', 'Drum solo', 'A cappella', 'Euro-house', 'Dance hall', 'Goa music', 'Drum & bass', - 'Club-house', 'Hardcore techno', 'Terror', 'Indie', 'Britpop', 'Negerpunk', 'Polsk punk', - 'Beat', 'Christian gangsta rap', 'Heavy metal', 'Black metal', 'Crossover', - 'Contemporary Christian', 'Christian rock', 'Merengue', 'Salsa', 'Thrash metal', 'Anime', - 'Jpop', 'Synthpop', 'Christmas', 'Art rock', 'Baroque', 'Bhangra', 'Big beat', 'Breakbeat', - 'Chillout', 'Downtempo', 'Dub', 'EBM', 'Eclectic', 'Electro', 'Electroclash', 'Emo', - 'Experimental', 'Garage', 'Global', 'IDM', 'Illbient', 'Industro-Goth', 'Jam Band', - 'Krautrock', 'Leftfield', 'Lounge', 'Math rock', 'New romantic', 'Nu-breakz', 'Post-punk', - 'Post-rock', 'Psytrance', 'Shoegaze', 'Space rock', 'Trop rock', 'World music', 'Neoclassical', - 'Audiobook', 'Audio theatre', 'Neue Deutsche Welle', 'Podcast', 'Indie rock', 'G-Funk', - 'Dubstep', 'Garage rock', 'Psybient', -]; +import { FRAME_HEADER_SIZE, FrameHeader, readFrameHeader } from '../../shared/mp3-misc'; +import { Reader, readU32Be } from '../reader'; export const readNextFrameHeader = async (reader: Reader, startPos: number, until: number | null): Promise<{ header: FrameHeader; @@ -88,571 +32,3 @@ export const readNextFrameHeader = async (reader: Reader, startPos: number, unti return null; }; - -export const parseId3V1Tag = (slice: FileSlice, tags: MetadataTags) => { - const startPos = slice.filePos; - tags.raw ??= {}; - tags.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); - if (title) tags.title ??= title; - - const artist = readId3V1String(slice, 30); - if (artist) tags.artist ??= artist; - - const album = readId3V1String(slice, 30); - if (album) tags.album ??= album; - - const yearText = readId3V1String(slice, 4); - const year = Number.parseInt(yearText, 10); - if (Number.isInteger(year) && year > 0) { - tags.date ??= new Date(year, 0, 1); - } - - const commentBytes = readBytes(slice, 30); - let comment: string; - - // Check for the ID3v1.1 track number format: - // The 29th byte (index 28) is a null terminator, and the 30th byte is the track number. - if (commentBytes[28] === 0 && commentBytes[29] !== 0) { - const trackNum = commentBytes[29]!; - if (trackNum > 0) { - tags.trackNumber ??= trackNum; - } - - slice.skip(-30); - comment = readId3V1String(slice, 28); - slice.skip(2); - } else { - slice.skip(-30); - comment = readId3V1String(slice, 30); - } - - if (comment) tags.comment ??= comment; - - const genreIndex = readU8(slice); - if (genreIndex < ID3_V1_GENRES.length) { - tags.genre ??= ID3_V1_GENRES[genreIndex]; - } -}; - -export const readId3V1String = (slice: FileSlice, length: number) => { - const bytes = readBytes(slice, length); - - const endIndex = coalesceIndex(bytes.indexOf(0), bytes.length); - const relevantBytes = bytes.subarray(0, endIndex); - - // Decode as ISO-8859-1 - let str = ''; - for (let i = 0; i < relevantBytes.length; i++) { - str += String.fromCharCode(relevantBytes[i]!); - } - - return str.trimEnd(); // String also may be padded with spaces -}; - -export const readId3V2Header = (slice: FileSlice): Id3V2Header | null => { - const startPos = slice.filePos; - - const tag = readAscii(slice, 3); - const majorVersion = readU8(slice); - const revision = readU8(slice); - const flags = readU8(slice); - const sizeRaw = readU32Be(slice); - - if (tag !== 'ID3' || majorVersion === 0xff || revision === 0xff || (sizeRaw & 0x80808080) !== 0) { - slice.filePos = startPos; - return null; - } - - const size = decodeSynchsafe(sizeRaw); - - return { majorVersion, revision, flags, size }; -}; - -export const parseId3V2Tag = (slice: FileSlice, header: Id3V2Header, tags: MetadataTags) => { - // https://id3.org/id3v2.3.0 - - if (![2, 3, 4].includes(header.majorVersion)) { - console.warn(`Unsupported ID3v2 major version: ${header.majorVersion}`); - return; - } - - const bytes = readBytes(slice, header.size); - const reader = new Id3V2Reader(header, bytes); - - if (header.flags & Id3V2HeaderFlags.Footer) { - reader.removeFooter(); - } - - if ((header.flags & Id3V2HeaderFlags.Unsynchronisation) && header.majorVersion === 3) { - reader.ununsynchronizeAll(); - } - - if (header.flags & Id3V2HeaderFlags.ExtendedHeader) { - const extendedHeaderSize = reader.readU32(); - - if (header.majorVersion === 3) { - reader.pos += extendedHeaderSize; // The extended header size excludes itself - } else { - reader.pos += extendedHeaderSize - 4; // The extended header size includes itself - } - } - - while (reader.pos <= reader.bytes.length - reader.frameHeaderSize()) { - const frame = reader.readId3V2Frame(); - if (!frame) { - break; - } - - const frameStartPos = reader.pos; - const frameEndPos = reader.pos + frame.size; - - let frameEncrypted = false; - let frameCompressed = false; - let frameUnsynchronized = false; - - if (header.majorVersion === 3) { - frameEncrypted = !!(frame.flags & (1 << 6)); - frameCompressed = !!(frame.flags & (1 << 7)); - } else if (header.majorVersion === 4) { - frameEncrypted = !!(frame.flags & (1 << 2)); - frameCompressed = !!(frame.flags & (1 << 3)); - frameUnsynchronized = !!(frame.flags & (1 << 1)) - || !!(header.flags & Id3V2HeaderFlags.Unsynchronisation); - } - - if (frameEncrypted) { - console.warn(`Skipping encrypted ID3v2 frame ${frame.id}`); - reader.pos = frameEndPos; - continue; - } - - if (frameCompressed) { - console.warn(`Skipping compressed ID3v2 frame ${frame.id}`); // Maybe someday? Idk - reader.pos = frameEndPos; - continue; - } - - if (frameUnsynchronized) { - reader.ununsynchronizeRegion(reader.pos, frameEndPos); - } - - tags.raw ??= {}; - if (frame.id[0] === 'T') { - // It's a text frame, let's decode as text - tags.raw[frame.id] ??= reader.readId3V2EncodingAndText(frameEndPos); - } else { - // For the others, let's just get the bytes - tags.raw[frame.id] ??= reader.readBytes(frame.size); - } - - reader.pos = frameStartPos; - - switch (frame.id) { - case 'TIT2': - case 'TT2': { - tags.title ??= reader.readId3V2EncodingAndText(frameEndPos); - }; break; - - case 'TIT3': - case 'TT3': { - tags.description ??= reader.readId3V2EncodingAndText(frameEndPos); - }; break; - - case 'TPE1': - case 'TP1': { - tags.artist ??= reader.readId3V2EncodingAndText(frameEndPos); - }; break; - - case 'TALB': - case 'TAL': { - tags.album ??= reader.readId3V2EncodingAndText(frameEndPos); - }; break; - - case 'TPE2': - case 'TP2': { - tags.albumArtist ??= reader.readId3V2EncodingAndText(frameEndPos); - }; break; - - case 'TRCK': - case 'TRK': { - const trackText = reader.readId3V2EncodingAndText(frameEndPos); - const parts = trackText.split('/'); - const trackNum = Number.parseInt(parts[0]!, 10); - const tracksTotal = parts[1] && Number.parseInt(parts[1], 10); - - if (Number.isInteger(trackNum) && trackNum > 0) { - tags.trackNumber ??= trackNum; - } - if (tracksTotal && Number.isInteger(tracksTotal) && tracksTotal > 0) { - tags.tracksTotal ??= tracksTotal; - } - }; break; - - case 'TPOS': - case 'TPA': { - const discText = reader.readId3V2EncodingAndText(frameEndPos); - const parts = discText.split('/'); - const discNum = Number.parseInt(parts[0]!, 10); - const discsTotal = parts[1] && Number.parseInt(parts[1], 10); - - if (Number.isInteger(discNum) && discNum > 0) { - tags.discNumber ??= discNum; - } - if (discsTotal && Number.isInteger(discsTotal) && discsTotal > 0) { - tags.discsTotal ??= discsTotal; - } - }; break; - - case 'TCON': - case 'TCO': { - const genreText = reader.readId3V2EncodingAndText(frameEndPos); - let match = /^\((\d+)\)/.exec(genreText); - if (match) { - const genreNumber = Number.parseInt(match[1]!); - if (ID3_V1_GENRES[genreNumber] !== undefined) { - tags.genre ??= ID3_V1_GENRES[genreNumber]; - break; - } - } - - match = /^\d+$/.exec(genreText); - if (match) { - const genreNumber = Number.parseInt(match[0]); - if (ID3_V1_GENRES[genreNumber] !== undefined) { - tags.genre ??= ID3_V1_GENRES[genreNumber]; - break; - } - } - - tags.genre ??= genreText; - }; break; - - case 'TDRC': - case 'TDAT': { - const dateText = reader.readId3V2EncodingAndText(frameEndPos); - const date = new Date(dateText); - - if (!Number.isNaN(date.getTime())) { - tags.date ??= date; - } - }; break; - - case 'TYER': - case 'TYE': { - const yearText = reader.readId3V2EncodingAndText(frameEndPos); - const year = Number.parseInt(yearText, 10); - - if (Number.isInteger(year)) { - tags.date ??= new Date(year, 0, 1); - } - }; break; - - case 'USLT': - case 'ULT': { - const encoding = reader.readU8(); - reader.pos += 3; // Skip language - reader.readId3V2Text(encoding, frameEndPos); // Short content description - tags.lyrics ??= reader.readId3V2Text(encoding, frameEndPos); - }; break; - - case 'COMM': - case 'COM': { - const encoding = reader.readU8(); - reader.pos += 3; // Skip language - reader.readId3V2Text(encoding, frameEndPos); // Short content description - tags.comment ??= reader.readId3V2Text(encoding, frameEndPos); - }; break; - - case 'APIC': - case 'PIC': { - const encoding = reader.readId3V2TextEncoding(); - - let mimeType: string; - if (header.majorVersion === 2) { - const imageFormat = reader.readAscii(3); - mimeType = imageFormat === 'PNG' - ? 'image/png' - : imageFormat === 'JPG' - ? 'image/jpeg' - : 'image/*'; - } else { - mimeType = reader.readId3V2Text(encoding, frameEndPos); - } - - const pictureType = reader.readU8(); - const description = reader.readId3V2Text(encoding, frameEndPos).trimEnd(); // Trim ending spaces - - const imageDataSize = frameEndPos - reader.pos; - if (imageDataSize >= 0) { - const imageData = reader.readBytes(imageDataSize); - - if (!tags.images) tags.images = []; - tags.images.push({ - data: imageData, - mimeType, - kind: pictureType === 3 - ? 'coverFront' - : pictureType === 4 - ? 'coverBack' - : 'unknown', - description, - }); - } - }; break; - - default: { - reader.pos += frame.size; - }; break; - } - - reader.pos = frameEndPos; - } -}; - -// https://id3.org/id3v2.3.0 -export class Id3V2Reader { - pos = 0; - view: DataView; - - constructor(public header: Id3V2Header, public bytes: Uint8Array) { - this.view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); - } - - frameHeaderSize() { - return this.header.majorVersion === 2 ? 6 : 10; - } - - ununsynchronizeAll() { - const newBytes: number[] = []; - - for (let i = 0; i < this.bytes.length; i++) { - const value1 = this.bytes[i]!; - newBytes.push(value1); - - if (value1 === 0xff && i !== this.bytes.length - 1) { - const value2 = this.bytes[i]!; - if (value2 === 0x00) { - i++; - } - } - } - - this.bytes = new Uint8Array(newBytes); - this.view = new DataView(this.bytes.buffer); - } - - ununsynchronizeRegion(start: number, end: number) { - const newBytes: number[] = []; - - for (let i = start; i < end; i++) { - const value1 = this.bytes[i]!; - newBytes.push(value1); - - if (value1 === 0xff && i !== end - 1) { - const value2 = this.bytes[i + 1]!; - if (value2 === 0x00) { - i++; - } - } - } - - const before = this.bytes.subarray(0, start); - const after = this.bytes.subarray(end); - - this.bytes = new Uint8Array(before.length + newBytes.length + after.length); - this.bytes.set(before, 0); - this.bytes.set(newBytes, before.length); - this.bytes.set(after, before.length + newBytes.length); - - this.view = new DataView(this.bytes.buffer); - } - - removeFooter() { - this.bytes = this.bytes.subarray(0, this.bytes.length - ID3_V2_HEADER_SIZE); - this.view = new DataView(this.bytes.buffer); - } - - readBytes(length: number) { - const slice = this.bytes.subarray(this.pos, this.pos + length); - this.pos += length; - return slice; - } - - readU8() { - const value = this.view.getUint8(this.pos); - this.pos += 1; - return value; - } - - readU16() { - const value = this.view.getUint16(this.pos, false); - this.pos += 2; - return value; - } - - readU24() { - const high = this.view.getUint16(this.pos, false); - const low = this.view.getUint8(this.pos + 1); - this.pos += 3; - return high * 0x100 + low; - } - - readU32() { - const value = this.view.getUint32(this.pos, false); - this.pos += 4; - return value; - } - - readAscii(length: number) { - let str = ''; - for (let i = 0; i < length; i++) { - str += String.fromCharCode(this.view.getUint8(this.pos + i)); - } - this.pos += length; - return str; - } - - readId3V2Frame() { - if (this.header.majorVersion === 2) { - const id = this.readAscii(3); - if (id === '\x00\x00\x00') { - return null; - } - - const size = this.readU24(); - - return { id, size, flags: 0 }; - } else { - const id = this.readAscii(4); - if (id === '\x00\x00\x00\x00') { - // We've landed in the padding section - return null; - } - - const sizeRaw = this.readU32(); - let size = this.header.majorVersion === 4 - ? decodeSynchsafe(sizeRaw) - : sizeRaw; - const flags = this.readU16(); - const headerEndPos = this.pos; - - // Some files may have incorrectly synchsafed/unsynchsafed sizes. To validate which interpretation is valid, - // we validate a size by skipping ahead and seeing if we land at a valid frame header (or at the end of the - // tag. - - const isSizeValid = (size: number) => { - const nextPos = this.pos + size; - if (nextPos > this.bytes.length) { - return false; - } - - if (nextPos <= this.bytes.length - this.frameHeaderSize()) { - this.pos += size; - const nextId = this.readAscii(4); - if (nextId !== '\x00\x00\x00\x00' && !/[0-9A-Z]{4}/.test(nextId)) { - return false; - } - } - - return true; - }; - - if (!isSizeValid(size)) { - // Flip the synchsafing, and try if this one makes more sense - const otherSize = this.header.majorVersion === 4 - ? sizeRaw - : decodeSynchsafe(sizeRaw); - - if (isSizeValid(otherSize)) { - size = otherSize; - } - } - - this.pos = headerEndPos; - return { id, size, flags }; - } - } - - readId3V2TextEncoding(): Id3V2TextEncoding { - const number = this.readU8(); - if (number > 3) { - throw new Error(`Unsupported text encoding: ${number}`); - } - return number; - } - - readId3V2Text(encoding: Id3V2TextEncoding, until: number): string { - const startPos = this.pos; - const data = this.readBytes(until); - - switch (encoding) { - case Id3V2TextEncoding.ISO_8859_1: { - let str = ''; - - for (let i = 0; i < data.length; i++) { - const value = data[i]!; - if (value === 0) { - this.pos = startPos + i + 1; - break; - } - str += String.fromCharCode(value); - } - - return str; - } - - case Id3V2TextEncoding.UTF_16_WITH_BOM: { - if (data[0] === 0xff && data[1] === 0xfe) { - const decoder = new TextDecoder('utf-16le'); - const endIndex = coalesceIndex( - data.findIndex((x, i) => x === 0 && data[i + 1] === 0 && i % 2 === 0), - data.length, - ); - this.pos = startPos + Math.min(endIndex + 2, data.length); - return decoder.decode(data.subarray(2, endIndex)); - } else if (data[0] === 0xfe && data[1] === 0xff) { - const decoder = new TextDecoder('utf-16be'); - const endIndex = coalesceIndex( - data.findIndex((x, i) => x === 0 && data[i + 1] === 0 && i % 2 === 0), - data.length, - ); - this.pos = startPos + Math.min(endIndex + 2, data.length); - return decoder.decode(data.subarray(2, endIndex)); - } else { - // Treat it like UTF-8, some files do this - const endIndex = coalesceIndex(data.findIndex(x => x === 0), data.length); - this.pos = startPos + Math.min(endIndex + 1, data.length); - return textDecoder.decode(data.subarray(0, endIndex)); - } - } - - case Id3V2TextEncoding.UTF_16_BE_NO_BOM: { - const decoder = new TextDecoder('utf-16be'); - const endIndex = coalesceIndex( - data.findIndex((x, i) => x === 0 && data[i + 1] === 0 && i % 2 === 0), - data.length, - ); - this.pos = startPos + Math.min(endIndex + 2, data.length); - return decoder.decode(data.subarray(0, endIndex)); - } - - case Id3V2TextEncoding.UTF_8: { - const endIndex = coalesceIndex(data.findIndex(x => x === 0), data.length); - this.pos = startPos + Math.min(endIndex + 1, data.length); - return textDecoder.decode(data.subarray(0, endIndex)); - } - } - } - - readId3V2EncodingAndText(until: number) { - if (this.pos >= until) { - return ''; - } - - const encoding = this.readId3V2TextEncoding(); - return this.readId3V2Text(encoding, until); - } -} diff --git a/src/mp3/mp3-writer.ts b/src/mp3/mp3-writer.ts index 97d389e..08bee9a 100644 --- a/src/mp3/mp3-writer.ts +++ b/src/mp3/mp3-writer.ts @@ -6,16 +6,13 @@ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ -import { isIso88591Compatible, textEncoder } from '../misc'; import { Writer } from '../writer'; import { computeMp3FrameSize, - encodeSynchsafe, getXingOffset, KILOBIT_RATES, XING, } from '../../shared/mp3-misc'; -import { Id3V2TextEncoding } from './mp3-reader'; export type XingFrameData = { mpegVersionId: number; @@ -39,28 +36,11 @@ export class Mp3Writer { constructor(private writer: Writer) {} - writeU8(value: number) { - this.helper[0] = value; - this.writer.write(this.helper.subarray(0, 1)); - } - - writeU16(value: number) { - this.helperView.setUint16(0, value, false); - this.writer.write(this.helper.subarray(0, 2)); - } - writeU32(value: number) { this.helperView.setUint32(0, value, false); this.writer.write(this.helper.subarray(0, 4)); } - writeAscii(text: string) { - for (let i = 0; i < text.length; i++) { - this.helper[i] = text.charCodeAt(i); - } - this.writer.write(this.helper.subarray(0, text.length)); - } - writeXingFrame(data: XingFrameData) { const startPos = this.writer.getPos(); @@ -137,113 +117,4 @@ export class Mp3Writer { ); this.writer.seek(startPos + frameSize); } - - writeSynchsafeU32(value: number) { - this.writeU32(encodeSynchsafe(value)); - } - - writeIsoString(text: string) { - const bytes = new Uint8Array(text.length + 1); - for (let i = 0; i < text.length; i++) { - bytes[i] = text.charCodeAt(i); - } - bytes[text.length] = 0x00; - this.writer.write(bytes); - } - - writeUtf8String(text: string) { - const utf8Data = textEncoder.encode(text); - this.writer.write(utf8Data); - this.writeU8(0x00); - } - - writeId3V2TextFrame(frameId: string, text: string) { - const useIso88591 = isIso88591Compatible(text); - const textDataLength = useIso88591 ? text.length : textEncoder.encode(text).byteLength; - const frameSize = 1 + textDataLength + 1; - - this.writeAscii(frameId); - this.writeSynchsafeU32(frameSize); - this.writeU16(0x0000); - - this.writeU8(useIso88591 ? Id3V2TextEncoding.ISO_8859_1 : Id3V2TextEncoding.UTF_8); - if (useIso88591) { - this.writeIsoString(text); - } else { - this.writeUtf8String(text); - } - } - - writeId3V2LyricsFrame(lyrics: string) { - const useIso88591 = isIso88591Compatible(lyrics); - const shortDescription = ''; - const frameSize = 1 + 3 + shortDescription.length + 1 + lyrics.length + 1; - - this.writeAscii('USLT'); - this.writeSynchsafeU32(frameSize); - this.writeU16(0x0000); - - this.writeU8(useIso88591 ? Id3V2TextEncoding.ISO_8859_1 : Id3V2TextEncoding.UTF_8); - this.writeAscii('und'); - - if (useIso88591) { - this.writeIsoString(shortDescription); - this.writeIsoString(lyrics); - } else { - this.writeUtf8String(shortDescription); - this.writeUtf8String(lyrics); - } - } - - writeId3V2CommentFrame(comment: string) { - const useIso88591 = isIso88591Compatible(comment); - const textDataLength = useIso88591 ? comment.length : textEncoder.encode(comment).byteLength; - const shortDescription = ''; - const frameSize = 1 + 3 + shortDescription.length + 1 + textDataLength + 1; - - this.writeAscii('COMM'); - this.writeSynchsafeU32(frameSize); - this.writeU16(0x0000); - - this.writeU8(useIso88591 ? Id3V2TextEncoding.ISO_8859_1 : Id3V2TextEncoding.UTF_8); - this.writeU8(0x75); // 'u' - this.writeU8(0x6E); // 'n' - this.writeU8(0x64); // 'd' - - if (useIso88591) { - this.writeIsoString(shortDescription); - this.writeIsoString(comment); - } else { - this.writeUtf8String(shortDescription); - this.writeUtf8String(comment); - } - } - - writeId3V2ApicFrame(mimeType: string, pictureType: number, description: string, imageData: Uint8Array) { - const useIso88591 = isIso88591Compatible(mimeType) && isIso88591Compatible(description); - const descriptionDataLength = useIso88591 ? description.length : textEncoder.encode(description).byteLength; - const frameSize = 1 + mimeType.length + 1 + 1 + descriptionDataLength + 1 + imageData.byteLength; - - this.writeAscii('APIC'); - this.writeSynchsafeU32(frameSize); - this.writeU16(0x0000); - - this.writeU8(useIso88591 ? Id3V2TextEncoding.ISO_8859_1 : Id3V2TextEncoding.UTF_8); - - if (useIso88591) { - this.writeIsoString(mimeType); - } else { - this.writeUtf8String(mimeType); - } - - this.writeU8(pictureType); - - if (useIso88591) { - this.writeIsoString(description); - } else { - this.writeUtf8String(description); - } - - this.writer.write(imageData); - } } diff --git a/src/output-format.ts b/src/output-format.ts index 8bc542c..8a5d4ed 100644 --- a/src/output-format.ts +++ b/src/output-format.ts @@ -626,8 +626,18 @@ export type WavOutputFormatOptions = { large?: boolean; /** - * Will be called once the file header is written. The header consists of the RIFF header, the format chunk, and the - * start of the data chunk (with a placeholder size of 0). + * The metadata format to use for writing metadata tags. + * + * - `'info'` (default): Writes metadata into a RIFF INFO LIST chunk, the default way to contain metadata tags + * within WAVE. Only allows for a limited subset of tags to be written. + * - `'id3'`: Writes metadata into an ID3 chunk. Non-default, but used by many taggers in practice. Allows for a + * much larger and richer set of tags to be written. + */ + metadataFormat?: 'info' | 'id3'; + + /** + * Will be called once the file header is written. The header consists of the RIFF header, the format chunk, + * metadata chunks, and the start of the data chunk (with a placeholder size of 0). */ onHeader?: (data: Uint8Array, position: number) => unknown; }; @@ -649,6 +659,9 @@ export class WavOutputFormat extends OutputFormat { if (options.large !== undefined && typeof options.large !== 'boolean') { throw new TypeError('options.large, when provided, must be a boolean.'); } + if (options.metadataFormat !== undefined && !['info', 'id3'].includes(options.metadataFormat)) { + throw new TypeError('options.metadataFormat, when provided, must be either \'info\' or \'id3\'.'); + } if (options.onHeader !== undefined && typeof options.onHeader !== 'function') { throw new TypeError('options.onHeader, when provided, must be a function.'); } diff --git a/src/wave/wave-demuxer.ts b/src/wave/wave-demuxer.ts index 92669d3..1b5bbe4 100644 --- a/src/wave/wave-demuxer.ts +++ b/src/wave/wave-demuxer.ts @@ -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); diff --git a/src/wave/wave-muxer.ts b/src/wave/wave-muxer.ts index e1259f6..63c8f96 100644 --- a/src/wave/wave-muxer.ts +++ b/src/wave/wave-muxer.ts @@ -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(); diff --git a/test/node/metadata-tags.test.ts b/test/node/metadata-tags.test.ts index ee8a64f..a742cee 100644 --- a/test/node/metadata-tags.test.ts +++ b/test/node/metadata-tags.test.ts @@ -567,3 +567,99 @@ test('Conversion metadata tags, modified', async () => { expect(readTags.artist).toBe(songMetadata.artist); expect(Object.keys(readTags.raw!).length).toBe(2); }); + +test('Read ID3v2 tags from WAV file', async () => { + const filePath = path.join(import.meta.dirname, '../public/glitch-hop-is-dead.wav'); + + using input = new Input({ + source: new FilePathSource(filePath), + formats: ALL_FORMATS, + }); + + const tags = await input.getMetadataTags(); + + const expectedTitle = 'Glitch Hop Is Dead'; + const expectedArtist = 'GRiZ'; + const expectedTrackNumber = 19; + + // Specific expectations for the test WAV file + expect(tags.title).toBe(expectedTitle); + expect(tags.artist).toBe(expectedArtist); + expect(tags.trackNumber).toBe(expectedTrackNumber); + + if (!tags.images) { + throw new Error('No images found in the file'); + } + + const frontCover = tags.images[0]; + + if (!frontCover) { + throw new Error('No front cover found in the file'); + } + + expect(frontCover.kind).toBe('coverFront'); + expect(frontCover.mimeType).toBe('image/jpg'); + expect(frontCover.data).toBeInstanceOf(Uint8Array); + expect(frontCover.data.length).toBeGreaterThan(0); + + if (!tags.raw) { + throw new Error('No raw tags found in the file'); + } + + expect(tags.raw['TIT2']).toBe(expectedTitle); // Title + expect(tags.raw['TPE1']).toBe(expectedArtist); // Artist + expect(tags.raw['TRCK']).toBe(String(expectedTrackNumber)); // Track number + expect(tags.raw['APIC']).toBeInstanceOf(Uint8Array); +}); + +test('Write WAV with ID3 tags', async () => { + const output = new Output({ + target: new BufferTarget(), + format: new WavOutputFormat({ metadataFormat: 'id3' }), + }); + + output.setMetadataTags(songMetadata); + + const dummyTrack = createDummyAudioTrack('pcm-f32', output); + + await output.start(); + await dummyTrack.addPacket(); + await output.finalize(); + + using input = new Input({ + source: new BufferSource(output.target.buffer!), + formats: ALL_FORMATS, + }); + + const readTags = await input.getMetadataTags(); + + expect(readTags.title).toBe(songMetadata.title); + expect(readTags.artist).toBe(songMetadata.artist); + expect(readTags.album).toBe(songMetadata.album); + expect(readTags.albumArtist).toBe(songMetadata.albumArtist); + expect(readTags.trackNumber).toBe(songMetadata.trackNumber); + expect(readTags.discNumber).toBe(songMetadata.discNumber); + expect(readTags.genre).toBe(songMetadata.genre); + expect(readTags.comment).toBe(songMetadata.comment); + expect(readTags.lyrics).toBe(songMetadata.lyrics); + + expect(readTags.date).toBeDefined(); + + expect(readTags.raw).toBeDefined(); + if (readTags.raw) { + // Verify RIFF INFO tags are not present + expect(readTags.raw['INAM']).toBeUndefined(); + + // Verify ID3 tags are also present + expect(readTags.raw['TIT2']).toBe(songMetadata.title); + expect(readTags.raw['TPE1']).toBe(songMetadata.artist); + expect(readTags.raw['TALB']).toBe(songMetadata.album); + expect(readTags.raw['TPE2']).toBe(songMetadata.albumArtist); + expect(readTags.raw['TCON']).toBe(songMetadata.genre); + expect(readTags.raw['COMM']).toBeDefined(); + expect(readTags.raw['USLT']).toBeDefined(); + expect(readTags.raw['TRCK']).toBe(`${songMetadata.trackNumber}/${songMetadata.tracksTotal}`); + expect(readTags.raw['TPOS']).toBe(`${songMetadata.discNumber}/${songMetadata.discsTotal}`); + expect(readTags.raw['TDRC']).toBeDefined(); + } +}); diff --git a/test/public/glitch-hop-is-dead.wav b/test/public/glitch-hop-is-dead.wav new file mode 100644 index 0000000..784477a Binary files /dev/null and b/test/public/glitch-hop-is-dead.wav differ