Implement MP3 metadata (ID3v1/2) read & write

This commit is contained in:
Vanilagy
2025-08-25 17:43:40 +02:00
parent 72eb54391e
commit fb0c41aa09
13 changed files with 1014 additions and 51 deletions
+2
View File
@@ -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<number>;
abstract getTracks(): Promise<InputTrack[]>;
abstract getMimeType(): Promise<string>;
abstract getMetadata(): Promise<MediaMetadata>;
}
+14 -6
View File
@@ -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;
}
+5
View File
@@ -135,4 +135,9 @@ export class Input<S extends Source = Source> {
const demuxer = await this._getDemuxer();
return demuxer.getMimeType();
}
async getMetadata() {
const demuxer = await this._getDemuxer();
return demuxer.getMetadata();
}
}
+85
View File
@@ -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);
};
+16
View File
@@ -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 = <K extends PropertyKey, V extends PropertyKey>(object: Record<K, V>) => {
return Object.fromEntries(Object.entries(object).map(([key, value]) => [value, key])) as Record<V, K>;
};
@@ -633,3 +644,8 @@ export const isSafari = () => {
* @public
*/
export type MaybePromise<T> = T | Promise<T>;
/** Acts like `??` except the condition is -1 and not null/undefined. */
export const coalesceIndex = (a: number, b: number) => {
return a !== -1 ? a : b;
};
+57 -5
View File
@@ -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<void> | 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 {
+61 -3
View File
@@ -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++) {
+587 -29
View File
@@ -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);
}
}
+107
View File
@@ -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();
+13
View File
@@ -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<void> | null = null;
/** @internal */
_mutex = new AsyncMutex();
/** @internal */
_metadata: MediaMetadata = {};
constructor(options: OutputOptions<F, T>) {
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') {