mirror of
https://github.com/arcodange-org/mediabunny.git
synced 2026-09-27 19:03:46 +02:00
Implement MP3 demuxer
This commit is contained in:
@@ -74,12 +74,14 @@ export {
|
||||
QuickTimeInputFormat,
|
||||
MatroskaInputFormat,
|
||||
WebMInputFormat,
|
||||
Mp3InputFormat,
|
||||
WaveInputFormat,
|
||||
ALL_FORMATS,
|
||||
MP4,
|
||||
QTFF,
|
||||
MATROSKA,
|
||||
WEBM,
|
||||
MP3,
|
||||
WAVE,
|
||||
} from './input-format';
|
||||
export { Input, InputOptions } from './input';
|
||||
|
||||
+41
-1
@@ -4,6 +4,8 @@ import { IsobmffDemuxer } from './isobmff/isobmff-demuxer';
|
||||
import { IsobmffReader } from './isobmff/isobmff-reader';
|
||||
import { EBMLId, EBMLReader } from './matroska/ebml';
|
||||
import { MatroskaDemuxer } from './matroska/matroska-demuxer';
|
||||
import { Mp3Demuxer } from './mp3/mp3-demuxer';
|
||||
import { Mp3Reader } from './mp3/mp3-reader';
|
||||
import { RiffReader } from './wave/riff-reader';
|
||||
import { WaveDemuxer } from './wave/wave-demuxer';
|
||||
|
||||
@@ -210,6 +212,42 @@ export class WaveInputFormat extends InputFormat {
|
||||
}
|
||||
}
|
||||
|
||||
/** @public */
|
||||
export class Mp3InputFormat extends InputFormat {
|
||||
async _canReadInput(input: Input) {
|
||||
const sourceSize = await input._mainReader.source._getSize();
|
||||
if (sourceSize < 4) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const mp3Reader = new Mp3Reader(input._mainReader);
|
||||
mp3Reader.fileSize = sourceSize;
|
||||
|
||||
const id3Tag = mp3Reader.readId3();
|
||||
|
||||
if (id3Tag) {
|
||||
mp3Reader.pos += id3Tag.size;
|
||||
}
|
||||
|
||||
await mp3Reader.reader.loadRange(mp3Reader.pos, mp3Reader.pos + 4096);
|
||||
|
||||
return mp3Reader.readNextFrameHeader(mp3Reader.pos + 4096) !== null;
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
_createDemuxer(input: Input) {
|
||||
return new Mp3Demuxer(input);
|
||||
}
|
||||
|
||||
getName() {
|
||||
return 'MP3';
|
||||
}
|
||||
|
||||
getMimeType() {
|
||||
return 'audio/mpeg';
|
||||
}
|
||||
}
|
||||
|
||||
/** @public */
|
||||
export const MP4 = new Mp4InputFormat();
|
||||
/** @public */
|
||||
@@ -219,7 +257,9 @@ export const MATROSKA = new MatroskaInputFormat();
|
||||
/** @public */
|
||||
export const WEBM = new WebMInputFormat();
|
||||
/** @public */
|
||||
export const MP3 = new Mp3InputFormat();
|
||||
/** @public */
|
||||
export const WAVE = new WaveInputFormat();
|
||||
|
||||
/** @public */
|
||||
export const ALL_FORMATS: InputFormat[] = [MP4, QTFF, MATROSKA, WEBM, WAVE];
|
||||
export const ALL_FORMATS: InputFormat[] = [MP4, QTFF, MATROSKA, WEBM, MP3, WAVE];
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
import { AudioCodec } from '../codec';
|
||||
import { Demuxer } from '../demuxer';
|
||||
import { Input } from '../input';
|
||||
import { InputAudioTrack, InputAudioTrackBacking } from '../input-track';
|
||||
import { SampleRetrievalOptions } from '../media-sink';
|
||||
import { assert, binarySearchExact, binarySearchLessOrEqual, last, UNDETERMINED_LANGUAGE } from '../misc';
|
||||
import { EncodedAudioSample, PLACEHOLDER_DATA } from '../sample';
|
||||
import { FrameHeader, Mp3Reader } from './mp3-reader';
|
||||
|
||||
const AUDIO_SAMPLES_PER_FRAME = 1152;
|
||||
|
||||
type Sample = {
|
||||
timestamp: number;
|
||||
duration: number;
|
||||
dataStart: number;
|
||||
dataSize: number;
|
||||
};
|
||||
|
||||
export class Mp3Demuxer extends Demuxer {
|
||||
reader: Mp3Reader;
|
||||
|
||||
metadataPromise: Promise<void> | null = null;
|
||||
firstFrameHeader: FrameHeader | null = null;
|
||||
allSamples: Sample[] = [];
|
||||
|
||||
tracks: InputAudioTrack[] = [];
|
||||
|
||||
constructor(input: Input) {
|
||||
super(input);
|
||||
|
||||
this.reader = new Mp3Reader(input._mainReader);
|
||||
}
|
||||
|
||||
async readMetadata() {
|
||||
return this.metadataPromise ??= (async () => {
|
||||
const fileSize = await this.input._source._getSize();
|
||||
this.reader.fileSize = fileSize;
|
||||
|
||||
// Just load the entire file. Primitive, but the only way to actually ensure 100% correct timestamps.
|
||||
// Random access in MP3 can be flaky and unreliable.
|
||||
await this.reader.reader.loadRange(0, fileSize);
|
||||
|
||||
const id3Tag = this.reader.readId3();
|
||||
if (id3Tag) {
|
||||
this.reader.pos += id3Tag.size;
|
||||
}
|
||||
|
||||
let nextTimestamp = 0;
|
||||
|
||||
// Let's read all samples
|
||||
while (true) {
|
||||
const header = this.reader.readNextFrameHeader();
|
||||
if (!header) {
|
||||
break;
|
||||
}
|
||||
|
||||
const xingOffset = header.mpegVersionId === 3
|
||||
? (header.channelCount === 1 ? 21 : 36)
|
||||
: (header.channelCount === 1 ? 13 : 21);
|
||||
this.reader.pos = header.startPos + xingOffset;
|
||||
const word = this.reader.readU32();
|
||||
const isXing = word === 0x58696e67 // 'Xing'
|
||||
|| word === 0x496e666f; // 'Info'
|
||||
|
||||
this.reader.pos = header.startPos + header.totalSize - 1; // -1 in case the frame is 1 byte too short
|
||||
|
||||
if (isXing) {
|
||||
// There's no actual audio data in this frame, so let's skip it
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!this.firstFrameHeader) {
|
||||
this.firstFrameHeader = header;
|
||||
}
|
||||
|
||||
const sampleDuration = AUDIO_SAMPLES_PER_FRAME / header.sampleRate;
|
||||
const sample: Sample = {
|
||||
timestamp: nextTimestamp,
|
||||
duration: sampleDuration,
|
||||
dataStart: header.startPos,
|
||||
dataSize: header.totalSize,
|
||||
};
|
||||
|
||||
this.allSamples.push(sample);
|
||||
nextTimestamp += sampleDuration;
|
||||
}
|
||||
|
||||
if (!this.firstFrameHeader) {
|
||||
throw new Error('No MP3 frames found.');
|
||||
}
|
||||
|
||||
this.tracks = [new InputAudioTrack(new Mp3AudioTrackBacking(this))];
|
||||
})();
|
||||
}
|
||||
|
||||
async getMimeType() {
|
||||
return 'audio/mpeg';
|
||||
}
|
||||
|
||||
async getTracks() {
|
||||
await this.readMetadata();
|
||||
return this.tracks;
|
||||
}
|
||||
|
||||
async computeDuration() {
|
||||
await this.readMetadata();
|
||||
|
||||
const lastSample = last(this.allSamples);
|
||||
assert(lastSample);
|
||||
|
||||
return lastSample.timestamp + lastSample.duration;
|
||||
}
|
||||
}
|
||||
|
||||
class Mp3AudioTrackBacking implements InputAudioTrackBacking {
|
||||
constructor(public demuxer: Mp3Demuxer) {}
|
||||
|
||||
getId() {
|
||||
return 1;
|
||||
}
|
||||
|
||||
async getFirstTimestamp() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
computeDuration() {
|
||||
return this.demuxer.computeDuration();
|
||||
}
|
||||
|
||||
async getLanguageCode() {
|
||||
return UNDETERMINED_LANGUAGE;
|
||||
}
|
||||
|
||||
async getCodec(): Promise<AudioCodec> {
|
||||
return 'mp3';
|
||||
}
|
||||
|
||||
async getNumberOfChannels() {
|
||||
assert(this.demuxer.firstFrameHeader);
|
||||
return this.demuxer.firstFrameHeader.channelCount;
|
||||
}
|
||||
|
||||
async getSampleRate() {
|
||||
assert(this.demuxer.firstFrameHeader);
|
||||
return this.demuxer.firstFrameHeader.sampleRate;
|
||||
}
|
||||
|
||||
async getDecoderConfig(): Promise<AudioDecoderConfig> {
|
||||
assert(this.demuxer.firstFrameHeader);
|
||||
|
||||
return {
|
||||
codec: 'mp3',
|
||||
numberOfChannels: this.demuxer.firstFrameHeader.channelCount,
|
||||
sampleRate: this.demuxer.firstFrameHeader.sampleRate,
|
||||
};
|
||||
}
|
||||
|
||||
getSampleAtIndex(sampleIndex: number, options: SampleRetrievalOptions) {
|
||||
if (sampleIndex === -1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rawSample = this.demuxer.allSamples[sampleIndex];
|
||||
if (!rawSample) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let data: Uint8Array;
|
||||
if (options.metadataOnly) {
|
||||
data = PLACEHOLDER_DATA;
|
||||
} else {
|
||||
this.demuxer.reader.pos = rawSample.dataStart;
|
||||
data = this.demuxer.reader.readBytes(rawSample.dataSize);
|
||||
}
|
||||
|
||||
return new EncodedAudioSample(
|
||||
data,
|
||||
'key',
|
||||
rawSample.timestamp,
|
||||
rawSample.duration,
|
||||
);
|
||||
}
|
||||
|
||||
async getFirstSample(options: SampleRetrievalOptions) {
|
||||
return this.getSampleAtIndex(0, options);
|
||||
}
|
||||
|
||||
async getNextSample(sample: EncodedAudioSample, options: SampleRetrievalOptions) {
|
||||
const sampleIndex = binarySearchExact(
|
||||
this.demuxer.allSamples,
|
||||
sample.timestamp,
|
||||
x => x.timestamp,
|
||||
);
|
||||
if (sampleIndex === -1) {
|
||||
throw new Error('Sample was not created from this track.');
|
||||
}
|
||||
|
||||
return this.getSampleAtIndex(sampleIndex + 1, options);
|
||||
}
|
||||
|
||||
async getSample(timestamp: number, options: SampleRetrievalOptions) {
|
||||
const index = binarySearchLessOrEqual(
|
||||
this.demuxer.allSamples,
|
||||
timestamp,
|
||||
x => x.timestamp,
|
||||
);
|
||||
return this.getSampleAtIndex(index, options);
|
||||
}
|
||||
|
||||
getKeySample(timestamp: number, options: SampleRetrievalOptions) {
|
||||
return this.getSample(timestamp, options);
|
||||
}
|
||||
|
||||
getNextKeySample(sample: EncodedAudioSample, options: SampleRetrievalOptions) {
|
||||
return this.getNextSample(sample, options);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import { assert } from '../misc';
|
||||
import { Reader } from '../reader';
|
||||
|
||||
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 {
|
||||
pos = 0;
|
||||
fileSize: number | null = null;
|
||||
|
||||
constructor(public reader: Reader) {}
|
||||
|
||||
readBytes(length: number) {
|
||||
const { view, offset } = this.reader.getViewAndOffset(this.pos, this.pos + length);
|
||||
this.pos += length;
|
||||
|
||||
return new Uint8Array(view.buffer, offset, length);
|
||||
}
|
||||
|
||||
readU16() {
|
||||
const { view, offset } = this.reader.getViewAndOffset(this.pos, this.pos + 2);
|
||||
this.pos += 2;
|
||||
|
||||
return view.getUint16(offset, false);
|
||||
}
|
||||
|
||||
readU32() {
|
||||
const { view, offset } = this.reader.getViewAndOffset(this.pos, this.pos + 4);
|
||||
this.pos += 4;
|
||||
|
||||
return view.getUint32(offset, false);
|
||||
}
|
||||
|
||||
readAscii(length: number) {
|
||||
const { view, offset } = this.reader.getViewAndOffset(this.pos, this.pos + length);
|
||||
this.pos += length;
|
||||
|
||||
let str = '';
|
||||
for (let i = 0; i < length; i++) {
|
||||
str += String.fromCharCode(view.getUint8(offset + i));
|
||||
}
|
||||
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;
|
||||
|
||||
while (this.pos < until - FRAME_HEADER_SIZE) {
|
||||
const startPos = this.pos;
|
||||
const word = this.readU32();
|
||||
|
||||
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) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
export const decodeSynchsafe = (synchsafed: number) => {
|
||||
let mask = 0x7f000000;
|
||||
let unsynchsafed = 0;
|
||||
|
||||
while (mask !== 0) {
|
||||
unsynchsafed >>= 1;
|
||||
unsynchsafed |= synchsafed & mask;
|
||||
mask >>= 8;
|
||||
}
|
||||
|
||||
return unsynchsafed;
|
||||
};
|
||||
@@ -27,6 +27,10 @@ export class Reader {
|
||||
async loadRange(start: number, end: number) {
|
||||
end = Math.min(end, await this.source._getSize());
|
||||
|
||||
if (start >= end) {
|
||||
return;
|
||||
}
|
||||
|
||||
const matchingLoadingSegment = this.loadingSegments.find(x => x.start <= start && x.end >= end);
|
||||
if (matchingLoadingSegment) {
|
||||
// Simply wait for the existing promise to finish to avoid loading the same range twice
|
||||
|
||||
+24
-24
@@ -17,7 +17,7 @@ export enum WaveFormat {
|
||||
}
|
||||
|
||||
export class WaveDemuxer extends Demuxer {
|
||||
riffReader: RiffReader;
|
||||
metadataReader: RiffReader;
|
||||
chunkReader: RiffReader;
|
||||
|
||||
metadataPromise: Promise<void> | null = null;
|
||||
@@ -36,38 +36,38 @@ export class WaveDemuxer extends Demuxer {
|
||||
constructor(input: Input) {
|
||||
super(input);
|
||||
|
||||
this.riffReader = new RiffReader(input._mainReader);
|
||||
this.metadataReader = new RiffReader(input._mainReader);
|
||||
this.chunkReader = new RiffReader(new Reader(input._source, 64 * 2 ** 20));
|
||||
}
|
||||
|
||||
async readMetadata() {
|
||||
return this.metadataPromise ??= (async () => {
|
||||
const riffType = this.riffReader.readAscii(4);
|
||||
this.riffReader.littleEndian = riffType === 'RIFF';
|
||||
const riffType = this.metadataReader.readAscii(4);
|
||||
this.metadataReader.littleEndian = riffType === 'RIFF';
|
||||
|
||||
const totalFileSize = this.riffReader.readU32() + 8;
|
||||
const format = this.riffReader.readAscii(4);
|
||||
const totalFileSize = this.metadataReader.readU32() + 8;
|
||||
const format = this.metadataReader.readAscii(4);
|
||||
|
||||
if (format !== 'WAVE') {
|
||||
throw new Error('Invalid WAVE file - wrong format');
|
||||
}
|
||||
|
||||
this.riffReader.pos = 12;
|
||||
while (this.riffReader.pos < totalFileSize) {
|
||||
await this.riffReader.reader.loadRange(this.riffReader.pos, this.riffReader.pos + 8);
|
||||
this.metadataReader.pos = 12;
|
||||
while (this.metadataReader.pos < totalFileSize) {
|
||||
await this.metadataReader.reader.loadRange(this.metadataReader.pos, this.metadataReader.pos + 8);
|
||||
|
||||
const chunkId = this.riffReader.readAscii(4);
|
||||
const chunkSize = this.riffReader.readU32();
|
||||
const startPos = this.riffReader.pos;
|
||||
const chunkId = this.metadataReader.readAscii(4);
|
||||
const chunkSize = this.metadataReader.readU32();
|
||||
const startPos = this.metadataReader.pos;
|
||||
|
||||
if (chunkId === 'fmt ') {
|
||||
await this.parseFmtChunk(chunkSize);
|
||||
} else if (chunkId === 'data') {
|
||||
this.dataStart = this.riffReader.pos;
|
||||
this.dataStart = this.metadataReader.pos;
|
||||
this.dataSize = chunkSize;
|
||||
}
|
||||
|
||||
this.riffReader.pos = startPos + chunkSize + (chunkSize & 1); // Handle padding
|
||||
this.metadataReader.pos = startPos + chunkSize + (chunkSize & 1); // Handle padding
|
||||
}
|
||||
|
||||
if (!this.audioInfo) {
|
||||
@@ -85,32 +85,32 @@ export class WaveDemuxer extends Demuxer {
|
||||
}
|
||||
|
||||
private async parseFmtChunk(size: number) {
|
||||
await this.riffReader.reader.loadRange(this.riffReader.pos, this.riffReader.pos + size);
|
||||
await this.metadataReader.reader.loadRange(this.metadataReader.pos, this.metadataReader.pos + size);
|
||||
|
||||
let formatTag = this.riffReader.readU16();
|
||||
const numChannels = this.riffReader.readU16();
|
||||
const sampleRate = this.riffReader.readU32();
|
||||
this.riffReader.pos += 4;
|
||||
const blockAlign = this.riffReader.readU16();
|
||||
let formatTag = this.metadataReader.readU16();
|
||||
const numChannels = this.metadataReader.readU16();
|
||||
const sampleRate = this.metadataReader.readU32();
|
||||
this.metadataReader.pos += 4;
|
||||
const blockAlign = this.metadataReader.readU16();
|
||||
|
||||
let bitsPerSample: number;
|
||||
|
||||
if (size === 14) { // Plain WAVEFORMAT
|
||||
bitsPerSample = 8;
|
||||
} else {
|
||||
bitsPerSample = this.riffReader.readU16();
|
||||
bitsPerSample = this.metadataReader.readU16();
|
||||
}
|
||||
|
||||
// Handle WAVEFORMATEXTENSIBLE
|
||||
if (size >= 18 && formatTag !== 0x0165) {
|
||||
const cbSize = this.riffReader.readU16();
|
||||
const cbSize = this.metadataReader.readU16();
|
||||
const remainingSize = size - 18;
|
||||
const extensionSize = Math.min(remainingSize, cbSize);
|
||||
|
||||
if (extensionSize >= 22 && formatTag === WaveFormat.EXTENSIBLE) {
|
||||
// Parse WAVEFORMATEXTENSIBLE
|
||||
this.riffReader.pos += 2 + 4;
|
||||
const subFormat = this.riffReader.readBytes(16);
|
||||
this.metadataReader.pos += 2 + 4;
|
||||
const subFormat = this.metadataReader.readBytes(16);
|
||||
|
||||
// Get actual format from subFormat GUID
|
||||
formatTag = subFormat[0]! | (subFormat[1]! << 8);
|
||||
|
||||
Reference in New Issue
Block a user