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();
}
}