From fb0c41aa097d5035d035350e7d842c6c6190273b Mon Sep 17 00:00:00 2001 From: Vanilagy <1696106+Vanilagy@users.noreply.github.com> Date: Mon, 25 Aug 2025 17:43:40 +0200 Subject: [PATCH] Implement MP3 metadata (ID3v1/2) read & write --- dev/convert.html | 39 ++- dev/demux.html | 7 + shared/mp3-misc.ts | 29 ++ src/demuxer.ts | 2 + src/input-format.ts | 20 +- src/input.ts | 5 + src/metadata.ts | 85 ++++++ src/misc.ts | 16 ++ src/mp3/mp3-demuxer.ts | 62 ++++- src/mp3/mp3-muxer.ts | 64 ++++- src/mp3/mp3-reader.ts | 616 +++++++++++++++++++++++++++++++++++++++-- src/mp3/mp3-writer.ts | 107 +++++++ src/output.ts | 13 + 13 files changed, 1014 insertions(+), 51 deletions(-) create mode 100644 src/metadata.ts diff --git a/dev/convert.html b/dev/convert.html index ba67311..d093a9d 100644 --- a/dev/convert.html +++ b/dev/convert.html @@ -24,22 +24,45 @@ chunked: true, chunkSize: 2**20 }); - const outputFormat = new Mediabunny.Mp4OutputFormat({}); + const outputFormat = new Mediabunny.Mp3OutputFormat({}); const button = document.createElement('button'); button.textContent = 'Cancel'; button.onclick = () => conversion.cancel(); document.body.append(button); + const yo = document.createElement('canvas'); + yo.width = 512; + yo.height = 512; + const context = yo.getContext('2d', { alpha: false }); + context.fillStyle = 'red'; + context.fillRect(100, 100, 300, 300); + const blob = await new Promise(resolve => yo.toBlob(resolve, 'image/jpeg', 0.92)); + const blobData = new Uint8Array(await blob.arrayBuffer()); + + const mh = new Blob([blobData], { type: 'image/jpeg' }); + console.log(URL.createObjectURL(mh)) + + const output = new Mediabunny.Output({ + format: outputFormat, + target + }); + output.setMetadata({ + title: 'Bigggy', + artist: 'Buck Bunny', + images: [{ + data: blobData, + kind: 'coverFront', + mimeType: 'image/jpeg' + }] + }); + const conversion = await Mediabunny.Conversion.init({ input: new Mediabunny.Input({ formats: Mediabunny.ALL_FORMATS, source }), - output: new Mediabunny.Output({ - format: outputFormat, - target - }), + output, audio: { //discard: true, //codec: 'opus', @@ -74,7 +97,7 @@ }, */ video: () => ({ - codec: 'avc', + //codec: 'avc', //fit: 'contain', //frameRate: 27.123, //width: 320, @@ -96,8 +119,8 @@ //height: 100, }), trim: { - //start: 0, - //end: 20 + start: 0, + end: 20 }, }); console.log(conversion); diff --git a/dev/demux.html b/dev/demux.html index 29f4027..3dc518f 100644 --- a/dev/demux.html +++ b/dev/demux.html @@ -16,11 +16,18 @@ source }); + //const blob = new Blob([(await input.getMetadata()).images[0].data], { type: (await input.getMetadata()).images[0].mimeType }); + //console.log(URL.createObjectURL(blob)) + + console.log(await input.getMetadata()); + + /* const videoTrack = await input.getPrimaryVideoTrack(); console.log(await videoTrack.getFirstTimestamp(), await videoTrack.computeDuration()); console.log(videoTrack.name); console.log(videoTrack.internalCodecId); + */ /* const sink = new Mediabunny.EncodedPacketSink(videoTrack); diff --git a/shared/mp3-misc.ts b/shared/mp3-misc.ts index fdd9554..42d1342 100644 --- a/shared/mp3-misc.ts +++ b/shared/mp3-misc.ts @@ -156,3 +156,32 @@ export const readFrameHeader = (word: number, reader: { pos: number; fileSize: n audioSamplesInFrame, }; }; + +export const encodeSynchsafe = (unsynchsafed: number) => { + let mask = 0x7f; + let synchsafed = 0; + let unsynchsafedRest = unsynchsafed; + + while ((mask ^ 0x7fffffff) !== 0) { + synchsafed = unsynchsafedRest & ~mask; + synchsafed <<= 1; + synchsafed |= unsynchsafedRest & mask; + mask = ((mask + 1) << 8) - 1; + unsynchsafedRest = synchsafed; + } + + return synchsafed; +}; + +export const decodeSynchsafe = (synchsafed: number) => { + let mask = 0x7f000000; + let unsynchsafed = 0; + + while (mask !== 0) { + unsynchsafed >>= 1; + unsynchsafed |= synchsafed & mask; + mask >>= 8; + } + + return unsynchsafed; +}; diff --git a/src/demuxer.ts b/src/demuxer.ts index b47d0c1..75e2803 100644 --- a/src/demuxer.ts +++ b/src/demuxer.ts @@ -8,6 +8,7 @@ import { Input } from './input'; import { InputTrack } from './input-track'; +import { MediaMetadata } from './metadata'; export abstract class Demuxer { input: Input; @@ -19,4 +20,5 @@ export abstract class Demuxer { abstract computeDuration(): Promise; abstract getTracks(): Promise; abstract getMimeType(): Promise; + abstract getMetadata(): Promise; } diff --git a/src/input-format.ts b/src/input-format.ts index 961ef8b..a6b6d78 100644 --- a/src/input-format.ts +++ b/src/input-format.ts @@ -14,7 +14,7 @@ import { EBMLId, EBMLReader, MIN_HEADER_SIZE } from './matroska/ebml'; import { MatroskaDemuxer } from './matroska/matroska-demuxer'; import { Mp3Demuxer } from './mp3/mp3-demuxer'; import { FRAME_HEADER_SIZE } from '../shared/mp3-misc'; -import { Mp3Reader } from './mp3/mp3-reader'; +import { ID3_V2_HEADER_SIZE, Mp3Reader } from './mp3/mp3-reader'; import { OggDemuxer } from './ogg/ogg-demuxer'; import { OggReader } from './ogg/ogg-reader'; import { RiffReader } from './wave/riff-reader'; @@ -241,10 +241,18 @@ export class Mp3InputFormat extends InputFormat { const mp3Reader = new Mp3Reader(input._mainReader); mp3Reader.fileSize = sourceSize; - const id3Tag = mp3Reader.readId3(); + let id3V2HeaderFound = false; - if (id3Tag) { - mp3Reader.pos += id3Tag.size; + while (true) { + await mp3Reader.reader.loadRange(mp3Reader.pos, mp3Reader.pos + ID3_V2_HEADER_SIZE); + + const id3V2Header = mp3Reader.readId3V2Header(); + if (!id3V2Header) { + break; + } + + id3V2HeaderFound = true; + mp3Reader.pos += id3V2Header.size; } const framesStartPos = mp3Reader.pos; @@ -255,8 +263,8 @@ export class Mp3InputFormat extends InputFormat { return false; } - if (id3Tag) { - // If there was an ID3 tag at the start, we can be pretty sure this is MP3 by now + if (id3V2HeaderFound) { + // If there was an ID3v2 tag at the start, we can be pretty sure this is MP3 by now return true; } diff --git a/src/input.ts b/src/input.ts index 5c3d086..10c15e8 100644 --- a/src/input.ts +++ b/src/input.ts @@ -135,4 +135,9 @@ export class Input { const demuxer = await this._getDemuxer(); return demuxer.getMimeType(); } + + async getMetadata() { + const demuxer = await this._getDemuxer(); + return demuxer.getMetadata(); + } } diff --git a/src/metadata.ts b/src/metadata.ts new file mode 100644 index 0000000..8aecafb --- /dev/null +++ b/src/metadata.ts @@ -0,0 +1,85 @@ +// TODO: Give this a different name. This clashes with a web concept. Question is, what? + +export type MediaMetadata = { + title?: string; + artist?: string; + album?: string; + albumArtist?: string; + trackNumber?: number; + discNumber?: number; + genre?: string; + releasedAt?: Date; + images?: { + data: Uint8Array; + mimeType: string; + kind: 'coverFront' | 'coverBack' | 'unknown'; + name?: string; + description?: string; + }[]; + comment?: string; +}; + +export const validateMediaMetadata = (metadata: MediaMetadata) => { + if (!metadata || typeof metadata !== 'object') { + throw new TypeError('metadata must be an object.'); + } + if (metadata.title !== undefined && typeof metadata.title !== 'string') { + throw new TypeError('metadata.title, when provided, must be a string.'); + } + if (metadata.artist !== undefined && typeof metadata.artist !== 'string') { + throw new TypeError('metadata.artist, when provided, must be a string.'); + } + if (metadata.album !== undefined && typeof metadata.album !== 'string') { + throw new TypeError('metadata.album, when provided, must be a string.'); + } + if (metadata.albumArtist !== undefined && typeof metadata.albumArtist !== 'string') { + throw new TypeError('metadata.albumArtist, when provided, must be a string.'); + } + if (metadata.trackNumber !== undefined && (!Number.isInteger(metadata.trackNumber) || metadata.trackNumber <= 0)) { + throw new TypeError('metadata.trackNumber, when provided, must be a positive integer.'); + } + if (metadata.discNumber !== undefined && (!Number.isInteger(metadata.discNumber) || metadata.discNumber <= 0)) { + throw new TypeError('metadata.discNumber, when provided, must be a positive integer.'); + } + if (metadata.genre !== undefined && typeof metadata.genre !== 'string') { + throw new TypeError('metadata.genre, when provided, must be a string.'); + } + if (metadata.releasedAt !== undefined && !(metadata.releasedAt instanceof Date)) { + throw new TypeError('metadata.releasedAt, when provided, must be a Date.'); + } + if (metadata.comment !== undefined && typeof metadata.comment !== 'string') { + throw new TypeError('metadata.comment, when provided, must be a string.'); + } + if (metadata.images !== undefined) { + if (!Array.isArray(metadata.images)) { + throw new TypeError('metadata.images, when provided, must be an array.'); + } + for (const image of metadata.images) { + if (!image || typeof image !== 'object') { + throw new TypeError('Each image in metadata.images must be an object.'); + } + if (!(image.data instanceof Uint8Array)) { + throw new TypeError('Each image.data must be a Uint8Array.'); + } + if (typeof image.mimeType !== 'string') { + throw new TypeError('Each image.mimeType must be a string.'); + } + if (!['coverFront', 'coverBack', 'other'].includes(image.kind)) { + throw new TypeError('Each image.kind must be \'coverFront\', \'coverBack\', or \'other\'.'); + } + } + } +}; + +export const mediaMetadataIsEmpty = (metadata: MediaMetadata) => { + return !metadata.title + && !metadata.artist + && !metadata.album + && !metadata.albumArtist + && !metadata.trackNumber + && !metadata.discNumber + && !metadata.genre + && !metadata.releasedAt + && !metadata.comment + && (!metadata.images || metadata.images.length === 0); +}; diff --git a/src/misc.ts b/src/misc.ts index 94fdd65..ea64527 100644 --- a/src/misc.ts +++ b/src/misc.ts @@ -176,6 +176,17 @@ export const toDataView = (source: AllowSharedBufferSource) => { export const textDecoder = new TextDecoder(); export const textEncoder = new TextEncoder(); +export const isIso88591Compatible = (text: string) => { + for (let i = 0; i < text.length; i++) { + const code = text.charCodeAt(i); + if (code > 255) { + return false; + } + } + + return true; +}; + const invertObject = (object: Record) => { return Object.fromEntries(Object.entries(object).map(([key, value]) => [value, key])) as Record; }; @@ -633,3 +644,8 @@ export const isSafari = () => { * @public */ export type MaybePromise = T | Promise; + +/** Acts like `??` except the condition is -1 and not null/undefined. */ +export const coalesceIndex = (a: number, b: number) => { + return a !== -1 ? a : b; +}; diff --git a/src/mp3/mp3-demuxer.ts b/src/mp3/mp3-demuxer.ts index 012b91c..957fea9 100644 --- a/src/mp3/mp3-demuxer.ts +++ b/src/mp3/mp3-demuxer.ts @@ -10,11 +10,12 @@ import { AudioCodec } from '../codec'; import { Demuxer } from '../demuxer'; import { Input } from '../input'; import { InputAudioTrack, InputAudioTrackBacking } from '../input-track'; +import { MediaMetadata } from '../metadata'; import { PacketRetrievalOptions } from '../media-sink'; import { assert, AsyncMutex, binarySearchExact, binarySearchLessOrEqual, UNDETERMINED_LANGUAGE } from '../misc'; import { EncodedPacket, PLACEHOLDER_DATA } from '../packet'; import { FrameHeader, getXingOffset, INFO, XING } from '../../shared/mp3-misc'; -import { Mp3Reader } from './mp3-reader'; +import { ID3_V1_TAG_SIZE, ID3_V2_HEADER_SIZE, Mp3Reader } from './mp3-reader'; type Sample = { timestamp: number; @@ -29,6 +30,7 @@ export class Mp3Demuxer extends Demuxer { metadataPromise: Promise | null = null; firstFrameHeader: FrameHeader | null = null; loadedSamples: Sample[] = []; // All samples from the start of the file to lastLoadedPos + metadata: MediaMetadata | null = null; tracks: InputAudioTrack[] = []; @@ -64,6 +66,8 @@ export class Mp3Demuxer extends Demuxer { async loadNextChunk() { assert(this.lastLoadedPos < this.fileSize); + this.reader.pos = this.lastLoadedPos; + const chunkSize = 0.5 * 1024 * 1024; // 0.5 MiB const endPos = Math.min(this.lastLoadedPos + chunkSize, this.fileSize); await this.reader.reader.loadRange(this.lastLoadedPos, endPos); @@ -72,10 +76,14 @@ export class Mp3Demuxer extends Demuxer { assert(this.lastLoadedPos <= this.fileSize); if (this.reader.pos === 0) { - // First time, let's see if there's an ID3 tag - const id3Tag = this.reader.readId3(); - if (id3Tag) { - this.reader.pos += id3Tag.size; + while (true) { // There may be multiple + await this.reader.reader.loadRange(this.reader.pos, this.reader.pos + ID3_V2_HEADER_SIZE); + const id3V2Header = this.reader.readId3V2Header(); + if (!id3V2Header) { + break; + } + + this.reader.pos += id3V2Header.size; // Skip it for now } } @@ -145,6 +153,50 @@ export class Mp3Demuxer extends Demuxer { return track.computeDuration(); } + + async getMetadata() { + const release = await this.readingMutex.acquire(); + + try { + await this.readMetadata(); + + if (this.metadata) { + return this.metadata; + } + + this.metadata = {}; + this.reader.pos = 0; + let id3V2HeaderFound = false; + + while (true) { + await this.reader.reader.loadRange(this.reader.pos, this.reader.pos + ID3_V2_HEADER_SIZE); + const id3V2Header = this.reader.readId3V2Header(); + if (!id3V2Header) { + break; + } + + id3V2HeaderFound = true; + + await this.reader.reader.loadRange(this.reader.pos, this.reader.pos + id3V2Header.size); + this.reader.parseId3V2Tag(id3V2Header, this.metadata); + } + + if (!id3V2HeaderFound) { + // Try reading an ID3v1 tag at the end of the file + this.reader.pos = Math.max(0, this.fileSize - ID3_V1_TAG_SIZE); + await this.reader.reader.loadRange(this.reader.pos, this.reader.pos + ID3_V1_TAG_SIZE); + + const tag = this.reader.readAscii(3); + if (tag === 'TAG') { + this.reader.parseId3V1Tag(this.metadata); + } + } + + return this.metadata; + } finally { + release(); + } + } } class Mp3AudioTrackBacking implements InputAudioTrackBacking { diff --git a/src/mp3/mp3-muxer.ts b/src/mp3/mp3-muxer.ts index 051bcf9..8c19404 100644 --- a/src/mp3/mp3-muxer.ts +++ b/src/mp3/mp3-muxer.ts @@ -7,6 +7,7 @@ */ import { assert, toDataView } from '../misc'; +import { mediaMetadataIsEmpty, MediaMetadata } from '../metadata'; import { Muxer } from '../muxer'; import { Output, OutputAudioTrack } from '../output'; import { Mp3OutputFormat } from '../output-format'; @@ -22,6 +23,7 @@ export class Mp3Muxer extends Muxer { private xingFrameData: XingFrameData | null = null; private frameCount = 0; private framePositions: number[] = []; + private xingFramePos: number | null = null; constructor(output: Output, format: Mp3OutputFormat) { super(output); @@ -32,7 +34,9 @@ export class Mp3Muxer extends Muxer { } async start() { - // Nothing needed here + if (!mediaMetadataIsEmpty(this.output._metadata)) { + this.writeId3v2Tag(this.output._metadata); + } } async getMimeType() { @@ -92,6 +96,7 @@ export class Mp3Muxer extends Muxer { // Write a Xing frame because this muxer doesn't make any bitrate constraints, meaning we don't know if // this will be a constant or variable bitrate file. Therefore, always write the Xing frame. + this.xingFramePos = this.writer.getPos(); this.mp3Writer.writeXingFrame(this.xingFrameData); this.frameCount++; @@ -116,8 +121,61 @@ export class Mp3Muxer extends Muxer { throw new Error('MP3 does not support subtitles.'); } + writeId3v2Tag(metadata: MediaMetadata) { + 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(); + + if (metadata.title) { + this.mp3Writer.writeId3V2TextFrame('TIT2', metadata.title); + } + if (metadata.artist) { + this.mp3Writer.writeId3V2TextFrame('TPE1', metadata.artist); + } + if (metadata.album) { + this.mp3Writer.writeId3V2TextFrame('TALB', metadata.album); + } + if (metadata.albumArtist) { + this.mp3Writer.writeId3V2TextFrame('TPE2', metadata.albumArtist); + } + if (metadata.trackNumber) { + this.mp3Writer.writeId3V2TextFrame('TRCK', metadata.trackNumber.toString()); + } + if (metadata.discNumber) { + this.mp3Writer.writeId3V2TextFrame('TPOS', metadata.discNumber.toString()); + } + if (metadata.genre) { + this.mp3Writer.writeId3V2TextFrame('TCON', metadata.genre); + } + if (metadata.releasedAt) { + this.mp3Writer.writeId3V2TextFrame('TDRC', metadata.releasedAt.toISOString().split('.')[0]!); + } + if (metadata.comment) { + this.mp3Writer.writeId3V2CommentFrame(metadata.comment); + } + if (metadata.images) { + const pictureTypeMap = { coverFront: 0x03, coverBack: 0x04, unknown: 0x00 }; + for (const image of metadata.images) { + const pictureType = pictureTypeMap[image.kind]; + const description = image.description || ''; + this.mp3Writer.writeId3V2ApicFrame(image.mimeType, pictureType, description, image.data); + } + } + + 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) { + if (!this.xingFrameData || this.xingFramePos === null) { return; } @@ -125,7 +183,7 @@ export class Mp3Muxer extends Muxer { const endPos = this.writer.getPos(); - this.writer.seek(0); + this.writer.seek(this.xingFramePos); const toc = new Uint8Array(100); for (let i = 0; i < 100; i++) { diff --git a/src/mp3/mp3-reader.ts b/src/mp3/mp3-reader.ts index 9a9627d..3c0cd64 100644 --- a/src/mp3/mp3-reader.ts +++ b/src/mp3/mp3-reader.ts @@ -6,9 +6,64 @@ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ -import { assert } from '../misc'; +import { assert, coalesceIndex, textDecoder } from '../misc'; import { Reader } from '../reader'; -import { FRAME_HEADER_SIZE, FrameHeader, readFrameHeader } from '../../shared/mp3-misc'; +import { decodeSynchsafe, FRAME_HEADER_SIZE, FrameHeader, readFrameHeader } from '../../shared/mp3-misc'; +import { MediaMetadata } from '../metadata'; + +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 class Mp3Reader { pos = 0; @@ -23,11 +78,11 @@ export class Mp3Reader { return new Uint8Array(view.buffer, offset, length); } - readU16() { - const { view, offset } = this.reader.getViewAndOffset(this.pos, this.pos + 2); - this.pos += 2; + readU8() { + const { view, offset } = this.reader.getViewAndOffset(this.pos, this.pos + 1); + this.pos += 1; - return view.getUint16(offset, false); + return view.getUint8(offset); } readU32() { @@ -48,19 +103,6 @@ export class Mp3Reader { return str; } - readId3() { - const tag = this.readAscii(3); - if (tag !== 'ID3') { - this.pos -= 3; - return null; - } - - this.pos += 3; - - const size = decodeSynchsafe(this.readU32()); - return { size }; - } - readNextFrameHeader(until?: number): FrameHeader | null { assert(this.fileSize); until ??= this.fileSize; @@ -77,17 +119,533 @@ export class Mp3Reader { return null; } -} -export const decodeSynchsafe = (synchsafed: number) => { - let mask = 0x7f000000; - let unsynchsafed = 0; + parseId3V1Tag(metadata: MediaMetadata) { + const title = this.readId3V1String(30); + if (title) metadata.title ??= title; - while (mask !== 0) { - unsynchsafed >>= 1; - unsynchsafed |= synchsafed & mask; - mask >>= 8; + const artist = this.readId3V1String(30); + if (artist) metadata.artist ??= artist; + + const album = this.readId3V1String(30); + if (album) metadata.album ??= album; + + const yearText = this.readId3V1String(4); + const year = Number.parseInt(yearText, 10); + if (Number.isInteger(year) && year > 0) { + metadata.releasedAt ??= new Date(year, 0, 1); + } + + const commentBytes = this.readBytes(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) { + metadata.trackNumber ??= trackNum; + } + + this.pos -= 30; + comment = this.readId3V1String(28); + this.pos += 2; + } else { + this.pos -= 30; + comment = this.readId3V1String(30); + } + + if (comment) metadata.comment ??= comment; + + const genreIndex = this.readU8(); + if (genreIndex < ID3_V1_GENRES.length) { + metadata.genre ??= ID3_V1_GENRES[genreIndex]; + } } - return unsynchsafed; -}; + readId3V1String(length: number): string { + const bytes = this.readBytes(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 + } + + readId3V2Header(): Id3V2Header | null { + const startPos = this.pos; + + const tag = this.readAscii(3); + const majorVersion = this.readU8(); + const revision = this.readU8(); + const flags = this.readU8(); + const sizeRaw = this.readU32(); + + if (tag !== 'ID3' || majorVersion === 0xff || revision === 0xff || (sizeRaw & 0x80808080) !== 0) { + this.pos = startPos; + return null; + } + + const size = decodeSynchsafe(sizeRaw); + + return { majorVersion, revision, flags, size }; + } + + parseId3V2Tag(header: Id3V2Header, metadata: MediaMetadata) { + // https://id3.org/id3v2.3.0 + + if (![2, 3, 4].includes(header.majorVersion)) { + console.warn(`Unsupported ID3v2 major version: ${header.majorVersion}`); + return; + } + + this.pos = ID3_V2_HEADER_SIZE; + const bytes = this.readBytes(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 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); + } + + switch (frame.id) { + case 'TIT2': + case 'TT2': { + metadata.title ??= reader.readId3V2EncodingAndText(frameEndPos); + }; break; + + case 'TPE1': + case 'TP1': { + metadata.artist ??= reader.readId3V2EncodingAndText(frameEndPos); + }; break; + + case 'TALB': + case 'TAL': { + metadata.album ??= reader.readId3V2EncodingAndText(frameEndPos); + }; break; + + case 'TPE2': + case 'TP2': { + metadata.albumArtist ??= reader.readId3V2EncodingAndText(frameEndPos); + }; break; + + case 'TRCK': + case 'TRK': { + const trackText = reader.readId3V2EncodingAndText(frameEndPos); + const trackNum = Number.parseInt(trackText, 10); + + if (Number.isInteger(trackNum) && trackNum > 0) { + metadata.trackNumber ??= trackNum; + } + }; break; + + case 'TPOS': + case 'TPA': { + const discText = reader.readId3V2EncodingAndText(frameEndPos); + const discNum = Number.parseInt(discText, 10); + + if (Number.isInteger(discNum) && discNum > 0) { + metadata.discNumber ??= discNum; + } + }; 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) { + metadata.genre ??= ID3_V1_GENRES[genreNumber]; + break; + } + } + + match = /^\d+$/.exec(genreText); + if (match) { + const genreNumber = Number.parseInt(match[0]); + if (ID3_V1_GENRES[genreNumber] !== undefined) { + metadata.genre ??= ID3_V1_GENRES[genreNumber]; + break; + } + } + + metadata.genre ??= genreText; + }; break; + + case 'TDRC': + case 'TDAT': { + const dateText = reader.readId3V2EncodingAndText(frameEndPos); + const date = new Date(dateText); + + if (!Number.isNaN(date.getTime())) { + metadata.releasedAt ??= date; + } + }; break; + + case 'TYER': + case 'TYE': { + const yearText = reader.readId3V2EncodingAndText(frameEndPos); + const year = Number.parseInt(yearText, 10); + + if (Number.isInteger(year)) { + metadata.releasedAt ??= new Date(year, 0, 1); + } + }; break; + + case 'COMM': + case 'COM': { + const encoding = reader.readU8(); + reader.pos += 3; // Skip language + reader.readId3V2Text(encoding, frameEndPos); // Short content description + metadata.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 (!metadata.images) metadata.images = []; + metadata.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 2f697f2..6c63b9d 100644 --- a/src/mp3/mp3-writer.ts +++ b/src/mp3/mp3-writer.ts @@ -6,9 +6,11 @@ * 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, MPEG_V1_BITRATES, MPEG_V2_BITRATES, @@ -37,11 +39,116 @@ 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)); + } + + 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 ? 0x00 : 0x03); + if (useIso88591) { + this.writeIsoString(text); + } else { + this.writeUtf8String(text); + } + } + + writeId3V2CommentFrame(comment: string) { + const useIso88591 = isIso88591Compatible(comment); + const textDataLength = useIso88591 ? comment.length : textEncoder.encode(comment).byteLength; + const frameSize = 1 + 3 + 1 + textDataLength + 1; + const shortDescription = ''; + + this.writeAscii('COMM'); + this.writeSynchsafeU32(frameSize); + this.writeU16(0x0000); + + this.writeU8(useIso88591 ? 0x00 : 0x03); + 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 ? 0x00 : 0x03); + + 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); + } + writeXingFrame(data: XingFrameData) { const startPos = this.writer.getPos(); diff --git a/src/output.ts b/src/output.ts index 92c0d0f..a2063d0 100644 --- a/src/output.ts +++ b/src/output.ts @@ -7,6 +7,7 @@ */ import { AsyncMutex, isIso639Dash2LanguageCode, Rotation } from './misc'; +import { MediaMetadata, validateMediaMetadata } from './metadata'; import { Muxer } from './muxer'; import { OutputFormat } from './output-format'; import { AudioSource, MediaSource, SubtitleSource, VideoSource } from './media-source'; @@ -137,6 +138,8 @@ export class Output< _finalizePromise: Promise | null = null; /** @internal */ _mutex = new AsyncMutex(); + /** @internal */ + _metadata: MediaMetadata = {}; constructor(options: OutputOptions) { if (!options || typeof options !== 'object') { @@ -205,6 +208,16 @@ export class Output< this._addTrack('subtitle', source, metadata); } + setMetadata(metadata: MediaMetadata) { + validateMediaMetadata(metadata); + + if (this.state !== 'pending') { + throw new Error('Cannot set metadata after output has been started or canceled.'); + } + + this._metadata = metadata; + } + /** @internal */ private _addTrack(type: OutputTrack['type'], source: MediaSource, metadata: object) { if (this.state !== 'pending') {