UNPKG

mediabunny

Version:

Pure TypeScript media toolkit for reading, writing, and converting media files, directly in the browser.

466 lines (378 loc) 11.2 kB
/*! * Copyright (c) 2026-present, Vanilagy and contributors * * This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ import { AudioCodec } from '../codec'; import { Demuxer } from '../demuxer'; import { Input } from '../input'; import { InputAudioTrackBacking } from '../input-track'; import { DEFAULT_TRACK_DISPOSITION, MetadataTags } from '../metadata'; import { PacketRetrievalOptions } from '../media-sink'; import { assert, AsyncMutex, binarySearchExact, binarySearchLessOrEqual, toDataView, UNDETERMINED_LANGUAGE, } from '../misc'; import { EncodedPacket, PLACEHOLDER_DATA } from '../packet'; import { Mp3FrameHeader, getXingOffset, INFO, XING, XingFlags, computeAverageMp3FrameSize, getMp3ChannelCount, } from '../../shared/mp3-misc'; import { ID3_V1_TAG_SIZE, ID3_V2_HEADER_SIZE, parseId3V1Tag, parseId3V2Tag, readId3V2Header, } from '../id3'; import { readNextMp3FrameHeader } from './mp3-reader'; import { readAscii, readBytes, Reader, readU32Be } from '../reader'; type Sample = { timestamp: number; duration: number; dataStart: number; dataSize: number; }; export class Mp3Demuxer extends Demuxer { reader: Reader; metadataPromise: Promise<void> | null = null; firstFrameHeader: Mp3FrameHeader | null = null; firstFrameHeaderPos: number | null = null; loadedSamples: Sample[] = []; // All samples from the start of the file to lastLoadedPos metadataTags: MetadataTags | null = null; xingData: { frameCount: number | null; fileSize: number | null; } | null = null; trackBackings: Mp3AudioTrackBacking[] = []; readingMutex = new AsyncMutex(); lastSampleLoaded = false; lastLoadedPos = 0; nextTimestampInSamples = 0; constructor(input: Input) { super(input); this.reader = input._reader; } async readMetadata() { return this.metadataPromise ??= (async () => { // Keep loading until we find the first frame header while (!this.firstFrameHeader && !this.lastSampleLoaded) { await this.advanceReader(); } if (!this.firstFrameHeader) { throw new Error('No valid MP3 frame found.'); } this.trackBackings = [new Mp3AudioTrackBacking(this)]; })(); } async advanceReader() { if (this.lastLoadedPos === 0) { // Let's skip all ID3v2 tags at the start of the file while (true) { let slice = this.reader.requestSlice(this.lastLoadedPos, ID3_V2_HEADER_SIZE); if (slice instanceof Promise) slice = await slice; if (!slice) { this.lastSampleLoaded = true; return; } const id3V2Header = readId3V2Header(slice); if (!id3V2Header) { break; } this.lastLoadedPos = slice.filePos + id3V2Header.size; } } const result = await readNextMp3FrameHeader( this.reader, this.lastLoadedPos, this.reader.fileSize, this.firstFrameHeader, ); if (!result) { this.lastSampleLoaded = true; return; } const header = result.header; this.lastLoadedPos = result.startPos + header.totalSize - 1; // -1 in case the frame is 1 byte too short const xingOffset = getXingOffset(header.mpegVersionId, header.channel); let slice = this.reader.requestSlice(result.startPos + xingOffset, 4); if (slice instanceof Promise) slice = await slice; if (slice) { const word = readU32Be(slice); const isXing = word === XING || word === INFO; if (isXing) { // There's no actual audio data in this frame, so let's skip it if (!this.xingData) { let xingDataSlice = this.reader.requestSlice(result.startPos + xingOffset + 4, 12); if (xingDataSlice instanceof Promise) xingDataSlice = await xingDataSlice; if (xingDataSlice) { const xingData = readBytes(xingDataSlice, 12); const view = toDataView(xingData); const flags = view.getUint32(0, false); this.xingData = { frameCount: (flags & XingFlags.FrameCount) ? view.getUint32(4, false) : null, fileSize: (flags & XingFlags.FileSize) ? view.getUint32(8, false) : null, }; } } return; } } if (!this.firstFrameHeader) { this.firstFrameHeader = header; this.firstFrameHeaderPos = result.startPos; } const sampleDuration = header.audioSamplesInFrame / this.firstFrameHeader.sampleRate; const sample: Sample = { timestamp: this.nextTimestampInSamples / this.firstFrameHeader.sampleRate, duration: sampleDuration, dataStart: result.startPos, dataSize: header.totalSize, }; this.loadedSamples.push(sample); this.nextTimestampInSamples += header.audioSamplesInFrame; return; } async getMimeType() { return 'audio/mpeg'; } async getTrackBackings() { await this.readMetadata(); return this.trackBackings; } async getMetadataTags() { const release = await this.readingMutex.acquire(); try { await this.readMetadata(); if (this.metadataTags) { return this.metadataTags; } this.metadataTags = {}; let currentPos = 0; let id3V2HeaderFound = false; while (true) { let headerSlice = this.reader.requestSlice(currentPos, ID3_V2_HEADER_SIZE); if (headerSlice instanceof Promise) headerSlice = await headerSlice; if (!headerSlice) break; const id3V2Header = readId3V2Header(headerSlice); if (!id3V2Header) { break; } id3V2HeaderFound = true; let contentSlice = this.reader.requestSlice(headerSlice.filePos, id3V2Header.size); if (contentSlice instanceof Promise) contentSlice = await contentSlice; if (!contentSlice) break; parseId3V2Tag(contentSlice, id3V2Header, this.metadataTags); currentPos = headerSlice.filePos + id3V2Header.size; } if (!id3V2HeaderFound && this.reader.fileSize !== null && this.reader.fileSize >= ID3_V1_TAG_SIZE) { // Try reading an ID3v1 tag at the end of the file let slice = this.reader.requestSlice(this.reader.fileSize - ID3_V1_TAG_SIZE, ID3_V1_TAG_SIZE); if (slice instanceof Promise) slice = await slice; assert(slice); const tag = readAscii(slice, 3); if (tag === 'TAG') { parseId3V1Tag(slice, this.metadataTags); } } return this.metadataTags; } finally { release(); } } } class Mp3AudioTrackBacking implements InputAudioTrackBacking { constructor(public demuxer: Mp3Demuxer) {} getType() { return 'audio' as const; } getId() { return 1; } getNumber() { return 1; } getTimeResolution() { assert(this.demuxer.firstFrameHeader); return this.demuxer.firstFrameHeader.sampleRate / this.demuxer.firstFrameHeader.audioSamplesInFrame; } isRelativeToUnixEpoch() { return false; } getPairingMask() { return 1n; } getBitrate() { return null; } getAverageBitrate() { return null; } async getDurationFromMetadata() { const demuxer = this.demuxer; assert(demuxer.firstFrameHeader !== null); assert(demuxer.firstFrameHeaderPos !== null); if (demuxer.xingData) { if (demuxer.xingData.frameCount !== null) { return demuxer.xingData.frameCount * demuxer.firstFrameHeader.audioSamplesInFrame / demuxer.firstFrameHeader.sampleRate; } } else { // No Xing, assuming CBR if (demuxer.reader.fileSize !== null) { const averageFrameSize = computeAverageMp3FrameSize( demuxer.firstFrameHeader.lowSamplingFrequency, demuxer.firstFrameHeader.layer, demuxer.firstFrameHeader.bitrate, demuxer.firstFrameHeader.sampleRate, ); const frameCount = (demuxer.reader.fileSize - demuxer.firstFrameHeaderPos) / averageFrameSize; return Math.round(frameCount) * demuxer.firstFrameHeader.audioSamplesInFrame / demuxer.firstFrameHeader.sampleRate; } } return null; } async getLiveRefreshInterval() { return null; } getName() { return null; } getLanguageCode() { return UNDETERMINED_LANGUAGE; } getCodec(): AudioCodec { return 'mp3'; } getInternalCodecId() { return null; } getNumberOfChannels() { assert(this.demuxer.firstFrameHeader); return getMp3ChannelCount(this.demuxer.firstFrameHeader.channel); } getSampleRate() { assert(this.demuxer.firstFrameHeader); return this.demuxer.firstFrameHeader.sampleRate; } getDisposition() { return { ...DEFAULT_TRACK_DISPOSITION, }; } async getDecoderConfig(): Promise<AudioDecoderConfig> { assert(this.demuxer.firstFrameHeader); return { codec: 'mp3', numberOfChannels: getMp3ChannelCount(this.demuxer.firstFrameHeader.channel), sampleRate: this.demuxer.firstFrameHeader.sampleRate, }; } async getPacketAtIndex(sampleIndex: number, options: PacketRetrievalOptions) { if (sampleIndex === -1) { return null; } const rawSample = this.demuxer.loadedSamples[sampleIndex]; if (!rawSample) { return null; } let data: Uint8Array; if (options.metadataOnly) { data = PLACEHOLDER_DATA; } else { let slice = this.demuxer.reader.requestSlice(rawSample.dataStart, rawSample.dataSize); if (slice instanceof Promise) slice = await slice; if (!slice) { return null; // Data didn't fit into the rest of the file } data = readBytes(slice, rawSample.dataSize); } return new EncodedPacket( data, 'key', rawSample.timestamp, rawSample.duration, sampleIndex, rawSample.dataSize, ); } getFirstPacket(options: PacketRetrievalOptions) { return this.getPacketAtIndex(0, options); } async getNextPacket(packet: EncodedPacket, options: PacketRetrievalOptions) { const release = await this.demuxer.readingMutex.acquire(); try { const sampleIndex = binarySearchExact( this.demuxer.loadedSamples, packet.timestamp, x => x.timestamp, ); if (sampleIndex === -1) { throw new Error('Packet was not created from this track.'); } const nextIndex = sampleIndex + 1; // Ensure the next sample exists while ( nextIndex >= this.demuxer.loadedSamples.length && !this.demuxer.lastSampleLoaded ) { await this.demuxer.advanceReader(); } return this.getPacketAtIndex(nextIndex, options); } finally { release(); } } async getPacket(timestamp: number, options: PacketRetrievalOptions) { const release = await this.demuxer.readingMutex.acquire(); try { 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.lastSampleLoaded) { // 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.advanceReader(); } } finally { release(); } } getKeyPacket(timestamp: number, options: PacketRetrievalOptions) { return this.getPacket(timestamp, options); } getNextKeyPacket(packet: EncodedPacket, options: PacketRetrievalOptions) { return this.getNextPacket(packet, options); } }