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') {