diff --git a/dev/demux.html b/dev/demux.html index 73a6a79..1674f94 100644 --- a/dev/demux.html +++ b/dev/demux.html @@ -16,6 +16,19 @@ source }); + const audioTrack = await input.getPrimaryAudioTrack(); + const sink = new Mediabunny.EncodedPacketSink(audioTrack); + + console.log(await sink.getPacket(100)) + + /* + for await (const packet of sink.packets()) { + //console.log(packet); + //if (packet.timestamp >= 2.4) break; + } + */ + + /* const videoTrack = await input.getPrimaryVideoTrack(); const sink = new Mediabunny.VideoSampleSink(videoTrack); @@ -23,6 +36,7 @@ console.log(sample.timestamp); sample.close(); } + */ /* const audioTrack = await input.getPrimaryAudioTrack(); diff --git a/src/mp3/mp3-demuxer.ts b/src/mp3/mp3-demuxer.ts index e3c68db..a9db28f 100644 --- a/src/mp3/mp3-demuxer.ts +++ b/src/mp3/mp3-demuxer.ts @@ -11,7 +11,7 @@ import { Demuxer } from '../demuxer'; import { Input } from '../input'; import { InputAudioTrack, InputAudioTrackBacking } from '../input-track'; import { PacketRetrievalOptions } from '../media-sink'; -import { assert, binarySearchExact, binarySearchLessOrEqual, last, UNDETERMINED_LANGUAGE } from '../misc'; +import { assert, AsyncMutex, binarySearchExact, binarySearchLessOrEqual, UNDETERMINED_LANGUAGE } from '../misc'; import { EncodedPacket, PLACEHOLDER_DATA } from '../packet'; import { FrameHeader, getXingOffset, INFO, XING } from '../../shared/mp3-misc'; import { Mp3Reader } from './mp3-reader'; @@ -28,10 +28,15 @@ export class Mp3Demuxer extends Demuxer { metadataPromise: Promise | null = null; firstFrameHeader: FrameHeader | null = null; - allSamples: Sample[] = []; + loadedSamples: Sample[] = []; // All samples from the start of the file to lastLoadedPos tracks: InputAudioTrack[] = []; + loadingMutex = new AsyncMutex(); + lastLoadedPos = 0; + fileSize = 0; + nextTimestampInSamples = 0; + constructor(input: Input) { super(input); @@ -40,53 +45,12 @@ export class Mp3Demuxer extends Demuxer { async readMetadata() { return this.metadataPromise ??= (async () => { - const fileSize = await this.input.source.getSize(); - this.reader.fileSize = fileSize; + this.fileSize = await this.input.source.getSize(); + this.reader.fileSize = this.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 nextTimestampInSamples = 0; - - // Let's read all samples - while (true) { - const header = this.reader.readNextFrameHeader(); - if (!header) { - break; - } - - const xingOffset = getXingOffset(header.mpegVersionId, header.channel); - this.reader.pos = header.startPos + xingOffset; - const word = this.reader.readU32(); - const isXing = word === XING || word === 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 = header.audioSamplesInFrame / header.sampleRate; - const sample: Sample = { - timestamp: nextTimestampInSamples / header.sampleRate, - duration: sampleDuration, - dataStart: header.startPos, - dataSize: header.totalSize, - }; - - this.allSamples.push(sample); - nextTimestampInSamples += header.audioSamplesInFrame; + // Keep loading until we find the first frame header + while (!this.firstFrameHeader && this.lastLoadedPos < this.fileSize) { + await this.loadNextChunk(); } if (!this.firstFrameHeader) { @@ -97,6 +61,80 @@ export class Mp3Demuxer extends Demuxer { })(); } + /** Loads the next 0.5 MiB of frames. */ + async loadNextChunk() { + const release = await this.loadingMutex.acquire(); + + try { + assert(this.lastLoadedPos < this.fileSize); + + const chunkSize = 0.5 * 1024 * 1024; // 0.5 MiB + const endPos = Math.min(this.lastLoadedPos + chunkSize, this.fileSize); + await this.reader.reader.loadRange(this.lastLoadedPos, endPos); + + this.lastLoadedPos = endPos; + assert(this.lastLoadedPos <= this.fileSize); + + if (this.reader.pos === 0) { + // First time, let's see if there's an ID3 tag + const id3Tag = this.reader.readId3(); + if (id3Tag) { + this.reader.pos += id3Tag.size; + } + } + + this.parseFramesFromLoadedData(); + } finally { + release(); + } + } + + private parseFramesFromLoadedData() { + while (true) { + const startPos = this.reader.pos; + const header = this.reader.readNextFrameHeader(); + if (!header) { + break; + } + + // Check if the entire frame fits in the loaded data + if (header.startPos + header.totalSize > this.lastLoadedPos) { + // Frame doesn't fit, reset positions and stop + this.reader.pos = startPos; + this.lastLoadedPos = startPos; // Snap this back too so that the next read is frame-aligned + + break; + } + + const xingOffset = getXingOffset(header.mpegVersionId, header.channel); + this.reader.pos = header.startPos + xingOffset; + const word = this.reader.readU32(); + const isXing = word === XING || word === 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 = header.audioSamplesInFrame / header.sampleRate; + const sample: Sample = { + timestamp: this.nextTimestampInSamples / header.sampleRate, + duration: sampleDuration, + dataStart: header.startPos, + dataSize: header.totalSize, + }; + + this.loadedSamples.push(sample); + this.nextTimestampInSamples += header.audioSamplesInFrame; + } + } + async getMimeType() { return 'audio/mpeg'; } @@ -109,10 +147,10 @@ export class Mp3Demuxer extends Demuxer { async computeDuration() { await this.readMetadata(); - const lastSample = last(this.allSamples); - assert(lastSample); + const track = this.tracks[0]; + assert(track); - return lastSample.timestamp + lastSample.duration; + return track.computeDuration(); } } @@ -132,8 +170,9 @@ class Mp3AudioTrackBacking implements InputAudioTrackBacking { return this.demuxer.firstFrameHeader.sampleRate / this.demuxer.firstFrameHeader.audioSamplesInFrame; } - computeDuration() { - return this.demuxer.computeDuration(); + async computeDuration() { + const lastPacket = await this.getPacket(Infinity, { metadataOnly: true }); + return (lastPacket?.timestamp ?? 0) + (lastPacket?.duration ?? 0); } getLanguageCode() { @@ -169,7 +208,7 @@ class Mp3AudioTrackBacking implements InputAudioTrackBacking { return null; } - const rawSample = this.demuxer.allSamples[sampleIndex]; + const rawSample = this.demuxer.loadedSamples[sampleIndex]; if (!rawSample) { return null; } @@ -193,12 +232,17 @@ class Mp3AudioTrackBacking implements InputAudioTrackBacking { } async getFirstPacket(options: PacketRetrievalOptions) { + // Ensure we have at least one frame loaded + while (this.demuxer.loadedSamples.length === 0 && this.demuxer.lastLoadedPos < this.demuxer.fileSize) { + await this.demuxer.loadNextChunk(); + } + return this.getPacketAtIndex(0, options); } async getNextPacket(packet: EncodedPacket, options: PacketRetrievalOptions) { const sampleIndex = binarySearchExact( - this.demuxer.allSamples, + this.demuxer.loadedSamples, packet.timestamp, x => x.timestamp, ); @@ -206,16 +250,41 @@ class Mp3AudioTrackBacking implements InputAudioTrackBacking { throw new Error('Packet was not created from this track.'); } - return this.getPacketAtIndex(sampleIndex + 1, options); + const nextIndex = sampleIndex + 1; + // Ensure the next sample exists + while (nextIndex >= this.demuxer.loadedSamples.length && this.demuxer.lastLoadedPos < this.demuxer.fileSize) { + await this.demuxer.loadNextChunk(); + } + + return this.getPacketAtIndex(nextIndex, options); } async getPacket(timestamp: number, options: PacketRetrievalOptions) { - const index = binarySearchLessOrEqual( - this.demuxer.allSamples, - timestamp, - x => x.timestamp, - ); - return this.getPacketAtIndex(index, options); + while (true) { + const index = binarySearchLessOrEqual( + this.demuxer.loadedSamples, + timestamp, + x => x.timestamp, + ); + + if (index === -1 && this.demuxer.loadedSamples.length > 0) { + // We're before the first sample + return null; + } + + if (this.demuxer.lastLoadedPos === this.demuxer.fileSize) { + // All data is loaded, return what we found + return this.getPacketAtIndex(index, options); + } + + if (index >= 0 && index + 1 < this.demuxer.loadedSamples.length) { + // The next packet also exists, we're done + return this.getPacketAtIndex(index, options); + } + + // Otherwise, keep loading data + await this.demuxer.loadNextChunk(); + } } getKeyPacket(timestamp: number, options: PacketRetrievalOptions) {