diff --git a/dev/convert.html b/dev/convert.html
index 4962384..ebbbf05 100644
--- a/dev/convert.html
+++ b/dev/convert.html
@@ -24,9 +24,7 @@
chunked: true,
chunkSize: 2**20
});
- const outputFormat = new Mediabunny.Mp4OutputFormat({
- metadataFormat: 'mdta',
- });
+ const outputFormat = new Mediabunny.Mp3OutputFormat({});
const button = document.createElement('button');
button.textContent = 'Cancel';
diff --git a/dev/demux.html b/dev/demux.html
index ba6df08..526b99c 100644
--- a/dev/demux.html
+++ b/dev/demux.html
@@ -14,7 +14,18 @@
source: new Mediabunny.BlobSource(file),
});
- console.log(await input.getMetadataTags());
+ console.log(await input.computeDuration());
+ return;
+
+ const audioTrack = await input.getPrimaryAudioTrack();
+ const sink = new Mediabunny.EncodedPacketSink(audioTrack);
+
+ for await (const packet of sink.packets()) {
+ console.log(packet)
+ }
+
+ console.log("Done")
+
/*
diff --git a/package-lock.json b/package-lock.json
index 0d6436c..e7fc2c5 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "mediabunny",
- "version": "1.17.0",
+ "version": "1.17.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mediabunny",
- "version": "1.17.0",
+ "version": "1.17.1",
"license": "MPL-2.0",
"workspaces": [
"packages/*"
@@ -7749,9 +7749,9 @@
}
},
"node_modules/mediabunny": {
- "version": "1.16.1",
- "resolved": "https://registry.npmjs.org/mediabunny/-/mediabunny-1.16.1.tgz",
- "integrity": "sha512-BKA5wJaen/3g0nclyykXkS2rELsokcfOYSUfYbSZpDbChqxuITn+bCQhSTtDyMlv3nmK6KoSTo/JhcZLbF9pxQ==",
+ "version": "1.17.0",
+ "resolved": "https://registry.npmjs.org/mediabunny/-/mediabunny-1.17.0.tgz",
+ "integrity": "sha512-3IvrYu1Eur6IoLjygMAeVrh+5jkJxsr1RTAc7+e4QYONUt4MrUaorPIl3XtLKQdJLGcxlERrHn2KC50qBZN9mg==",
"license": "MPL-2.0",
"peer": true,
"workspaces": [
@@ -12242,7 +12242,7 @@
},
"packages/mp3-encoder": {
"name": "@mediabunny/mp3-encoder",
- "version": "1.17.0",
+ "version": "1.17.1",
"license": "MPL-2.0",
"devDependencies": {
"@types/emscripten": "^1.40.1"
diff --git a/package.json b/package.json
index f944438..405d1eb 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "mediabunny",
"author": "Vanilagy",
- "version": "1.17.0",
+ "version": "1.17.1",
"description": "Pure TypeScript media toolkit for reading, writing, and converting media files, directly in the browser.",
"type": "module",
"workspaces": [
diff --git a/packages/mp3-encoder/package.json b/packages/mp3-encoder/package.json
index 2717510..8d355d4 100644
--- a/packages/mp3-encoder/package.json
+++ b/packages/mp3-encoder/package.json
@@ -1,7 +1,7 @@
{
"name": "@mediabunny/mp3-encoder",
"author": "Vanilagy",
- "version": "1.17.0",
+ "version": "1.17.1",
"description": "MP3 encoder extension for Mediabunny, based on LAME.",
"main": "./dist/bundles/mediabunny-mp3-encoder.mjs",
"module": "./dist/bundles/mediabunny-mp3-encoder.mjs",
diff --git a/packages/mp3-encoder/src/index.ts b/packages/mp3-encoder/src/index.ts
index 7319e2d..92aedbc 100644
--- a/packages/mp3-encoder/src/index.ts
+++ b/packages/mp3-encoder/src/index.ts
@@ -28,7 +28,9 @@ class Mp3Encoder extends CustomAudioEncoder {
static override supports(codec: AudioCodec, config: AudioDecoderConfig): boolean {
return codec === 'mp3'
&& (config.numberOfChannels === 1 || config.numberOfChannels === 2)
- && Object.values(SAMPLING_RATES).some(x => x.includes(config.sampleRate));
+ && Object.values(SAMPLING_RATES).some(x =>
+ x === config.sampleRate || (x / 2) === config.sampleRate || (x / 4) === config.sampleRate,
+ );
}
async init() {
diff --git a/shared/mp3-misc.ts b/shared/mp3-misc.ts
index 433771b..9ab974a 100644
--- a/shared/mp3-misc.ts
+++ b/shared/mp3-misc.ts
@@ -7,32 +7,20 @@
*/
export const FRAME_HEADER_SIZE = 4;
+export const SAMPLING_RATES = [44100, 48000, 32000];
+export const KILOBIT_RATES = [
+ // lowSamplingFrequency === 0
+ -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, // layer = 0
+ -1, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, -1, // layer 1
+ -1, 32, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 384, -1, // layer = 2
+ -1, 32, 64, 96, 128, 160, 192, 224, 256, 288, 320, 352, 384, 416, 448, -1, // layer = 3
-// These are in kbps:
-export const MPEG_V1_BITRATES: Record = {
- // Layer 3
- 1: [-1, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, -1],
- // Layer 2
- 2: [-1, 32, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 384, -1],
- // Layer 1
- 3: [-1, 32, 64, 96, 128, 160, 192, 224, 256, 288, 320, 352, 384, 416, 448, -1],
-};
-export const MPEG_V2_BITRATES: Record = {
- // Layer 3
- 1: [-1, 32, 48, 56, 64, 80, 96, 112, 128, 144, 160, 176, 192, 224, 256, -1],
- // Layer 2
- 2: [-1, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160, -1],
- // Layer 1
- 3: [-1, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160, -1],
-};
-export const SAMPLING_RATES: Record = {
- // MPEG Version 2.5
- 0: [11025, 12000, 8000, -1],
- // MPEG Version 2 (ISO/IEC 13818-3)
- 2: [22050, 24000, 16000, -1],
- // MPEG Version 1 (ISO/IEC 11172-3)
- 3: [44100, 48000, 32000, -1],
-};
+ // lowSamplingFrequency === 1
+ -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, // layer = 0
+ -1, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160, -1, // layer = 1
+ -1, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160, -1, // layer = 2
+ -1, 32, 48, 56, 64, 80, 96, 112, 128, 144, 160, 176, 192, 224, 256, -1, // layer = 3
+];
/** 'Xing' */
export const XING = 0x58696e67;
@@ -54,12 +42,21 @@ export type FrameHeader = {
audioSamplesInFrame: number;
};
-export const computeMp3FrameSize = (layer: number, bitrate: number, sampleRate: number, padding: number) => {
- if (layer === 3) {
- // Layer 1
- return Math.floor((12 * bitrate / sampleRate + padding) * 4);
- } else {
- return Math.floor((144 * bitrate / sampleRate) + padding);
+export const computeMp3FrameSize = (
+ lowSamplingFrequency: number,
+ layer: number,
+ bitrate: number,
+ sampleRate: number,
+ padding: number,
+) => {
+ if (layer === 0) {
+ return 0; // Not expected that this is hit
+ } else if (layer === 1) {
+ return Math.round(144 * bitrate / (sampleRate << lowSamplingFrequency)) + padding;
+ } else if (layer === 2) {
+ return Math.round(144 * bitrate / sampleRate) + padding;
+ } else { // layer === 3
+ return (Math.round(12 * bitrate / sampleRate) + padding) * 4;
}
};
@@ -93,11 +90,21 @@ export const readFrameHeader = (word: number, remainingBytes: number | null): {
return { header: null, bytesAdvanced: 1 };
}
+ let lowSamplingFrequency = 0;
+ let mpeg25 = 0;
+
+ if (secondByte & (1 << 4)) {
+ lowSamplingFrequency = (secondByte & (1 << 3)) ? 0 : 1;
+ } else {
+ lowSamplingFrequency = 1;
+ mpeg25 = 1;
+ }
+
const mpegVersionId = (secondByte >> 3) & 0x3;
const layer = (secondByte >> 1) & 0x3;
const bitrateIndex = (thirdByte >> 4) & 0xf;
- const frequencyIndex = (thirdByte >> 2) & 0x3;
+ const frequencyIndex = ((thirdByte >> 2) & 0x3) % 3;
const padding = (thirdByte >> 1) & 0x1;
const channel = (fourthByte >> 6) & 0x3;
@@ -106,21 +113,14 @@ export const readFrameHeader = (word: number, remainingBytes: number | null): {
const original = (fourthByte >> 2) & 0x1;
const emphasis = fourthByte & 0x3;
- const kilobitRate = mpegVersionId === 3
- ? MPEG_V1_BITRATES[layer]?.[bitrateIndex]
- : MPEG_V2_BITRATES[layer]?.[bitrateIndex];
- if (!kilobitRate || kilobitRate === -1) {
+ const kilobitRate = KILOBIT_RATES[lowSamplingFrequency * 16 * 4 + layer * 16 + bitrateIndex]!;
+ if (kilobitRate === -1) {
return { header: null, bytesAdvanced: 1 };
}
const bitrate = kilobitRate * 1000;
-
- const sampleRate = SAMPLING_RATES[mpegVersionId]?.[frequencyIndex];
- if (!sampleRate || sampleRate === -1) {
- return { header: null, bytesAdvanced: 1 };
- }
-
- const frameLength = computeMp3FrameSize(layer, bitrate, sampleRate, padding);
+ const sampleRate = SAMPLING_RATES[frequencyIndex]! >> (lowSamplingFrequency + mpeg25);
+ const frameLength = computeMp3FrameSize(lowSamplingFrequency, layer, bitrate, sampleRate, padding);
if (remainingBytes !== null && remainingBytes < frameLength) {
// The frame doesn't fit into the rest of the file
diff --git a/src/mp3/mp3-demuxer.ts b/src/mp3/mp3-demuxer.ts
index dad16a1..b467c47 100644
--- a/src/mp3/mp3-demuxer.ts
+++ b/src/mp3/mp3-demuxer.ts
@@ -117,9 +117,16 @@ export class Mp3Demuxer extends Demuxer {
this.firstFrameHeader = header;
}
- const sampleDuration = header.audioSamplesInFrame / header.sampleRate;
+ if (header.sampleRate !== this.firstFrameHeader.sampleRate) {
+ console.warn(
+ `MP3 changed sample rate mid-file: ${this.firstFrameHeader.sampleRate} Hz to ${header.sampleRate} Hz.`
+ + ` Might be a bug, so please report this file.`,
+ );
+ }
+
+ const sampleDuration = header.audioSamplesInFrame / this.firstFrameHeader.sampleRate;
const sample: Sample = {
- timestamp: this.nextTimestampInSamples / header.sampleRate,
+ timestamp: this.nextTimestampInSamples / this.firstFrameHeader.sampleRate,
duration: sampleDuration,
dataStart: result.startPos,
dataSize: header.totalSize,
diff --git a/src/mp3/mp3-muxer.ts b/src/mp3/mp3-muxer.ts
index a18335b..7eb6563 100644
--- a/src/mp3/mp3-muxer.ts
+++ b/src/mp3/mp3-muxer.ts
@@ -84,6 +84,7 @@ export class Mp3Muxer extends Muxer {
mpegVersionId: header.mpegVersionId,
layer: header.layer,
frequencyIndex: header.frequencyIndex,
+ sampleRate: header.sampleRate,
channel: header.channel,
modeExtension: header.modeExtension,
copyright: header.copyright,
diff --git a/src/mp3/mp3-writer.ts b/src/mp3/mp3-writer.ts
index 4bd726f..98c3d26 100644
--- a/src/mp3/mp3-writer.ts
+++ b/src/mp3/mp3-writer.ts
@@ -12,9 +12,7 @@ import {
computeMp3FrameSize,
encodeSynchsafe,
getXingOffset,
- MPEG_V1_BITRATES,
- MPEG_V2_BITRATES,
- SAMPLING_RATES,
+ KILOBIT_RATES,
XING,
} from '../../shared/mp3-misc';
import { Id3V2TextEncoding } from './mp3-reader';
@@ -23,6 +21,7 @@ export type XingFrameData = {
mpegVersionId: number;
layer: number;
frequencyIndex: number;
+ sampleRate: number;
channel: number;
modeExtension: number;
copyright: number;
@@ -62,6 +61,78 @@ export class Mp3Writer {
this.writer.write(this.helper.subarray(0, text.length));
}
+ writeXingFrame(data: XingFrameData) {
+ const startPos = this.writer.getPos();
+
+ const firstByte = 0xff;
+ const secondByte = 0xe0 | (data.mpegVersionId << 3) | (data.layer << 1);
+
+ const lowSamplingFrequency = data.mpegVersionId & 1;
+
+ const padding = 0;
+ const neededBytes = 155;
+
+ let bitrateIndex = -1;
+ const bitrateOffset = lowSamplingFrequency * 16 * 4 + data.layer * 16;
+
+ // Let's find the lowest bitrate for which the frame size is sufficiently large to fit all the data
+ for (let i = 0; i < 16; i++) {
+ const kbr = KILOBIT_RATES[bitrateOffset + i]!;
+ const size = computeMp3FrameSize(lowSamplingFrequency, data.layer, 1000 * kbr, data.sampleRate, padding);
+
+ if (size >= neededBytes) {
+ bitrateIndex = i;
+ break;
+ }
+ }
+
+ if (bitrateIndex === -1) {
+ throw new Error('No suitable bitrate found.');
+ }
+
+ const thirdByte = (bitrateIndex << 4) | (data.frequencyIndex << 2) | padding << 1;
+ const fourthByte = (data.channel << 6)
+ | (data.modeExtension << 4)
+ | (data.copyright << 3)
+ | (data.original << 2)
+ | data.emphasis;
+
+ this.helper[0] = firstByte;
+ this.helper[1] = secondByte;
+ this.helper[2] = thirdByte;
+ this.helper[3] = fourthByte;
+
+ this.writer.write(this.helper.subarray(0, 4));
+
+ const xingOffset = getXingOffset(data.mpegVersionId, data.channel);
+
+ this.writer.seek(startPos + xingOffset);
+ this.writeU32(XING);
+
+ let flags = 0;
+ if (data.frameCount !== null) {
+ flags |= 1;
+ }
+ if (data.fileSize !== null) {
+ flags |= 2;
+ }
+ if (data.toc !== null) {
+ flags |= 4;
+ }
+
+ this.writeU32(flags);
+
+ this.writeU32(data.frameCount ?? 0);
+ this.writeU32(data.fileSize ?? 0);
+ this.writer.write(data.toc ?? new Uint8Array(100));
+
+ const kilobitRate = KILOBIT_RATES[bitrateOffset + bitrateIndex]!;
+ const frameSize = computeMp3FrameSize(
+ lowSamplingFrequency, data.layer, 1000 * kilobitRate, data.sampleRate, padding,
+ );
+ this.writer.seek(startPos + frameSize);
+ }
+
writeSynchsafeU32(value: number) {
this.writeU32(encodeSynchsafe(value));
}
@@ -170,72 +241,4 @@ export class Mp3Writer {
this.writer.write(imageData);
}
-
- writeXingFrame(data: XingFrameData) {
- const startPos = this.writer.getPos();
-
- const firstByte = 0xff;
- const secondByte = 0xe0 | (data.mpegVersionId << 3) | (data.layer << 1);
-
- const bitrateGroup = data.mpegVersionId === 3 ? MPEG_V1_BITRATES : MPEG_V2_BITRATES;
- const bitrates = bitrateGroup?.[data.layer];
- if (!bitrates) {
- throw new Error('Invalid MPEG version and layer combination.');
- }
-
- const sampleRate = SAMPLING_RATES[data.mpegVersionId]?.[data.frequencyIndex];
- if (!sampleRate || sampleRate === -1) {
- throw new Error('Invalid MPEG version and frequency index combination.');
- }
-
- const padding = 0;
- const neededBytes = 155;
-
- // Let's find the lowest bitrate for which the frame size is sufficiently large to fit all the data
- const bitrateIndex = bitrates.findIndex((kbr) => {
- return computeMp3FrameSize(data.layer, 1000 * kbr, sampleRate, padding) >= neededBytes;
- });
- if (bitrateIndex === -1) {
- throw new Error('No suitable bitrate found.');
- }
-
- const thirdByte = (bitrateIndex << 4) | (data.frequencyIndex << 2) | padding << 1;
- const fourthByte = (data.channel << 6)
- | (data.modeExtension << 4)
- | (data.copyright << 3)
- | (data.original << 2)
- | data.emphasis;
-
- this.helper[0] = firstByte;
- this.helper[1] = secondByte;
- this.helper[2] = thirdByte;
- this.helper[3] = fourthByte;
-
- this.writer.write(this.helper.subarray(0, 4));
-
- const xingOffset = getXingOffset(data.mpegVersionId, data.channel);
-
- this.writer.seek(startPos + xingOffset);
- this.writeU32(XING);
-
- let flags = 0;
- if (data.frameCount !== null) {
- flags |= 1;
- }
- if (data.fileSize !== null) {
- flags |= 2;
- }
- if (data.toc !== null) {
- flags |= 4;
- }
-
- this.writeU32(flags);
-
- this.writeU32(data.frameCount ?? 0);
- this.writeU32(data.fileSize ?? 0);
- this.writer.write(data.toc ?? new Uint8Array(100));
-
- const frameSize = computeMp3FrameSize(data.layer, 1000 * bitrates[bitrateIndex]!, sampleRate, padding);
- this.writer.seek(startPos + frameSize);
- }
}