diff --git a/src/index.ts b/src/index.ts index fb4af5f..cbac2d9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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'; diff --git a/src/input-format.ts b/src/input-format.ts index 5125ae2..ed0fd88 100644 --- a/src/input-format.ts +++ b/src/input-format.ts @@ -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]; diff --git a/src/mp3/mp3-demuxer.ts b/src/mp3/mp3-demuxer.ts new file mode 100644 index 0000000..583ec23 --- /dev/null +++ b/src/mp3/mp3-demuxer.ts @@ -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 | 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 { + 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 { + 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); + } +} diff --git a/src/mp3/mp3-reader.ts b/src/mp3/mp3-reader.ts new file mode 100644 index 0000000..957fd82 --- /dev/null +++ b/src/mp3/mp3-reader.ts @@ -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 = { + // 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 = { + // 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 = { + // 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; +}; diff --git a/src/reader.ts b/src/reader.ts index c9bd2a3..6ac6eb6 100644 --- a/src/reader.ts +++ b/src/reader.ts @@ -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 diff --git a/src/wave/wave-demuxer.ts b/src/wave/wave-demuxer.ts index 31e04d8..ee3d9ae 100644 --- a/src/wave/wave-demuxer.ts +++ b/src/wave/wave-demuxer.ts @@ -17,7 +17,7 @@ export enum WaveFormat { } export class WaveDemuxer extends Demuxer { - riffReader: RiffReader; + metadataReader: RiffReader; chunkReader: RiffReader; metadataPromise: Promise | 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);