From 0db871dd21b77f5326c622a773780b7bd207b11e Mon Sep 17 00:00:00 2001 From: Vanilagy <1696106+Vanilagy@users.noreply.github.com> Date: Sun, 9 Feb 2025 10:56:54 +0100 Subject: [PATCH] Implement Ogg muxer --- dev/index.html | 7 +- src/codec.ts | 2 + src/index.ts | 1 + src/matroska/matroska-muxer.ts | 3 +- src/misc.ts | 10 + src/mp3/mp3-muxer.ts | 4 + src/ogg/ogg-demuxer.ts | 107 +++------ src/ogg/ogg-misc.ts | 186 +++++++++++++- src/ogg/ogg-muxer.ts | 428 +++++++++++++++++++++++++++++++++ src/ogg/ogg-reader.ts | 8 +- src/ogg/vorbis.ts | 122 ---------- src/output-format.ts | 37 +++ src/wave/wave-muxer.ts | 4 + 13 files changed, 715 insertions(+), 204 deletions(-) create mode 100644 src/ogg/ogg-muxer.ts delete mode 100644 src/ogg/vorbis.ts diff --git a/dev/index.html b/dev/index.html index efd9800..8b74414 100644 --- a/dev/index.html +++ b/dev/index.html @@ -40,6 +40,7 @@ let format = new Metamuxer.MkvOutputFormat({ streamable: false }); format = new Metamuxer.Mp4OutputFormat({ fastStart: 'fragmented' }); // new Metamuxer.MkvOutputFormat();// new Metamuxer.Mp4OutputFormat({ fastStart: false }); + format = new Metamuxer.OggOutputFormat(); let target = new Metamuxer.BufferTarget(); /* @@ -93,9 +94,9 @@ }); let subtitleSource = new Metamuxer.TextSubtitleSource('webvtt'); - output.addVideoTrack(videoSource, { languageCode: 'eng' }); + //output.addVideoTrack(videoSource, { languageCode: 'eng' }); output.addAudioTrack(audioSource); - output.addSubtitleTrack(subtitleSource); + //output.addSubtitleTrack(subtitleSource); output.start(); @@ -161,7 +162,7 @@ Testing... <00:17.350>One... <00:18.125>Two... context.fillStyle = ['red', 'green', 'blue', 'yellow'][i % 4]; context.fillRect(canvas.width * Math.random(), canvas.height * Math.random(), canvas.width * Math.random(), canvas.height * Math.random()); - await videoSource.digest(i / 10, 1 / 10); + //await videoSource.digest(i / 10, 1 / 10); } let audioContext = new AudioContext(); diff --git a/src/codec.ts b/src/codec.ts index 89516e4..d925ad7 100644 --- a/src/codec.ts +++ b/src/codec.ts @@ -991,6 +991,8 @@ export const parseOpusIdentificationHeader = (bytes: Uint8Array) => { }; }; +export const OPUS_INTERNAL_SAMPLE_RATE = 48000; + // From https://datatracker.ietf.org/doc/html/rfc6716, in 48 kHz samples const OPUS_FRAME_DURATION_TABLE = [ 480, 960, 1920, 2880, diff --git a/src/index.ts b/src/index.ts index a6d3208..84a0be1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -20,6 +20,7 @@ export { WebMOutputFormatOptions, Mp3OutputFormat, WaveOutputFormat, + OggOutputFormat, TrackCountLimits, InclusiveRange, } from './output-format'; diff --git a/src/matroska/matroska-muxer.ts b/src/matroska/matroska-muxer.ts index 75c457c..1c1b62b 100644 --- a/src/matroska/matroska-muxer.ts +++ b/src/matroska/matroska-muxer.ts @@ -31,6 +31,7 @@ import { parseSubtitleTimestamp, } from '../subtitles'; import { + OPUS_INTERNAL_SAMPLE_RATE, PCM_AUDIO_CODECS, PcmAudioCodec, generateAv1CodecConfigurationFromCodecString, @@ -233,7 +234,7 @@ export class MatroskaMuxer extends Muxer { const header = parseOpusIdentificationHeader(bytes); // Use the preSkip value from the header - seekPreRollNs = Math.round(1e9 * (header.preSkip / 48000)); + seekPreRollNs = Math.round(1e9 * (header.preSkip / OPUS_INTERNAL_SAMPLE_RATE)); } } diff --git a/src/misc.ts b/src/misc.ts index 428e823..623cfb6 100644 --- a/src/misc.ts +++ b/src/misc.ts @@ -329,6 +329,16 @@ export const setInt24 = (view: DataView, byteOffset: number, value: number, litt setUint24(view, byteOffset, value, littleEndian); }; +export const setInt64 = (view: DataView, byteOffset: number, value: number, littleEndian: boolean) => { + if (littleEndian) { + view.setUint32(byteOffset + 0, value, true); + view.setInt32(byteOffset + 4, Math.floor(value / 2 ** 32), true); + } else { + view.setInt32(byteOffset + 0, Math.floor(value / 2 ** 32), true); + view.setUint32(byteOffset + 4, value, true); + } +}; + /** * Calls a function on each value spat out by an async generator. The reason for writing this manually instead of * using a generator function is that the generator function queues return() calls - here, we forward them immediately. diff --git a/src/mp3/mp3-muxer.ts b/src/mp3/mp3-muxer.ts index 4d5f5ad..29176c5 100644 --- a/src/mp3/mp3-muxer.ts +++ b/src/mp3/mp3-muxer.ts @@ -101,6 +101,8 @@ export class Mp3Muxer extends Muxer { return; } + const release = await this.mutex.acquire(); + const endPos = this.writer.getPos(); this.writer.seek(0); @@ -121,5 +123,7 @@ export class Mp3Muxer extends Muxer { this.mp3Writer.writeXingFrame(this.xingFrameData); this.writer.seek(endPos); + + release(); } } diff --git a/src/ogg/ogg-demuxer.ts b/src/ogg/ogg-demuxer.ts index 684f18d..a787cf3 100644 --- a/src/ogg/ogg-demuxer.ts +++ b/src/ogg/ogg-demuxer.ts @@ -1,29 +1,21 @@ -import { AudioCodec, parseOpusIdentificationHeader, parseOpusTocByte } from '../codec'; +import { OPUS_INTERNAL_SAMPLE_RATE, parseOpusIdentificationHeader } from '../codec'; import { Demuxer } from '../demuxer'; import { Input } from '../input'; import { InputAudioTrack, InputAudioTrackBacking } from '../input-track'; import { SampleRetrievalOptions } from '../media-sink'; -import { assert, findLast, ilog, roundToPrecision, toDataView, UNDETERMINED_LANGUAGE } from '../misc'; +import { assert, findLast, roundToPrecision, toDataView, UNDETERMINED_LANGUAGE } from '../misc'; import { Reader } from '../reader'; import { EncodedAudioSample, PLACEHOLDER_DATA } from '../sample'; -import { computeOggPageCrc } from './ogg-misc'; +import { computeOggPageCrc, extractSampleMetadata, OggCodecInfo, parseModesFromVorbisSetupPacket } from './ogg-misc'; import { MAX_PAGE_HEADER_SIZE, MAX_PAGE_SIZE, MIN_PAGE_HEADER_SIZE, OggReader, Page } from './ogg-reader'; -import { parseModesFromSetupPacket } from './vorbis'; type LogicalBitstream = { serialNumber: number; bosPage: Page; - codec: AudioCodec | null; description: Uint8Array | null; numberOfChannels: number; sampleRate: number; - vorbisInfo: { - blocksizes: number[]; - modeBlockflags: number[]; - } | null; - opusInfo: { - preSkip: number; - } | null; + codecInfo: OggCodecInfo; lastMetadataPacket: Packet | null; }; @@ -74,12 +66,14 @@ export class OggDemuxer extends Demuxer { this.bitstreams.push({ serialNumber: page.serialNumber, bosPage: page, - codec: null, description: null, numberOfChannels: -1, sampleRate: -1, - vorbisInfo: null, - opusInfo: null, + codecInfo: { + codec: null, + vorbisInfo: null, + opusInfo: null, + }, lastMetadataPacket: null, }); @@ -119,7 +113,7 @@ export class OggDemuxer extends Demuxer { await this.readOpusMetadata(firstPacket, bitstream); } - if (bitstream.codec !== null) { + if (bitstream.codecInfo.codec !== null) { this.tracks.push(new InputAudioTrack(new OggAudioTrackBacking(bitstream, this))); } } @@ -194,7 +188,7 @@ export class OggDemuxer extends Demuxer { thirdPacket.data, 1 + lacingValues.length + firstPacket.data.length + secondPacket.data.length, ); - bitstream.codec = 'vorbis'; + bitstream.codecInfo.codec = 'vorbis'; bitstream.description = description; bitstream.lastMetadataPacket = thirdPacket; @@ -203,12 +197,12 @@ export class OggDemuxer extends Demuxer { bitstream.sampleRate = view.getUint32(12, true); const blockSizeByte = view.getUint8(28); - bitstream.vorbisInfo = { + bitstream.codecInfo.vorbisInfo = { blocksizes: [ 1 << (blockSizeByte & 0xf), 1 << (blockSizeByte >> 4), ], - modeBlockflags: parseModesFromSetupPacket(thirdPacket.data).modeBlockflags, + modeBlockflags: parseModesFromVorbisSetupPacket(thirdPacket.data).modeBlockflags, }; } @@ -232,7 +226,7 @@ export class OggDemuxer extends Demuxer { // We don't make use of the comment header's data - bitstream.codec = 'opus'; + bitstream.codecInfo.codec = 'opus'; bitstream.description = firstPacket.data; bitstream.lastMetadataPacket = secondPacket; @@ -240,7 +234,7 @@ export class OggDemuxer extends Demuxer { bitstream.numberOfChannels = header.outputChannelCount; bitstream.sampleRate = header.inputSampleRate; - bitstream.opusInfo = { + bitstream.codecInfo.opusInfo = { preSkip: header.preSkip, }; } @@ -402,8 +396,10 @@ class OggAudioTrackBacking implements InputAudioTrackBacking { sampleToMetadata = new WeakMap(); constructor(public bitstream: LogicalBitstream, public demuxer: OggDemuxer) { - // Opus always uses 48000 for its internal calculations, even if the actual sample rate is different - this.internalSampleRate = bitstream.codec === 'opus' ? 48000 : bitstream.sampleRate; + // Opus always uses a fixed sample rate for its internal calculations, even if the actual rate is different + this.internalSampleRate = bitstream.codecInfo.codec === 'opus' + ? OPUS_INTERNAL_SAMPLE_RATE + : bitstream.sampleRate; } getId() { @@ -423,14 +419,14 @@ class OggAudioTrackBacking implements InputAudioTrackBacking { } getCodec() { - return this.bitstream.codec; + return this.bitstream.codecInfo.codec; } async getDecoderConfig(): Promise { - assert(this.bitstream.codec); + assert(this.bitstream.codecInfo.codec); return { - codec: this.bitstream.codec, + codec: this.bitstream.codecInfo.codec, numberOfChannels: this.bitstream.numberOfChannels, sampleRate: this.bitstream.sampleRate, description: this.bitstream.description ?? undefined, @@ -451,9 +447,9 @@ class OggAudioTrackBacking implements InputAudioTrackBacking { } granulePositionToTimestampInSamples(granulePosition: number) { - if (this.bitstream.codec === 'opus') { - assert(this.bitstream.opusInfo); - return granulePosition - this.bitstream.opusInfo.preSkip; + if (this.bitstream.codecInfo.codec === 'opus') { + assert(this.bitstream.codecInfo.opusInfo); + return granulePosition - this.bitstream.codecInfo.opusInfo.preSkip; } return granulePosition; @@ -471,46 +467,11 @@ class OggAudioTrackBacking implements InputAudioTrackBacking { return null; } - let durationInSamples = 0; - let currentBlocksize: number | null = null; - - if (packet.data.length > 0) { - // To know sample duration, we'll need to peak inside the packet - - if (this.bitstream.codec === 'vorbis') { - assert(this.bitstream.vorbisInfo); - - const vorbisModeCount = this.bitstream.vorbisInfo.modeBlockflags.length; - const bitCount = ilog(vorbisModeCount - 1); - const modeMask = ((1 << bitCount) - 1) << 1; - const modeNumber = (packet.data[0]! & modeMask) >> 1; - - if (modeNumber >= this.bitstream.vorbisInfo.modeBlockflags.length) { - throw new Error('Invalid mode number.'); - } - - // In Vorbis, packet duration also depends on the blocksize of the previous packet - let prevBlocksize = additional.vorbisLastBlocksize; - - const blockflag = this.bitstream.vorbisInfo.modeBlockflags[modeNumber]!; - currentBlocksize = this.bitstream.vorbisInfo.blocksizes[blockflag]!; - - if (blockflag === 1) { - const prevMask = (modeMask | 0x1) + 1; - const flag = packet.data[0]! & prevMask ? 1 : 0; - prevBlocksize = this.bitstream.vorbisInfo.blocksizes[flag]!; - } - - durationInSamples = prevBlocksize !== null - ? (prevBlocksize + currentBlocksize) >> 2 - : 0; // The first sample outputs no audio data and therefore has a duration of 0 - } else if (this.bitstream.codec === 'opus') { - assert(this.bitstream.opusInfo); - - const toc = parseOpusTocByte(packet.data); - durationInSamples = toc.durationInSamples; - } - } + const { durationInSamples, vorbisBlockSize } = extractSampleMetadata( + packet.data, + this.bitstream.codecInfo, + additional.vorbisLastBlocksize, + ); const sample = new EncodedAudioSample( options.metadataOnly ? PLACEHOLDER_DATA : packet.data, @@ -523,7 +484,7 @@ class OggAudioTrackBacking implements InputAudioTrackBacking { packet, timestampInSamples: additional.timestampInSamples, durationInSamples, - vorbisBlockSize: currentBlocksize, + vorbisBlockSize, }); return sample; } @@ -539,9 +500,9 @@ class OggAudioTrackBacking implements InputAudioTrackBacking { } let timestampInSamples = 0; - if (this.bitstream.codec === 'opus') { - assert(this.bitstream.opusInfo); - timestampInSamples -= this.bitstream.opusInfo.preSkip; + if (this.bitstream.codecInfo.codec === 'opus') { + assert(this.bitstream.codecInfo.opusInfo); + timestampInSamples -= this.bitstream.codecInfo.opusInfo.preSkip; } const packet = await this.demuxer.readPacket( diff --git a/src/ogg/ogg-misc.ts b/src/ogg/ogg-misc.ts index c8ec6a7..1185239 100644 --- a/src/ogg/ogg-misc.ts +++ b/src/ogg/ogg-misc.ts @@ -1,4 +1,7 @@ -import { toDataView } from '../misc'; +import { parseOpusTocByte } from '../codec'; +import { assert, ilog, readBits, toDataView } from '../misc'; + +export const OGGS = 0x5367674f; // 'OggS' const OGG_CRC_POLYNOMIAL = 0x04c11db7; const OGG_CRC_TABLE = new Uint32Array(256); @@ -30,3 +33,184 @@ export const computeOggPageCrc = (bytes: Uint8Array) => { return crc; }; + +export type OggCodecInfo = { + codec: 'vorbis' | 'opus' | null; + vorbisInfo: { + blocksizes: number[]; + modeBlockflags: number[]; + } | null; + opusInfo: { + preSkip: number; + } | null; +}; + +export const extractSampleMetadata = ( + data: Uint8Array, + codecInfo: OggCodecInfo, + vorbisLastBlocksize: number | null, +) => { + let durationInSamples = 0; + let currentBlocksize: number | null = null; + + if (data.length > 0) { + // To know sample duration, we'll need to peak inside the packet + if (codecInfo.codec === 'vorbis') { + assert(codecInfo.vorbisInfo); + + const vorbisModeCount = codecInfo.vorbisInfo.modeBlockflags.length; + const bitCount = ilog(vorbisModeCount - 1); + const modeMask = ((1 << bitCount) - 1) << 1; + const modeNumber = (data[0]! & modeMask) >> 1; + + if (modeNumber >= codecInfo.vorbisInfo.modeBlockflags.length) { + throw new Error('Invalid mode number.'); + } + + // In Vorbis, packet duration also depends on the blocksize of the previous packet + let prevBlocksize = vorbisLastBlocksize; + + const blockflag = codecInfo.vorbisInfo.modeBlockflags[modeNumber]!; + currentBlocksize = codecInfo.vorbisInfo.blocksizes[blockflag]!; + + if (blockflag === 1) { + const prevMask = (modeMask | 0x1) + 1; + const flag = data[0]! & prevMask ? 1 : 0; + prevBlocksize = codecInfo.vorbisInfo.blocksizes[flag]!; + } + + durationInSamples = prevBlocksize !== null + ? (prevBlocksize + currentBlocksize) >> 2 + : 0; // The first sample outputs no audio data and therefore has a duration of 0 + } else if (codecInfo.codec === 'opus') { + const toc = parseOpusTocByte(data); + durationInSamples = toc.durationInSamples; + } + } + + return { + durationInSamples, + vorbisBlockSize: currentBlocksize, + }; +}; + +// Based on vorbis_parser.c from FFmpeg. +export const parseModesFromVorbisSetupPacket = (setupHeader: Uint8Array) => { + class Bitstream { + bytes: Uint8Array; + pos: number; + + constructor(bytes: Uint8Array) { + this.bytes = bytes; + this.pos = 0; + } + + read(n: number): number { + const result = readBits(this.bytes, this.pos, this.pos + n); + this.pos += n; + return result; + } + + skip(n: number): void { + this.pos += n; + } + + getBitsLeft(): number { + return this.bytes.length * 8 - this.pos; + } + + getBitCount(): number { + return this.pos; + } + + clone(): Bitstream { + const clone = new Bitstream(this.bytes); + clone.pos = this.pos; + return clone; + } + } + + // Verify that this is a Setup header. + if (setupHeader.length < 7) { + throw new Error('Setup header is too short.'); + } + if (setupHeader[0] !== 5) { + throw new Error('Wrong packet type in Setup header.'); + } + const signature = String.fromCharCode(...setupHeader.slice(1, 7)); + if (signature !== 'vorbis') { + throw new Error('Invalid packet signature in Setup header.'); + } + + // Reverse the entire buffer. + const bufSize = setupHeader.length; + const revBuffer = new Uint8Array(bufSize); + for (let i = 0; i < bufSize; i++) { + revBuffer[i] = setupHeader[bufSize - 1 - i]!; + } + + // Initialize a Bitstream on the reversed buffer. + const bs = new Bitstream(revBuffer); + + // --- Find the framing bit. + // In FFmpeg code, we scan until get_bits1() returns 1. + let gotFramingBit = 0; + while (bs.getBitsLeft() > 97) { + if (bs.read(1) === 1) { + gotFramingBit = bs.getBitCount(); + break; + } + } + if (gotFramingBit === 0) { + throw new Error('Invalid Setup header: framing bit not found.'); + } + + // --- Search backwards for a valid mode header. + // We try to “guess” the number of modes by reading a fixed pattern. + let modeCount = 0; + let gotModeHeader = false; + let lastModeCount = 0; + while (bs.getBitsLeft() >= 97) { + const tempPos = bs.pos; + const a = bs.read(8); + const b = bs.read(16); + const c = bs.read(16); + // If a > 63 or b or c nonzero, assume we’ve gone too far. + if (a > 63 || b !== 0 || c !== 0) { + bs.pos = tempPos; + break; + } + bs.skip(1); + modeCount++; + if (modeCount > 64) + break; + const bsClone = bs.clone(); + const candidate = bsClone.read(6) + 1; + if (candidate === modeCount) { + gotModeHeader = true; + lastModeCount = modeCount; + } + } + if (!gotModeHeader) { + throw new Error('Invalid Setup header: mode header not found.'); + } + if (lastModeCount > 63) { + throw new Error(`Unsupported mode count: ${lastModeCount}.`); + } + const finalModeCount = lastModeCount; + + // --- Reinitialize the bitstream. + bs.pos = 0; + // Skip the bits up to the found framing bit. + bs.skip(gotFramingBit); + + // --- Now read, for each mode (in reverse order), 40 bits then one bit. + // That one bit is the mode blockflag. + const modeBlockflags = Array(finalModeCount).fill(0) as number[]; + for (let i = finalModeCount - 1; i >= 0; i--) { + bs.skip(40); + modeBlockflags[i] = bs.read(1); + } + + return { modeBlockflags }; +}; diff --git a/src/ogg/ogg-muxer.ts b/src/ogg/ogg-muxer.ts new file mode 100644 index 0000000..cc86580 --- /dev/null +++ b/src/ogg/ogg-muxer.ts @@ -0,0 +1,428 @@ +import { OPUS_INTERNAL_SAMPLE_RATE, parseOpusIdentificationHeader } from '../codec'; +import { assert, setInt64, toDataView, toUint8Array } from '../misc'; +import { Muxer } from '../muxer'; +import { Output, OutputAudioTrack } from '../output'; +import { EncodedAudioSample } from '../sample'; +import { Writer } from '../writer'; +import { + computeOggPageCrc, + extractSampleMetadata, + OggCodecInfo, + OGGS, + parseModesFromVorbisSetupPacket, +} from './ogg-misc'; +import { MAX_PAGE_SIZE } from './ogg-reader'; + +const PAGE_SIZE_TARGET = 8192; + +type OggTrackData = { + track: OutputAudioTrack; + serialNumber: number; + internalSampleRate: number; + codecInfo: OggCodecInfo; + vorbisLastBlocksize: number | null; + packetQueue: Packet[]; + currentTimestampInSamples: number; + pagesWritten: number; + + currentGranulePosition: number; + currentLacingValues: number[]; + currentPageData: Uint8Array[]; + currentPageSize: number; + currentPageStartsWithFreshPacket: boolean; +}; + +type Packet = { + data: Uint8Array; + endGranulePosition: number; + timestamp: number; + forcePageFlush: boolean; +}; + +export class OggMuxer extends Muxer { + private writer: Writer; + + private trackDatas: OggTrackData[] = []; + private bosPagesWritten = false; + + private pageBytes = new Uint8Array(MAX_PAGE_SIZE); + private pageView = new DataView(this.pageBytes.buffer); + + constructor(output: Output) { + super(output); + + this.writer = output._writer; + } + + async start() { + // Nothin' + } + + addEncodedVideoSample(): never { + throw new Error('Video tracks are not supported.'); + } + + private getTrackData(track: OutputAudioTrack, meta?: EncodedAudioChunkMetadata) { + const existingTrackData = this.trackDatas.find(td => td.track === track); + if (existingTrackData) { + return existingTrackData; + } + + // Give the track a unique random serial number + let serialNumber: number; + do { + serialNumber = Math.floor(2 ** 32 * Math.random()); + } while (this.trackDatas.some(td => td.serialNumber === serialNumber)); + + assert(track.source._codec === 'vorbis' || track.source._codec === 'opus'); + + assert(meta); + assert(meta.decoderConfig); + assert(meta.decoderConfig.sampleRate); + + const newTrackData: OggTrackData = { + track, + serialNumber, + internalSampleRate: track.source._codec === 'opus' + ? OPUS_INTERNAL_SAMPLE_RATE + : meta.decoderConfig.sampleRate, + codecInfo: { + codec: track.source._codec, + vorbisInfo: null, + opusInfo: null, + }, + vorbisLastBlocksize: null, + packetQueue: [], + currentTimestampInSamples: 0, + pagesWritten: 0, + + currentGranulePosition: 0, + currentLacingValues: [], + currentPageData: [], + currentPageSize: 27, + currentPageStartsWithFreshPacket: true, + }; + + this.queueHeaderPackets(newTrackData, meta); + + this.trackDatas.push(newTrackData); + return newTrackData; + } + + private queueHeaderPackets(trackData: OggTrackData, meta: EncodedAudioChunkMetadata) { + assert(meta.decoderConfig); + + if (trackData.track.source._codec === 'vorbis') { + assert(meta.decoderConfig.description); + + const bytes = toUint8Array(meta.decoderConfig.description); + if (bytes[0] !== 2) { + throw new TypeError('First byte of Vorbis decoder description must be 2.'); + } + + let pos = 1; + const readPacketLength = () => { + let length = 0; + + while (true) { + const value = bytes[pos++]; + if (value === undefined) { + throw new TypeError('Vorbis decoder description is too short.'); + } + + length += value; + + if (value < 255) { + return length; + } + } + }; + + const identificationHeaderLength = readPacketLength(); + const commentHeaderLength = readPacketLength(); + const setupHeaderLength = bytes.length - pos; // Setup header fills the remaining bytes + + if (setupHeaderLength <= 0) { + throw new TypeError('Vorbis decoder description is too short.'); + } + + const identificationHeader = bytes.subarray(pos, pos += identificationHeaderLength); + const commentHeader = bytes.subarray(pos, pos += commentHeaderLength); + const setupHeader = bytes.subarray(pos); + + trackData.packetQueue.push({ + data: identificationHeader, + endGranulePosition: 0, + timestamp: 0, + forcePageFlush: true, + }, { + data: commentHeader, + endGranulePosition: 0, + timestamp: 0, + forcePageFlush: false, + }, { + data: setupHeader, + endGranulePosition: 0, + timestamp: 0, + forcePageFlush: true, // The last header packet must flush the page + }); + + const view = toDataView(identificationHeader); + const blockSizeByte = view.getUint8(28); + + trackData.codecInfo.vorbisInfo = { + blocksizes: [ + 1 << (blockSizeByte & 0xf), + 1 << (blockSizeByte >> 4), + ], + modeBlockflags: parseModesFromVorbisSetupPacket(setupHeader).modeBlockflags, + }; + } else if (trackData.track.source._codec === 'opus') { + if (!meta.decoderConfig.description) { + throw new TypeError('For Ogg, Opus decoder description is required.'); + } + + const identificationHeader = toUint8Array(meta.decoderConfig.description); + + const commentHeader = new Uint8Array(8 + 4 + 4); + const view = new DataView(commentHeader.buffer); + view.setUint32(0, 0x4f707573, false); // 'Opus' + view.setUint32(4, 0x54616773, false); // 'Tags' + view.setUint32(8, 0, true); // Vendor String Length + view.setUint32(12, 0, true); // User Comment List Length + + trackData.packetQueue.push({ + data: identificationHeader, + endGranulePosition: 0, + timestamp: 0, + forcePageFlush: true, + }, { + data: commentHeader, + endGranulePosition: 0, + timestamp: 0, + forcePageFlush: true, // The last header packet must flush the page + }); + + trackData.codecInfo.opusInfo = { + preSkip: parseOpusIdentificationHeader(identificationHeader).preSkip, + }; + } + } + + async addEncodedAudioSample(track: OutputAudioTrack, sample: EncodedAudioSample, meta?: EncodedAudioChunkMetadata) { + const release = await this.mutex.acquire(); + + try { + const trackData = this.getTrackData(track, meta); + + this.validateAndNormalizeTimestamp(trackData.track, sample.timestamp, sample.type === 'key'); + + const currentTimestampInSamples = trackData.currentTimestampInSamples; + + const { durationInSamples, vorbisBlockSize } = extractSampleMetadata( + sample.data, + trackData.codecInfo, + trackData.vorbisLastBlocksize, + ); + trackData.currentTimestampInSamples += durationInSamples; + trackData.vorbisLastBlocksize = vorbisBlockSize; + + trackData.packetQueue.push({ + data: sample.data, + endGranulePosition: trackData.currentTimestampInSamples, + timestamp: currentTimestampInSamples / trackData.internalSampleRate, + forcePageFlush: false, + }); + + await this.interleavePages(); + } finally { + release(); + } + } + + addSubtitleCue(): never { + throw new Error('Subtitle tracks are not supported.'); + } + + async interleavePages(isFinalCall = false) { + if (!this.bosPagesWritten) { + for (const track of this.output._tracks) { + if (!track.source._closed && !this.trackDatas.some(x => x.track === track)) { + return; // We haven't seen a sample from this open track yet + } + } + + // Write the header page for all bitstreams + for (const trackData of this.trackDatas) { + while (trackData.packetQueue.length > 0) { + const packet = trackData.packetQueue.shift()!; + this.writePacket(trackData, packet, false); + + if (packet.forcePageFlush) { + // We say the header page ends once the first packet is encountered that forces a page flush + break; + } + } + } + + this.bosPagesWritten = true; + } + + outer: + while (true) { + let trackWithMinTimestamp: OggTrackData | null = null; + let minTimestamp = Infinity; + + for (const trackData of this.trackDatas) { + if ( + !isFinalCall + && trackData.packetQueue.length <= 1 // Limit is 1, not 0, for correct EOS flag logic + && !trackData.track.source._closed + ) { + break outer; + } + + if ( + trackData.packetQueue.length > 0 + && trackData.packetQueue[0]!.timestamp < minTimestamp + ) { + trackWithMinTimestamp = trackData; + minTimestamp = trackData.packetQueue[0]!.timestamp; + } + } + + if (!trackWithMinTimestamp) { + break; + } + + const packet = trackWithMinTimestamp.packetQueue.shift()!; + const isFinalPacket = trackWithMinTimestamp.packetQueue.length === 0; + + this.writePacket(trackWithMinTimestamp, packet, isFinalPacket); + } + + if (!isFinalCall) { + await this.writer.flush(); + } + } + + writePacket(trackData: OggTrackData, packet: Packet, isFinalPacket: boolean) { + let remainingLength = packet.data.length; + let dataStartOffset = 0; + let dataOffset = 0; + + while (true) { + if (trackData.currentLacingValues.length === 0 && dataStartOffset > 0) { + // This is a packet spanning multiple pages + trackData.currentPageStartsWithFreshPacket = false; + } + + const segmentSize = Math.min(255, remainingLength); + trackData.currentLacingValues.push(segmentSize); + trackData.currentPageSize++; + dataOffset += segmentSize; + + const segmentIsLastOfPacket = remainingLength < 255; + + if (trackData.currentLacingValues.length === 255) { + // The page is full, we need to add part of the packet data and then flush the page + const slice = packet.data.subarray(dataStartOffset, dataOffset); + dataStartOffset = dataOffset; + trackData.currentPageData.push(slice); + trackData.currentPageSize += slice.length; + + this.writePage(trackData, isFinalPacket && segmentIsLastOfPacket); + + if (segmentIsLastOfPacket) { + return; + } + } + + if (segmentIsLastOfPacket) { + break; + } + remainingLength -= 255; + } + + const slice = packet.data.subarray(dataStartOffset); + trackData.currentPageData.push(slice); + trackData.currentPageSize += slice.length; + trackData.currentGranulePosition = packet.endGranulePosition; + + if (trackData.currentPageSize >= PAGE_SIZE_TARGET || packet.forcePageFlush) { + this.writePage(trackData, isFinalPacket); + } + } + + writePage(trackData: OggTrackData, isEos: boolean) { + this.pageView.setUint32(0, OGGS, true); // Capture pattern + this.pageView.setUint8(4, 0); // Version + + let headerType = 0; + if (!trackData.currentPageStartsWithFreshPacket) { + headerType |= 1; + } + if (trackData.pagesWritten === 0) { + headerType |= 2; // Beginning of stream + } + if (isEos) { + headerType |= 4; // End of stream + } + this.pageView.setUint8(5, headerType); // Header type + + const granulePosition = trackData.currentLacingValues.every(x => x === 255) + ? -1 // No packets end on this page + : trackData.currentGranulePosition; + setInt64(this.pageView, 6, granulePosition, true); // Granule position + + this.pageView.setUint32(14, trackData.serialNumber, true); // Serial number + this.pageView.setUint32(18, trackData.pagesWritten, true); // Page sequence number + this.pageView.setUint32(22, 0, true); // Checksum placeholder + + this.pageView.setUint8(26, trackData.currentLacingValues.length); // Number of page segments + this.pageBytes.set(trackData.currentLacingValues, 27); + + let pos = 27 + trackData.currentLacingValues.length; + for (const data of trackData.currentPageData) { + this.pageBytes.set(data, pos); + pos += data.length; + } + + const slice = this.pageBytes.subarray(0, pos); + + const crc = computeOggPageCrc(slice); + this.pageView.setUint32(22, crc, true); // Checksum + + trackData.pagesWritten++; + trackData.currentLacingValues.length = 0; + trackData.currentPageData.length = 0; + trackData.currentPageSize = 27; + trackData.currentPageStartsWithFreshPacket = true; + + this.writer.write(slice); + } + + // eslint-disable-next-line @typescript-eslint/no-misused-promises + override async onTrackClose() { + const release = await this.mutex.acquire(); + + // Since a track is now closed, we may be able to write out chunks that were previously waiting + await this.interleavePages(); + + release(); + } + + async finalize() { + const release = await this.mutex.acquire(); + + await this.interleavePages(true); + + for (const trackData of this.trackDatas) { + if (trackData.currentLacingValues.length > 0) { + this.writePage(trackData, true); + } + } + + release(); + } +} diff --git a/src/ogg/ogg-reader.ts b/src/ogg/ogg-reader.ts index fc3ec56..118b59e 100644 --- a/src/ogg/ogg-reader.ts +++ b/src/ogg/ogg-reader.ts @@ -1,6 +1,6 @@ import { Reader } from '../reader'; +import { OGGS } from './ogg-misc'; -const OGGS = 0x5367674f; // 'OggS' export const MIN_PAGE_HEADER_SIZE = 27; export const MAX_PAGE_HEADER_SIZE = 27 + 255; export const MAX_PAGE_SIZE = MAX_PAGE_HEADER_SIZE + 255 * 255; @@ -15,7 +15,7 @@ export type Page = { serialNumber: number; sequenceNumber: number; checksum: number; - lacingValues: number[]; + lacingValues: Uint8Array; }; export class OggReader { @@ -83,10 +83,10 @@ export class OggReader { const checksum = this.readU32(); const numberPageSegments = this.readU8(); - const lacingValues: number[] = []; + const lacingValues = new Uint8Array(numberPageSegments); for (let i = 0; i < numberPageSegments; i++) { - lacingValues.push(this.readU8()); + lacingValues[i] = this.readU8(); } const headerSize = 27 + numberPageSegments; diff --git a/src/ogg/vorbis.ts b/src/ogg/vorbis.ts deleted file mode 100644 index 4d52cdd..0000000 --- a/src/ogg/vorbis.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { readBits } from '../misc'; - -class Bitstream { - bytes: Uint8Array; - pos: number; - - constructor(bytes: Uint8Array) { - this.bytes = bytes; - this.pos = 0; - } - - read(n: number): number { - const result = readBits(this.bytes, this.pos, this.pos + n); - this.pos += n; - return result; - } - - skip(n: number): void { - this.pos += n; - } - - getBitsLeft(): number { - return this.bytes.length * 8 - this.pos; - } - - getBitCount(): number { - return this.pos; - } - - clone(): Bitstream { - const clone = new Bitstream(this.bytes); - clone.pos = this.pos; - return clone; - } -} - -// Based on vorbis_parser.c from FFmpeg. -export const parseModesFromSetupPacket = (setupHeader: Uint8Array) => { - // Verify that this is a Setup header. - if (setupHeader.length < 7) { - throw new Error('Setup header is too short.'); - } - if (setupHeader[0] !== 5) { - throw new Error('Wrong packet type in Setup header.'); - } - const signature = String.fromCharCode(...setupHeader.slice(1, 7)); - if (signature !== 'vorbis') { - throw new Error('Invalid packet signature in Setup header.'); - } - - // Reverse the entire buffer. - const bufSize = setupHeader.length; - const revBuffer = new Uint8Array(bufSize); - for (let i = 0; i < bufSize; i++) { - revBuffer[i] = setupHeader[bufSize - 1 - i]!; - } - - // Initialize a Bitstream on the reversed buffer. - const bs = new Bitstream(revBuffer); - - // --- Find the framing bit. - // In FFmpeg code, we scan until get_bits1() returns 1. - let gotFramingBit = 0; - while (bs.getBitsLeft() > 97) { - if (bs.read(1) === 1) { - gotFramingBit = bs.getBitCount(); - break; - } - } - if (gotFramingBit === 0) { - throw new Error('Invalid Setup header: framing bit not found.'); - } - - // --- Search backwards for a valid mode header. - // We try to “guess” the number of modes by reading a fixed pattern. - let modeCount = 0; - let gotModeHeader = false; - let lastModeCount = 0; - while (bs.getBitsLeft() >= 97) { - const tempPos = bs.pos; - const a = bs.read(8); - const b = bs.read(16); - const c = bs.read(16); - // If a > 63 or b or c nonzero, assume we’ve gone too far. - if (a > 63 || b !== 0 || c !== 0) { - bs.pos = tempPos; - break; - } - bs.skip(1); - modeCount++; - if (modeCount > 64) - break; - const bsClone = bs.clone(); - const candidate = bsClone.read(6) + 1; - if (candidate === modeCount) { - gotModeHeader = true; - lastModeCount = modeCount; - } - } - if (!gotModeHeader) { - throw new Error('Invalid Setup header: mode header not found.'); - } - if (lastModeCount > 63) { - throw new Error(`Unsupported mode count: ${lastModeCount}.`); - } - const finalModeCount = lastModeCount; - - // --- Reinitialize the bitstream. - bs.pos = 0; - // Skip the bits up to the found framing bit. - bs.skip(gotFramingBit); - - // --- Now read, for each mode (in reverse order), 40 bits then one bit. - // That one bit is the mode blockflag. - const modeBlockflags = Array(finalModeCount).fill(0) as number[]; - for (let i = finalModeCount - 1; i >= 0; i--) { - bs.skip(40); - modeBlockflags[i] = bs.read(1); - } - - return { modeBlockflags }; -}; diff --git a/src/output-format.ts b/src/output-format.ts index 0327d7f..f67a3c0 100644 --- a/src/output-format.ts +++ b/src/output-format.ts @@ -13,6 +13,7 @@ import { IsobmffMuxer } from './isobmff/isobmff-muxer'; import { MatroskaMuxer } from './matroska/matroska-muxer'; import { Mp3Muxer } from './mp3/mp3-muxer'; import { Muxer } from './muxer'; +import { OggMuxer } from './ogg/ogg-muxer'; import { Output, TrackType } from './output'; import { WaveMuxer } from './wave/wave-muxer'; @@ -328,3 +329,39 @@ export class WaveOutputFormat extends OutputFormat { ]; } } + +/** @public */ +export class OggOutputFormat extends OutputFormat { + /** @internal */ + _createMuxer(output: Output) { + return new OggMuxer(output); + } + + /** @internal */ + _getName() { + return 'Ogg'; + } + + getSupportedTrackCounts(): TrackCountLimits { + return { + video: { min: 0, max: 0 }, + audio: { min: 0, max: Infinity }, + subtitle: { min: 0, max: 0 }, + total: { min: 1, max: 2 ** 32 }, + }; + } + + getFileExtension() { + return '.ogg'; + } + + getSupportedCodecs() { + return OggOutputFormat.getSupportedCodecs(); + } + + static getSupportedCodecs(): MediaCodec[] { + return [ + ...AUDIO_CODECS.filter(codec => ['vorbis', 'opus'].includes(codec)), + ]; + } +} diff --git a/src/wave/wave-muxer.ts b/src/wave/wave-muxer.ts index 174150d..43212f1 100644 --- a/src/wave/wave-muxer.ts +++ b/src/wave/wave-muxer.ts @@ -100,6 +100,8 @@ export class WaveMuxer extends Muxer { } async finalize() { + const release = await this.mutex.acquire(); + const endPos = this.writer.getPos(); // Write file size @@ -111,5 +113,7 @@ export class WaveMuxer extends Muxer { this.riffWriter.writeU32(this.dataSize); this.writer.seek(endPos); + + release(); } }