mirror of
https://github.com/arcodange-org/mediabunny.git
synced 2026-10-02 21:33:52 +02:00
Implement Ogg muxer
This commit is contained in:
+34
-73
@@ -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<EncodedAudioSample, SampleMetadata>();
|
||||
|
||||
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<AudioDecoderConfig | null> {
|
||||
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(
|
||||
|
||||
+185
-1
@@ -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 };
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
Reference in New Issue
Block a user