mirror of
https://github.com/arcodange-org/mediabunny.git
synced 2026-09-27 10:53:50 +02:00
Implement MP3 muxer
This commit is contained in:
@@ -16,11 +16,45 @@
|
|||||||
source
|
source
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const output = new Metamuxer.Output({
|
||||||
|
format: new Metamuxer.Mp3OutputFormat(),
|
||||||
|
target: new Metamuxer.BufferTarget()
|
||||||
|
});
|
||||||
|
|
||||||
|
const audioTrack = await input.getPrimaryAudioTrack();
|
||||||
|
const sink = new Metamuxer.EncodedAudioSampleSink(audioTrack);
|
||||||
|
|
||||||
|
const mediaSource = new Metamuxer.EncodedAudioSampleSource(await audioTrack.getCodec());
|
||||||
|
output.addAudioTrack(mediaSource);
|
||||||
|
|
||||||
|
output.start();
|
||||||
|
|
||||||
|
for await (const sample of sink.samples()) {
|
||||||
|
await mediaSource.digest(sample);
|
||||||
|
}
|
||||||
|
|
||||||
|
await output.finalize();
|
||||||
|
|
||||||
|
function download(blob, filename) {
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = filename;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(output.target.buffer)
|
||||||
|
download(new Blob([output.target.buffer]), 'converted.mp3');
|
||||||
|
|
||||||
|
|
||||||
|
/*
|
||||||
const videoTrack = await input.getPrimaryVideoTrack();
|
const videoTrack = await input.getPrimaryVideoTrack();
|
||||||
const sink = new Metamuxer.EncodedVideoSampleSink(videoTrack);
|
const sink = new Metamuxer.EncodedVideoSampleSink(videoTrack);
|
||||||
|
|
||||||
console.log(await sink.getSample(16));
|
console.log(await sink.getSample(16));
|
||||||
console.log(await sink.getFirstSample());
|
console.log(await sink.getFirstSample());
|
||||||
|
*/
|
||||||
|
|
||||||
/*
|
/*
|
||||||
const source = new Metamuxer.BufferSource(await file.arrayBuffer()) ?? new Metamuxer.BlobSource(file);
|
const source = new Metamuxer.BufferSource(await file.arrayBuffer()) ?? new Metamuxer.BlobSource(file);
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export {
|
|||||||
MkvOutputFormatOptions,
|
MkvOutputFormatOptions,
|
||||||
WebMOutputFormat,
|
WebMOutputFormat,
|
||||||
WebMOutputFormatOptions,
|
WebMOutputFormatOptions,
|
||||||
|
Mp3OutputFormat,
|
||||||
WaveOutputFormat,
|
WaveOutputFormat,
|
||||||
TrackCountLimits,
|
TrackCountLimits,
|
||||||
InclusiveRange,
|
InclusiveRange,
|
||||||
|
|||||||
+33
-33
@@ -179,39 +179,6 @@ export class WebMInputFormat extends MatroskaInputFormat {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @public */
|
|
||||||
export class WaveInputFormat extends InputFormat {
|
|
||||||
async _canReadInput(input: Input) {
|
|
||||||
const sourceSize = await input._mainReader.source._getSize();
|
|
||||||
if (sourceSize < 12) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const riffReader = new RiffReader(input._mainReader);
|
|
||||||
const riffType = riffReader.readAscii(4);
|
|
||||||
if (riffType !== 'RIFF' && riffType !== 'RIFX') {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
riffReader.pos = 8;
|
|
||||||
const format = riffReader.readAscii(4);
|
|
||||||
return format === 'WAVE';
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @internal */
|
|
||||||
_createDemuxer(input: Input) {
|
|
||||||
return new WaveDemuxer(input);
|
|
||||||
}
|
|
||||||
|
|
||||||
getName() {
|
|
||||||
return 'WAVE';
|
|
||||||
}
|
|
||||||
|
|
||||||
getMimeType() {
|
|
||||||
return 'audio/wav';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @public */
|
/** @public */
|
||||||
export class Mp3InputFormat extends InputFormat {
|
export class Mp3InputFormat extends InputFormat {
|
||||||
async _canReadInput(input: Input) {
|
async _canReadInput(input: Input) {
|
||||||
@@ -248,6 +215,39 @@ export class Mp3InputFormat extends InputFormat {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @public */
|
||||||
|
export class WaveInputFormat extends InputFormat {
|
||||||
|
async _canReadInput(input: Input) {
|
||||||
|
const sourceSize = await input._mainReader.source._getSize();
|
||||||
|
if (sourceSize < 12) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const riffReader = new RiffReader(input._mainReader);
|
||||||
|
const riffType = riffReader.readAscii(4);
|
||||||
|
if (riffType !== 'RIFF' && riffType !== 'RIFX') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
riffReader.pos = 8;
|
||||||
|
const format = riffReader.readAscii(4);
|
||||||
|
return format === 'WAVE';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @internal */
|
||||||
|
_createDemuxer(input: Input) {
|
||||||
|
return new WaveDemuxer(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
getName() {
|
||||||
|
return 'WAVE';
|
||||||
|
}
|
||||||
|
|
||||||
|
getMimeType() {
|
||||||
|
return 'audio/wav';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** @public */
|
/** @public */
|
||||||
export const MP4 = new Mp4InputFormat();
|
export const MP4 = new Mp4InputFormat();
|
||||||
/** @public */
|
/** @public */
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ import { InputAudioTrack, InputAudioTrackBacking } from '../input-track';
|
|||||||
import { SampleRetrievalOptions } from '../media-sink';
|
import { SampleRetrievalOptions } from '../media-sink';
|
||||||
import { assert, binarySearchExact, binarySearchLessOrEqual, last, UNDETERMINED_LANGUAGE } from '../misc';
|
import { assert, binarySearchExact, binarySearchLessOrEqual, last, UNDETERMINED_LANGUAGE } from '../misc';
|
||||||
import { EncodedAudioSample, PLACEHOLDER_DATA } from '../sample';
|
import { EncodedAudioSample, PLACEHOLDER_DATA } from '../sample';
|
||||||
import { FrameHeader, Mp3Reader } from './mp3-reader';
|
import { FrameHeader, getXingOffset, INFO, XING } from './mp3-misc';
|
||||||
|
import { Mp3Reader } from './mp3-reader';
|
||||||
|
|
||||||
const AUDIO_SAMPLES_PER_FRAME = 1152;
|
const AUDIO_SAMPLES_PER_FRAME = 1152;
|
||||||
|
|
||||||
@@ -54,13 +55,10 @@ export class Mp3Demuxer extends Demuxer {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
const xingOffset = header.mpegVersionId === 3
|
const xingOffset = getXingOffset(header.mpegVersionId, header.channel);
|
||||||
? (header.channelCount === 1 ? 21 : 36)
|
|
||||||
: (header.channelCount === 1 ? 13 : 21);
|
|
||||||
this.reader.pos = header.startPos + xingOffset;
|
this.reader.pos = header.startPos + xingOffset;
|
||||||
const word = this.reader.readU32();
|
const word = this.reader.readU32();
|
||||||
const isXing = word === 0x58696e67 // 'Xing'
|
const isXing = word === XING || word === INFO;
|
||||||
|| word === 0x496e666f; // 'Info'
|
|
||||||
|
|
||||||
this.reader.pos = header.startPos + header.totalSize - 1; // -1 in case the frame is 1 byte too short
|
this.reader.pos = header.startPos + header.totalSize - 1; // -1 in case the frame is 1 byte too short
|
||||||
|
|
||||||
@@ -137,7 +135,7 @@ class Mp3AudioTrackBacking implements InputAudioTrackBacking {
|
|||||||
|
|
||||||
async getNumberOfChannels() {
|
async getNumberOfChannels() {
|
||||||
assert(this.demuxer.firstFrameHeader);
|
assert(this.demuxer.firstFrameHeader);
|
||||||
return this.demuxer.firstFrameHeader.channelCount;
|
return this.demuxer.firstFrameHeader.channel === 3 ? 1 : 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getSampleRate() {
|
async getSampleRate() {
|
||||||
@@ -150,7 +148,7 @@ class Mp3AudioTrackBacking implements InputAudioTrackBacking {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
codec: 'mp3',
|
codec: 'mp3',
|
||||||
numberOfChannels: this.demuxer.firstFrameHeader.channelCount,
|
numberOfChannels: this.demuxer.firstFrameHeader.channel === 3 ? 1 : 2,
|
||||||
sampleRate: this.demuxer.firstFrameHeader.sampleRate,
|
sampleRate: this.demuxer.firstFrameHeader.sampleRate,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
export const FRAME_HEADER_SIZE = 4;
|
||||||
|
|
||||||
|
// These are in kbps:
|
||||||
|
export const MPEG_V1_BITRATES: Record<number, number[]> = {
|
||||||
|
// 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<number, number[]> = {
|
||||||
|
// 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<number, number[]> = {
|
||||||
|
// 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],
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 'Xing' */
|
||||||
|
export const XING = 0x58696e67;
|
||||||
|
/** 'Info' */
|
||||||
|
export const INFO = 0x496e666f;
|
||||||
|
|
||||||
|
export type FrameHeader = {
|
||||||
|
startPos: number;
|
||||||
|
mpegVersionId: number;
|
||||||
|
layer: number;
|
||||||
|
bitrate: number;
|
||||||
|
frequencyIndex: number;
|
||||||
|
sampleRate: number;
|
||||||
|
channel: number;
|
||||||
|
modeExtension: number;
|
||||||
|
copyright: number;
|
||||||
|
original: number;
|
||||||
|
emphasis: number;
|
||||||
|
totalSize: number;
|
||||||
|
dataStart: number;
|
||||||
|
dataSize: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const computeMp3FrameSize = (bitrate: number, sampleRate: number, padding: number) => {
|
||||||
|
return Math.floor((144 * bitrate / sampleRate) + padding);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getXingOffset = (mpegVersionId: number, channel: number) => {
|
||||||
|
return mpegVersionId === 3
|
||||||
|
? (channel === 3 ? 21 : 36)
|
||||||
|
: (channel === 3 ? 13 : 21);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const readFrameHeader = (word: number, reader: { pos: number; fileSize: number | null }): FrameHeader | null => {
|
||||||
|
const startPos = reader.pos;
|
||||||
|
|
||||||
|
const firstByte = word >>> 24;
|
||||||
|
const secondByte = (word >>> 16) & 0xff;
|
||||||
|
const thirdByte = (word >>> 8) & 0xff;
|
||||||
|
const fourthByte = word & 0xff;
|
||||||
|
|
||||||
|
if (firstByte !== 0xff && secondByte !== 0xff && thirdByte !== 0xff && fourthByte !== 0xff) {
|
||||||
|
reader.pos += 4;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
reader.pos += 1;
|
||||||
|
|
||||||
|
if ((secondByte & 0xe0) !== 0xe0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mpegVersionId = (secondByte >> 3) & 0x3;
|
||||||
|
const layer = (secondByte >> 1) & 0x3;
|
||||||
|
|
||||||
|
const bitrateIndex = (thirdByte >> 4) & 0xf;
|
||||||
|
const frequencyIndex = (thirdByte >> 2) & 0x3;
|
||||||
|
const padding = (thirdByte >> 1) & 0x1;
|
||||||
|
|
||||||
|
const channel = (fourthByte >> 6) & 0x3;
|
||||||
|
const modeExtension = (fourthByte >> 4) & 0x3;
|
||||||
|
const copyright = (fourthByte >> 3) & 0x1;
|
||||||
|
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) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bitrate = kilobitRate * 1000;
|
||||||
|
|
||||||
|
const sampleRate = SAMPLING_RATES[mpegVersionId]?.[frequencyIndex];
|
||||||
|
if (!sampleRate || sampleRate === -1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const frameLength = computeMp3FrameSize(bitrate, sampleRate, padding);
|
||||||
|
|
||||||
|
if (reader.fileSize !== null && reader.fileSize - startPos < frameLength) {
|
||||||
|
// The frame doesn't fit into the rest of the file
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
startPos: startPos,
|
||||||
|
mpegVersionId,
|
||||||
|
layer,
|
||||||
|
bitrate,
|
||||||
|
frequencyIndex,
|
||||||
|
sampleRate,
|
||||||
|
channel,
|
||||||
|
modeExtension,
|
||||||
|
copyright,
|
||||||
|
original,
|
||||||
|
emphasis,
|
||||||
|
totalSize: frameLength,
|
||||||
|
dataStart: startPos + 4,
|
||||||
|
dataSize: frameLength - 4,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import { assert } from '../misc';
|
||||||
|
import { Muxer } from '../muxer';
|
||||||
|
import { Output, OutputAudioTrack } from '../output';
|
||||||
|
import { EncodedAudioSample } from '../sample';
|
||||||
|
import { Writer } from '../writer';
|
||||||
|
import { getXingOffset, INFO, readFrameHeader, XING } from './mp3-misc';
|
||||||
|
import { Mp3Writer, XingFrameData } from './mp3-writer';
|
||||||
|
|
||||||
|
export class Mp3Muxer extends Muxer {
|
||||||
|
private writer: Writer;
|
||||||
|
private mp3Writer: Mp3Writer;
|
||||||
|
private xingFrameData: XingFrameData | null = null;
|
||||||
|
private frameCount = 0;
|
||||||
|
private framePositions: number[] = [];
|
||||||
|
|
||||||
|
constructor(output: Output) {
|
||||||
|
super(output);
|
||||||
|
|
||||||
|
this.writer = output._writer;
|
||||||
|
this.mp3Writer = new Mp3Writer(output._writer);
|
||||||
|
}
|
||||||
|
|
||||||
|
async start() {
|
||||||
|
// Nothing needed here
|
||||||
|
}
|
||||||
|
|
||||||
|
async addEncodedVideoSample() {
|
||||||
|
throw new Error('MP3 does not support video.');
|
||||||
|
}
|
||||||
|
|
||||||
|
async addEncodedAudioSample(
|
||||||
|
track: OutputAudioTrack,
|
||||||
|
sample: EncodedAudioSample,
|
||||||
|
) {
|
||||||
|
const release = await this.mutex.acquire();
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!this.xingFrameData) {
|
||||||
|
const view = new DataView(sample.data.buffer, sample.data.byteOffset, sample.data.byteLength);
|
||||||
|
if (view.byteLength < 4) {
|
||||||
|
throw new Error('Invalid MP3 header in sample.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const word = view.getUint32(0, false);
|
||||||
|
const header = readFrameHeader(word, { pos: 0, fileSize: null });
|
||||||
|
if (!header) {
|
||||||
|
throw new Error('Invalid MP3 header in sample.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const xingOffset = getXingOffset(header.mpegVersionId, header.channel);
|
||||||
|
if (view.byteLength >= xingOffset + 4) {
|
||||||
|
const word = view.getUint32(xingOffset, false);
|
||||||
|
const isXing = word === XING || word === INFO;
|
||||||
|
|
||||||
|
if (isXing) {
|
||||||
|
// This is not a data frame, so let's completely ignore this sample
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.xingFrameData = {
|
||||||
|
mpegVersionId: header.mpegVersionId,
|
||||||
|
layer: header.layer,
|
||||||
|
frequencyIndex: header.frequencyIndex,
|
||||||
|
channel: header.channel,
|
||||||
|
modeExtension: header.modeExtension,
|
||||||
|
copyright: header.copyright,
|
||||||
|
original: header.original,
|
||||||
|
emphasis: header.emphasis,
|
||||||
|
|
||||||
|
frameCount: null,
|
||||||
|
fileSize: null,
|
||||||
|
toc: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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.mp3Writer.writeXingFrame(this.xingFrameData);
|
||||||
|
|
||||||
|
this.frameCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.validateAndNormalizeTimestamp(track, sample.timestamp, sample.type === 'key');
|
||||||
|
|
||||||
|
this.framePositions.push(this.writer.getPos());
|
||||||
|
this.writer.write(sample.data);
|
||||||
|
this.frameCount++;
|
||||||
|
|
||||||
|
await this.writer.flush();
|
||||||
|
} finally {
|
||||||
|
release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async addSubtitleCue() {
|
||||||
|
throw new Error('MP3 does not support subtitles.');
|
||||||
|
}
|
||||||
|
|
||||||
|
async finalize() {
|
||||||
|
if (!this.xingFrameData) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const endPos = this.writer.getPos();
|
||||||
|
|
||||||
|
this.writer.seek(0);
|
||||||
|
|
||||||
|
const toc = new Uint8Array(100);
|
||||||
|
for (let i = 0; i < 100; i++) {
|
||||||
|
const index = Math.floor(this.framePositions.length * (i / 100));
|
||||||
|
assert(index !== -1 && index < this.framePositions.length);
|
||||||
|
|
||||||
|
const byteOffset = this.framePositions[index]!;
|
||||||
|
toc[i] = 256 * (byteOffset / endPos);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.xingFrameData.frameCount = this.frameCount;
|
||||||
|
this.xingFrameData.fileSize = endPos;
|
||||||
|
this.xingFrameData.toc = toc;
|
||||||
|
|
||||||
|
this.mp3Writer.writeXingFrame(this.xingFrameData);
|
||||||
|
|
||||||
|
this.writer.seek(endPos);
|
||||||
|
}
|
||||||
|
}
|
||||||
+5
-96
@@ -1,44 +1,6 @@
|
|||||||
import { assert } from '../misc';
|
import { assert } from '../misc';
|
||||||
import { Reader } from '../reader';
|
import { Reader } from '../reader';
|
||||||
|
import { FRAME_HEADER_SIZE, FrameHeader, readFrameHeader } from './mp3-misc';
|
||||||
const FRAME_HEADER_SIZE = 4;
|
|
||||||
|
|
||||||
// These are in kbps:
|
|
||||||
const MPEG_V1_BITRATES: Record<number, number[]> = {
|
|
||||||
// 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],
|
|
||||||
};
|
|
||||||
const MPEG_V2_BITRATES: Record<number, number[]> = {
|
|
||||||
// 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],
|
|
||||||
};
|
|
||||||
const SAMPLING_RATES: Record<number, number[]> = {
|
|
||||||
// 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],
|
|
||||||
};
|
|
||||||
|
|
||||||
export type FrameHeader = {
|
|
||||||
startPos: number;
|
|
||||||
mpegVersionId: number;
|
|
||||||
bitrate: number;
|
|
||||||
sampleRate: number;
|
|
||||||
channelCount: number;
|
|
||||||
totalSize: number;
|
|
||||||
dataStart: number;
|
|
||||||
dataSize: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export class Mp3Reader {
|
export class Mp3Reader {
|
||||||
pos = 0;
|
pos = 0;
|
||||||
@@ -96,66 +58,13 @@ export class Mp3Reader {
|
|||||||
until ??= this.fileSize;
|
until ??= this.fileSize;
|
||||||
|
|
||||||
while (this.pos < until - FRAME_HEADER_SIZE) {
|
while (this.pos < until - FRAME_HEADER_SIZE) {
|
||||||
const startPos = this.pos;
|
|
||||||
const word = this.readU32();
|
const word = this.readU32();
|
||||||
|
this.pos -= 4;
|
||||||
|
|
||||||
const firstByte = word >>> 24;
|
const header = readFrameHeader(word, this);
|
||||||
const secondByte = (word >>> 16) & 0xff;
|
if (header) {
|
||||||
const thirdByte = (word >>> 8) & 0xff;
|
return header;
|
||||||
const fourthByte = word & 0xff;
|
|
||||||
|
|
||||||
if (firstByte !== 0xff && secondByte !== 0xff && thirdByte !== 0xff && fourthByte !== 0xff) {
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.pos -= 3; // For when we continue
|
|
||||||
|
|
||||||
if ((secondByte & 0xe0) !== 0xe0) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const mpegVersionId = (secondByte >> 3) & 0x3;
|
|
||||||
const layer = (secondByte >> 1) & 0x3;
|
|
||||||
|
|
||||||
const bitrateIndex = (thirdByte >> 4) & 0xf;
|
|
||||||
const frequencyIndex = (thirdByte >> 2) & 0x3;
|
|
||||||
const padding = (thirdByte >> 1) & 0x1;
|
|
||||||
|
|
||||||
const channel = (fourthByte >> 6) & 0x3;
|
|
||||||
|
|
||||||
const kilobitRate = mpegVersionId === 3
|
|
||||||
? MPEG_V1_BITRATES[layer]?.[bitrateIndex]
|
|
||||||
: MPEG_V2_BITRATES[layer]?.[bitrateIndex];
|
|
||||||
if (!kilobitRate || kilobitRate === -1) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const bitrate = kilobitRate * 1000;
|
|
||||||
|
|
||||||
const sampleRate = SAMPLING_RATES[mpegVersionId]?.[frequencyIndex];
|
|
||||||
if (!sampleRate || sampleRate === -1) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const channelCount = channel === 3 ? 1 : 2;
|
|
||||||
|
|
||||||
const frameLength = Math.floor((144 * bitrate / sampleRate) + padding);
|
|
||||||
|
|
||||||
if (this.fileSize - startPos < frameLength) {
|
|
||||||
// The frame doesn't fit into the rest of the file
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
startPos,
|
|
||||||
mpegVersionId,
|
|
||||||
bitrate,
|
|
||||||
sampleRate,
|
|
||||||
channelCount,
|
|
||||||
totalSize: frameLength,
|
|
||||||
dataStart: startPos + 4,
|
|
||||||
dataSize: frameLength - 4,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { Writer } from '../writer';
|
||||||
|
import {
|
||||||
|
computeMp3FrameSize,
|
||||||
|
getXingOffset,
|
||||||
|
MPEG_V1_BITRATES,
|
||||||
|
MPEG_V2_BITRATES,
|
||||||
|
SAMPLING_RATES,
|
||||||
|
XING,
|
||||||
|
} from './mp3-misc';
|
||||||
|
|
||||||
|
export type XingFrameData = {
|
||||||
|
mpegVersionId: number;
|
||||||
|
layer: number;
|
||||||
|
frequencyIndex: number;
|
||||||
|
channel: number;
|
||||||
|
modeExtension: number;
|
||||||
|
copyright: number;
|
||||||
|
original: number;
|
||||||
|
emphasis: number;
|
||||||
|
|
||||||
|
frameCount: number | null;
|
||||||
|
fileSize: number | null;
|
||||||
|
toc: Uint8Array | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class Mp3Writer {
|
||||||
|
private helper = new Uint8Array(8);
|
||||||
|
private helperView = new DataView(this.helper.buffer);
|
||||||
|
|
||||||
|
constructor(private writer: Writer) {}
|
||||||
|
|
||||||
|
writeU32(value: number) {
|
||||||
|
this.helperView.setUint32(0, value, false);
|
||||||
|
this.writer.write(this.helper.subarray(0, 4));
|
||||||
|
}
|
||||||
|
|
||||||
|
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(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(1000 * bitrates[bitrateIndex]!, sampleRate, padding);
|
||||||
|
this.writer.seek(startPos + frameSize);
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-2
@@ -14,12 +14,12 @@ export abstract class Muxer {
|
|||||||
abstract start(): Promise<void>;
|
abstract start(): Promise<void>;
|
||||||
abstract addEncodedVideoSample(
|
abstract addEncodedVideoSample(
|
||||||
track: OutputVideoTrack,
|
track: OutputVideoTrack,
|
||||||
chunk: EncodedVideoSample,
|
sample: EncodedVideoSample,
|
||||||
meta?: EncodedVideoChunkMetadata
|
meta?: EncodedVideoChunkMetadata
|
||||||
): Promise<void>;
|
): Promise<void>;
|
||||||
abstract addEncodedAudioSample(
|
abstract addEncodedAudioSample(
|
||||||
track: OutputAudioTrack,
|
track: OutputAudioTrack,
|
||||||
chunk: EncodedAudioSample,
|
sample: EncodedAudioSample,
|
||||||
meta?: EncodedAudioChunkMetadata
|
meta?: EncodedAudioChunkMetadata
|
||||||
): Promise<void>;
|
): Promise<void>;
|
||||||
abstract addSubtitleCue(track: OutputSubtitleTrack, cue: SubtitleCue, meta?: SubtitleMetadata): Promise<void>;
|
abstract addSubtitleCue(track: OutputSubtitleTrack, cue: SubtitleCue, meta?: SubtitleMetadata): Promise<void>;
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
} from './codec';
|
} from './codec';
|
||||||
import { IsobmffMuxer } from './isobmff/isobmff-muxer';
|
import { IsobmffMuxer } from './isobmff/isobmff-muxer';
|
||||||
import { MatroskaMuxer } from './matroska/matroska-muxer';
|
import { MatroskaMuxer } from './matroska/matroska-muxer';
|
||||||
|
import { Mp3Muxer } from './mp3/mp3-muxer';
|
||||||
import { Muxer } from './muxer';
|
import { Muxer } from './muxer';
|
||||||
import { Output, TrackType } from './output';
|
import { Output, TrackType } from './output';
|
||||||
import { WaveMuxer } from './wave/wave-muxer';
|
import { WaveMuxer } from './wave/wave-muxer';
|
||||||
@@ -256,6 +257,40 @@ export class WebMOutputFormat extends MkvOutputFormat {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @public */
|
||||||
|
export class Mp3OutputFormat extends OutputFormat {
|
||||||
|
/** @internal */
|
||||||
|
_createMuxer(output: Output) {
|
||||||
|
return new Mp3Muxer(output);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @internal */
|
||||||
|
_getName() {
|
||||||
|
return 'MP3';
|
||||||
|
}
|
||||||
|
|
||||||
|
getSupportedTrackCounts(): TrackCountLimits {
|
||||||
|
return {
|
||||||
|
video: { min: 0, max: 0 },
|
||||||
|
audio: { min: 1, max: 1 },
|
||||||
|
subtitle: { min: 0, max: 0 },
|
||||||
|
total: { min: 1, max: 1 },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
getFileExtension() {
|
||||||
|
return '.mp3';
|
||||||
|
}
|
||||||
|
|
||||||
|
getSupportedCodecs() {
|
||||||
|
return Mp3OutputFormat.getSupportedCodecs();
|
||||||
|
}
|
||||||
|
|
||||||
|
static getSupportedCodecs(): MediaCodec[] {
|
||||||
|
return ['mp3'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** @public */
|
/** @public */
|
||||||
export class WaveOutputFormat extends OutputFormat {
|
export class WaveOutputFormat extends OutputFormat {
|
||||||
/** @internal */
|
/** @internal */
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export class WaveMuxer extends Muxer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async addEncodedVideoSample() {
|
async addEncodedVideoSample() {
|
||||||
throw new Error('WAVE format does not support video.');
|
throw new Error('WAVE does not support video.');
|
||||||
}
|
}
|
||||||
|
|
||||||
async addEncodedAudioSample(
|
async addEncodedAudioSample(
|
||||||
@@ -46,19 +46,17 @@ export class WaveMuxer extends Muxer {
|
|||||||
|
|
||||||
this.validateAndNormalizeTimestamp(track, sample.timestamp, sample.type === 'key');
|
this.validateAndNormalizeTimestamp(track, sample.timestamp, sample.type === 'key');
|
||||||
|
|
||||||
if (sample.data) {
|
this.writer.write(sample.data);
|
||||||
this.output._writer.write(sample.data);
|
this.dataSize += sample.data.byteLength;
|
||||||
this.dataSize += sample.data.byteLength;
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.output._writer.flush();
|
await this.writer.flush();
|
||||||
} finally {
|
} finally {
|
||||||
release();
|
release();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async addSubtitleCue() {
|
async addSubtitleCue() {
|
||||||
throw new Error('WAVE format does not support subtitles.');
|
throw new Error('WAVE does not support subtitles.');
|
||||||
}
|
}
|
||||||
|
|
||||||
private writeHeader(track: OutputAudioTrack, config: AudioDecoderConfig) {
|
private writeHeader(track: OutputAudioTrack, config: AudioDecoderConfig) {
|
||||||
@@ -102,7 +100,7 @@ export class WaveMuxer extends Muxer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async finalize() {
|
async finalize() {
|
||||||
const currentPos = this.writer.getPos();
|
const endPos = this.writer.getPos();
|
||||||
|
|
||||||
// Write file size
|
// Write file size
|
||||||
this.writer.seek(4);
|
this.writer.seek(4);
|
||||||
@@ -112,6 +110,6 @@ export class WaveMuxer extends Muxer {
|
|||||||
this.writer.seek(40);
|
this.writer.seek(40);
|
||||||
this.riffWriter.writeU32(this.dataSize);
|
this.riffWriter.writeU32(this.dataSize);
|
||||||
|
|
||||||
this.writer.seek(currentPos);
|
this.writer.seek(endPos);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user