From c82ebb93da40bfe89814ddd337eaf50a406c6793 Mon Sep 17 00:00:00 2001 From: Vanilagy <1696106+Vanilagy@users.noreply.github.com> Date: Tue, 26 Aug 2025 17:36:49 +0200 Subject: [PATCH] Add `lyrics` field to metadata --- dev/convert.html | 3 ++- src/metadata.ts | 23 ++++++++++++++--------- src/mp3/mp3-muxer.ts | 23 +++++++++++++---------- src/mp3/mp3-reader.ts | 8 ++++++++ src/mp3/mp3-writer.ts | 30 ++++++++++++++++++++++++++---- 5 files changed, 63 insertions(+), 24 deletions(-) diff --git a/dev/convert.html b/dev/convert.html index d093a9d..7ab5593 100644 --- a/dev/convert.html +++ b/dev/convert.html @@ -54,7 +54,8 @@ data: blobData, kind: 'coverFront', mimeType: 'image/jpeg' - }] + }], + lyrics: "There's no way\nThat it's not going there" }); const conversion = await Mediabunny.Conversion.init({ diff --git a/src/metadata.ts b/src/metadata.ts index 8aecafb..1737491 100644 --- a/src/metadata.ts +++ b/src/metadata.ts @@ -9,6 +9,7 @@ export type MediaMetadata = { discNumber?: number; genre?: string; releasedAt?: Date; + lyrics?: string; images?: { data: Uint8Array; mimeType: string; @@ -47,6 +48,9 @@ export const validateMediaMetadata = (metadata: MediaMetadata) => { if (metadata.releasedAt !== undefined && !(metadata.releasedAt instanceof Date)) { throw new TypeError('metadata.releasedAt, when provided, must be a Date.'); } + if (metadata.lyrics !== undefined && typeof metadata.lyrics !== 'string') { + throw new TypeError('metadata.lyrics, when provided, must be a string.'); + } if (metadata.comment !== undefined && typeof metadata.comment !== 'string') { throw new TypeError('metadata.comment, when provided, must be a string.'); } @@ -72,14 +76,15 @@ export const validateMediaMetadata = (metadata: MediaMetadata) => { }; export const mediaMetadataIsEmpty = (metadata: MediaMetadata) => { - return !metadata.title - && !metadata.artist - && !metadata.album - && !metadata.albumArtist - && !metadata.trackNumber - && !metadata.discNumber - && !metadata.genre - && !metadata.releasedAt - && !metadata.comment + return metadata.title === undefined + && metadata.artist === undefined + && metadata.album === undefined + && metadata.albumArtist === undefined + && metadata.trackNumber === undefined + && metadata.discNumber === undefined + && metadata.genre === undefined + && metadata.releasedAt === undefined + && metadata.lyrics === undefined + && metadata.comment === undefined && (!metadata.images || metadata.images.length === 0); }; diff --git a/src/mp3/mp3-muxer.ts b/src/mp3/mp3-muxer.ts index 8c19404..ea9ce89 100644 --- a/src/mp3/mp3-muxer.ts +++ b/src/mp3/mp3-muxer.ts @@ -130,38 +130,41 @@ export class Mp3Muxer extends Muxer { const startPos = this.writer.getPos(); - if (metadata.title) { + if (metadata.title !== undefined) { this.mp3Writer.writeId3V2TextFrame('TIT2', metadata.title); } - if (metadata.artist) { + if (metadata.artist !== undefined) { this.mp3Writer.writeId3V2TextFrame('TPE1', metadata.artist); } - if (metadata.album) { + if (metadata.album !== undefined) { this.mp3Writer.writeId3V2TextFrame('TALB', metadata.album); } - if (metadata.albumArtist) { + if (metadata.albumArtist !== undefined) { this.mp3Writer.writeId3V2TextFrame('TPE2', metadata.albumArtist); } - if (metadata.trackNumber) { + if (metadata.trackNumber !== undefined) { this.mp3Writer.writeId3V2TextFrame('TRCK', metadata.trackNumber.toString()); } - if (metadata.discNumber) { + if (metadata.discNumber !== undefined) { this.mp3Writer.writeId3V2TextFrame('TPOS', metadata.discNumber.toString()); } - if (metadata.genre) { + if (metadata.genre !== undefined) { this.mp3Writer.writeId3V2TextFrame('TCON', metadata.genre); } - if (metadata.releasedAt) { + if (metadata.releasedAt !== undefined) { this.mp3Writer.writeId3V2TextFrame('TDRC', metadata.releasedAt.toISOString().split('.')[0]!); } - if (metadata.comment) { + if (metadata.lyrics !== undefined) { + this.mp3Writer.writeId3V2LyricsFrame(metadata.lyrics); + } + if (metadata.comment !== undefined) { 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 || ''; + const description = image.description ?? ''; this.mp3Writer.writeId3V2ApicFrame(image.mimeType, pictureType, description, image.data); } } diff --git a/src/mp3/mp3-reader.ts b/src/mp3/mp3-reader.ts index 3c0cd64..105877e 100644 --- a/src/mp3/mp3-reader.ts +++ b/src/mp3/mp3-reader.ts @@ -350,6 +350,14 @@ export class Mp3Reader { } }; break; + case 'USLT': + case 'ULT': { + const encoding = reader.readU8(); + reader.pos += 3; // Skip language + reader.readId3V2Text(encoding, frameEndPos); // Short content description + metadata.lyrics ??= reader.readId3V2Text(encoding, frameEndPos); + }; break; + case 'COMM': case 'COM': { const encoding = reader.readU8(); diff --git a/src/mp3/mp3-writer.ts b/src/mp3/mp3-writer.ts index 6c63b9d..4bd726f 100644 --- a/src/mp3/mp3-writer.ts +++ b/src/mp3/mp3-writer.ts @@ -17,6 +17,7 @@ import { SAMPLING_RATES, XING, } from '../../shared/mp3-misc'; +import { Id3V2TextEncoding } from './mp3-reader'; export type XingFrameData = { mpegVersionId: number; @@ -89,7 +90,7 @@ export class Mp3Writer { this.writeSynchsafeU32(frameSize); this.writeU16(0x0000); - this.writeU8(useIso88591 ? 0x00 : 0x03); + this.writeU8(useIso88591 ? Id3V2TextEncoding.ISO_8859_1 : Id3V2TextEncoding.UTF_8); if (useIso88591) { this.writeIsoString(text); } else { @@ -97,17 +98,38 @@ export class Mp3Writer { } } + 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 frameSize = 1 + 3 + 1 + textDataLength + 1; const shortDescription = ''; + const frameSize = 1 + 3 + shortDescription.length + 1 + textDataLength + 1; this.writeAscii('COMM'); this.writeSynchsafeU32(frameSize); this.writeU16(0x0000); - this.writeU8(useIso88591 ? 0x00 : 0x03); + this.writeU8(useIso88591 ? Id3V2TextEncoding.ISO_8859_1 : Id3V2TextEncoding.UTF_8); this.writeU8(0x75); // 'u' this.writeU8(0x6E); // 'n' this.writeU8(0x64); // 'd' @@ -130,7 +152,7 @@ export class Mp3Writer { this.writeSynchsafeU32(frameSize); this.writeU16(0x0000); - this.writeU8(useIso88591 ? 0x00 : 0x03); + this.writeU8(useIso88591 ? Id3V2TextEncoding.ISO_8859_1 : Id3V2TextEncoding.UTF_8); if (useIso88591) { this.writeIsoString(mimeType);