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); - } }