Implement MP3 demuxer

This commit is contained in:
Vanilagy
2025-01-26 10:01:09 +01:00
parent 387c4dc525
commit 7205d9445b
6 changed files with 464 additions and 25 deletions
+2
View File
@@ -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
View File
@@ -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];
+217
View File
@@ -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);
}
}
+176
View File
@@ -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;
};
+4
View File
@@ -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
View File
@@ -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);