Migrate Ogg demuxer to new packet system, improve Ogg demuxer performance

This commit is contained in:
Vanilagy
2025-12-22 22:52:26 +01:00
parent 0768f6137c
commit bc1511a201
4 changed files with 249 additions and 142 deletions
+3 -3
View File
@@ -14,15 +14,15 @@
source: new Mediabunny.BufferSource(await file.arrayBuffer()),
});
const videoTrack = await input.getPrimaryVideoTrack();
const audioTrack = await input.getPrimaryAudioTrack();
const iters = 50;
const iters = 250;
let total = 0;
for (let i = 0; i < iters; i++) {
const start = performance.now();
let count = 0;
const reader = new Mediabunny.PacketCursor(videoTrack);
const reader = new Mediabunny.PacketCursor(audioTrack);
await reader.iterate(() => count++);
console.log(count)
+203 -136
View File
@@ -15,13 +15,15 @@ import { PacketRetrievalOptions } from '../media-sink';
import { DEFAULT_TRACK_DISPOSITION, MetadataTags } from '../metadata';
import {
assert,
AsyncMutex,
AsyncMutex4,
binarySearchLessOrEqual,
findLast,
last,
ResultValue,
roundIfAlmostInteger,
toDataView,
UNDETERMINED_LANGUAGE,
Yo,
} from '../misc';
import { EncodedPacket, PLACEHOLDER_DATA } from '../packet';
import { readBytes, Reader } from '../reader';
@@ -52,6 +54,11 @@ type Packet = {
endSegmentIndex: number;
};
type PacketStart = {
startPage: Page;
startSegmentIndex: number;
};
export class OggDemuxer extends Demuxer {
reader: Reader;
@@ -105,7 +112,10 @@ export class OggDemuxer extends Demuxer {
}
for (const bitstream of this.bitstreams) {
const firstPacket = await this.readPacket(bitstream.bosPage, 0);
const packetResult = new ResultValue<Packet | null>();
await this.readPacket(packetResult, bitstream.bosPage, 0);
const firstPacket = packetResult.value;
if (!firstPacket) {
continue;
}
@@ -145,22 +155,32 @@ export class OggDemuxer extends Demuxer {
}
async readVorbisMetadata(firstPacket: Packet, bitstream: LogicalBitstream) {
let nextPacketPosition = await this.findNextPacketStart(firstPacket);
const positionResult = new ResultValue<PacketStart | null>();
await this.findNextPacketStart(positionResult, firstPacket);
let nextPacketPosition = positionResult.value;
if (!nextPacketPosition) {
return;
}
const secondPacket = await this.readPacket(nextPacketPosition.startPage, nextPacketPosition.startSegmentIndex);
const packetResult = new ResultValue<Packet | null>();
await this.readPacket(packetResult, nextPacketPosition.startPage, nextPacketPosition.startSegmentIndex);
const secondPacket = packetResult.value;
if (!secondPacket) {
return;
}
nextPacketPosition = await this.findNextPacketStart(secondPacket);
await this.findNextPacketStart(positionResult, secondPacket);
nextPacketPosition = positionResult.value;
if (!nextPacketPosition) {
return;
}
const thirdPacket = await this.readPacket(nextPacketPosition.startPage, nextPacketPosition.startSegmentIndex);
await this.readPacket(packetResult, nextPacketPosition.startPage, nextPacketPosition.startSegmentIndex);
const thirdPacket = packetResult.value;
if (!thirdPacket) {
return;
}
@@ -228,15 +248,19 @@ export class OggDemuxer extends Demuxer {
// From https://datatracker.ietf.org/doc/html/rfc7845#section-5:
// "An Ogg Opus logical stream contains exactly two mandatory header packets: an identification header and a
// comment header."
const nextPacketPosition = await this.findNextPacketStart(firstPacket);
const positionResult = new ResultValue<PacketStart | null>();
await this.findNextPacketStart(positionResult, firstPacket);
const nextPacketPosition = positionResult.value;
if (!nextPacketPosition) {
return;
}
const secondPacket = await this.readPacket(
nextPacketPosition.startPage,
nextPacketPosition.startSegmentIndex,
);
const packetResult = new ResultValue<Packet | null>();
await this.readPacket(packetResult, nextPacketPosition.startPage, nextPacketPosition.startSegmentIndex);
const secondPacket = packetResult.value;
if (!secondPacket) {
return;
}
@@ -256,7 +280,7 @@ export class OggDemuxer extends Demuxer {
readVorbisComments(secondPacket.data.subarray(8), this.metadataTags); // Skip 'OpusTags'
}
async readPacket(startPage: Page, startSegmentIndex: number): Promise<Packet | null> {
async readPacket(res: ResultValue<Packet | null>, startPage: Page, startSegmentIndex: number): Promise<Yo> {
assert(startSegmentIndex < startPage.lacingValues.length);
let startDataOffset = 0;
@@ -301,12 +325,12 @@ export class OggDemuxer extends Demuxer {
let headerSlice = this.reader.requestSliceRange(currentPos, MIN_PAGE_HEADER_SIZE, MAX_PAGE_HEADER_SIZE);
if (headerSlice instanceof Promise) headerSlice = await headerSlice;
if (!headerSlice) {
return null;
return res.set(null);
}
const nextPage = readPageHeader(headerSlice);
if (!nextPage) {
return null;
return res.set(null);
}
currentPage = nextPage;
@@ -321,33 +345,40 @@ export class OggDemuxer extends Demuxer {
currentSegmentIndex = 0;
}
const totalPacketSize = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
const packetData = new Uint8Array(totalPacketSize);
let packetData: Uint8Array;
let offset = 0;
for (let i = 0; i < chunks.length; i++) {
const chunk = chunks[i]!;
packetData.set(chunk, offset);
offset += chunk.length;
if (chunks.length === 1) {
// Fast path, no need for an allocation. Also typically the common path!
packetData = chunks[0]!;
} else {
const totalPacketSize = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
packetData = new Uint8Array(totalPacketSize);
let offset = 0;
for (let i = 0; i < chunks.length; i++) {
const chunk = chunks[i]!;
packetData.set(chunk, offset);
offset += chunk.length;
}
}
return {
return res.set({
data: packetData,
endPage: currentPage,
endSegmentIndex: currentSegmentIndex,
};
});
}
async findNextPacketStart(lastPacket: Packet) {
async findNextPacketStart(res: ResultValue<PacketStart | null>, lastPacket: Packet): Promise<Yo> {
// If there's another segment in the same page, return it
if (lastPacket.endSegmentIndex < lastPacket.endPage.lacingValues.length - 1) {
return { startPage: lastPacket.endPage, startSegmentIndex: lastPacket.endSegmentIndex + 1 };
return res.set({ startPage: lastPacket.endPage, startSegmentIndex: lastPacket.endSegmentIndex + 1 });
}
const isEos = !!(lastPacket.endPage.headerType & 0x04);
if (isEos) {
// The page is marked as the last page of the logical bitstream, so we won't find anything beyond it
return null;
return res.set(null);
}
// Otherwise, search for the next page belonging to the same bitstream
@@ -356,16 +387,16 @@ export class OggDemuxer extends Demuxer {
let slice = this.reader.requestSliceRange(currentPos, MIN_PAGE_HEADER_SIZE, MAX_PAGE_HEADER_SIZE);
if (slice instanceof Promise) slice = await slice;
if (!slice) {
return null;
return res.set(null);
}
const nextPage = readPageHeader(slice);
if (!nextPage) {
return null;
return res.set(null);
}
if (nextPage.serialNumber === lastPacket.endPage.serialNumber) {
return { startPage: nextPage, startSegmentIndex: 0 };
return res.set({ startPage: nextPage, startSegmentIndex: 0 });
}
currentPos = nextPage.headerStartPos + nextPage.totalSize;
@@ -387,12 +418,6 @@ export class OggDemuxer extends Demuxer {
return this.tracks;
}
async computeDuration() {
const tracks = await this.getTracks();
const trackDurations = await Promise.all(tracks.map(x => x.computeDuration()));
return Math.max(0, ...trackDurations);
}
async getMetadataTags() {
await this.readMetadata();
return this.metadataTags;
@@ -409,9 +434,8 @@ type EncodedPacketMetadata = {
class OggAudioTrackBacking implements InputAudioTrackBacking {
internalSampleRate: number;
encodedPacketToMetadata = new WeakMap<EncodedPacket, EncodedPacketMetadata>();
sequentialScanCache: EncodedPacketMetadata[] = [];
sequentialScanMutex = new AsyncMutex();
sequentialScanMutex = new AsyncMutex4();
constructor(public bitstream: LogicalBitstream, public demuxer: OggDemuxer) {
// Opus always uses a fixed sample rate for its internal calculations, even if the actual rate is different
@@ -469,15 +493,6 @@ class OggAudioTrackBacking implements InputAudioTrackBacking {
};
}
async getFirstTimestamp() {
return 0;
}
async computeDuration() {
const lastPacket = await this.getPacket(Infinity, { metadataOnly: true });
return (lastPacket?.timestamp ?? 0) + (lastPacket?.duration ?? 0);
}
granulePositionToTimestampInSamples(granulePosition: number) {
if (this.bitstream.codecInfo.codec === 'opus') {
assert(this.bitstream.codecInfo.opusInfo);
@@ -514,21 +529,28 @@ class OggAudioTrackBacking implements InputAudioTrackBacking {
packet.data.byteLength,
);
this.encodedPacketToMetadata.set(encodedPacket, {
encodedPacket._internal = {
packet,
timestampInSamples: additional.timestampInSamples,
durationInSamples,
vorbisLastBlockSize: additional.vorbisLastBlocksize,
vorbisBlockSize,
});
} satisfies EncodedPacketMetadata;
return encodedPacket;
}
async getFirstPacket(options: PacketRetrievalOptions) {
async getFirstPacket(res: ResultValue<EncodedPacket | null>, options: PacketRetrievalOptions): Promise<Yo> {
assert(this.bitstream.lastMetadataPacket);
const packetPosition = await this.demuxer.findNextPacketStart(this.bitstream.lastMetadataPacket);
const positionResult = new ResultValue<PacketStart | null>();
let promise = this.demuxer.findNextPacketStart(positionResult, this.bitstream.lastMetadataPacket);
if (positionResult.pending) await promise;
const packetPosition = positionResult.value;
if (!packetPosition) {
return null;
return res.set(null);
}
let timestampInSamples = 0;
@@ -537,66 +559,84 @@ class OggAudioTrackBacking implements InputAudioTrackBacking {
timestampInSamples -= this.bitstream.codecInfo.opusInfo.preSkip;
}
const packet = await this.demuxer.readPacket(packetPosition.startPage, packetPosition.startSegmentIndex);
const packetResult = new ResultValue<Packet | null>();
promise = this.demuxer.readPacket(packetResult, packetPosition.startPage, packetPosition.startSegmentIndex);
if (packetResult.pending) await promise;
return this.createEncodedPacketFromOggPacket(
packet,
return res.set(this.createEncodedPacketFromOggPacket(
packetResult.value,
{
timestampInSamples,
vorbisLastBlocksize: null,
},
options,
);
));
}
async getNextPacket(prevPacket: EncodedPacket, options: PacketRetrievalOptions) {
const prevMetadata = this.encodedPacketToMetadata.get(prevPacket);
async getNextPacket(
res: ResultValue<EncodedPacket | null>,
prevPacket: EncodedPacket,
options: PacketRetrievalOptions,
): Promise<Yo> {
const prevMetadata = prevPacket._internal as EncodedPacketMetadata | undefined;
if (!prevMetadata) {
throw new Error('Packet was not created from this track.');
}
const packetPosition = await this.demuxer.findNextPacketStart(prevMetadata.packet);
const positionResult = new ResultValue<PacketStart | null>();
let promise = this.demuxer.findNextPacketStart(positionResult, prevMetadata.packet);
if (positionResult.pending) await promise;
const packetPosition = positionResult.value;
if (!packetPosition) {
return null;
return res.set(null);
}
const timestampInSamples = prevMetadata.timestampInSamples + prevMetadata.durationInSamples;
const packet = await this.demuxer.readPacket(
packetPosition.startPage,
packetPosition.startSegmentIndex,
);
const packetResult = new ResultValue<Packet | null>();
promise = this.demuxer.readPacket(packetResult, packetPosition.startPage, packetPosition.startSegmentIndex);
if (packetResult.pending) await promise;
return this.createEncodedPacketFromOggPacket(
packet,
return res.set(this.createEncodedPacketFromOggPacket(
packetResult.value,
{
timestampInSamples,
vorbisLastBlocksize: prevMetadata.vorbisBlockSize,
},
options,
);
));
}
async getPacket(timestamp: number, options: PacketRetrievalOptions) {
async getPacket(
res: ResultValue<EncodedPacket | null>,
timestamp: number,
options: PacketRetrievalOptions,
): Promise<Yo> {
if (this.demuxer.reader.fileSize === null) {
// No file size known, can't do binary search, but fall back to sequential algo instead
return this.getPacketSequential(timestamp, options);
return this.getPacketSequential(res, timestamp, options);
}
const timestampInSamples = roundIfAlmostInteger(timestamp * this.internalSampleRate);
if (timestampInSamples === 0) {
// Fast path for timestamp 0 - avoids binary search when playing back from the start
return this.getFirstPacket(options);
return this.getFirstPacket(res, options);
}
if (timestampInSamples < 0) {
// There's nothing here
return null;
return res.set(null);
}
assert(this.bitstream.lastMetadataPacket);
const startPosition = await this.demuxer.findNextPacketStart(this.bitstream.lastMetadataPacket);
const positionResult = new ResultValue<PacketStart | null>();
const promise = this.demuxer.findNextPacketStart(positionResult, this.bitstream.lastMetadataPacket);
if (positionResult.pending) await promise;
const startPosition = positionResult.value;
if (!startPosition) {
return null;
return res.set(null);
}
let lowPage = startPosition.startPage;
@@ -778,7 +818,12 @@ class OggAudioTrackBacking implements InputAudioTrackBacking {
endPage,
endSegmentIndex,
};
const nextPosition = await this.demuxer.findNextPacketStart(pseudopacket);
positionResult.reset();
const promise = this.demuxer.findNextPacketStart(positionResult, pseudopacket);
if (positionResult.pending) await promise;
const nextPosition = positionResult.value;
if (nextPosition) {
// Let's rewind a single step (packet) - this previous packet ensures that we'll correctly compute
@@ -827,12 +872,18 @@ class OggAudioTrackBacking implements InputAudioTrackBacking {
let lastEncodedPacket: EncodedPacket | null = null;
let lastEncodedPacketMetadata: EncodedPacketMetadata | null = null;
const packetResult = new ResultValue<Packet | null>();
// Alright, now it's time for the final, granular seek: We keep iterating over packets until we've found the
// one with the correct timestamp - i.e., the last one with a timestamp <= the timestamp we're looking for.
while (currentPage !== null) {
assert(currentSegmentIndex !== null);
const packet = await this.demuxer.readPacket(currentPage, currentSegmentIndex);
packetResult.reset();
let promise = this.demuxer.readPacket(packetResult, currentPage, currentSegmentIndex);
if (packetResult.pending) await promise;
const packet = packetResult.value;
if (!packet) {
break;
}
@@ -852,7 +903,7 @@ class OggAudioTrackBacking implements InputAudioTrackBacking {
);
assert(encodedPacket);
let encodedPacketMetadata = this.encodedPacketToMetadata.get(encodedPacket);
let encodedPacketMetadata = encodedPacket._internal as EncodedPacketMetadata | undefined;
assert(encodedPacketMetadata);
if (
@@ -877,7 +928,7 @@ class OggAudioTrackBacking implements InputAudioTrackBacking {
);
assert(encodedPacket);
encodedPacketMetadata = this.encodedPacketToMetadata.get(encodedPacket);
encodedPacketMetadata = encodedPacket._internal as EncodedPacketMetadata | undefined;
assert(encodedPacketMetadata);
} else {
currentTimestampInSamples += encodedPacketMetadata.durationInSamples;
@@ -899,7 +950,11 @@ class OggAudioTrackBacking implements InputAudioTrackBacking {
}
}
const nextPosition = await this.demuxer.findNextPacketStart(packet);
positionResult.reset();
promise = this.demuxer.findNextPacketStart(positionResult, packet);
if (positionResult.pending) await promise;
const nextPosition = positionResult.value;
if (!nextPosition) {
break;
}
@@ -908,77 +963,89 @@ class OggAudioTrackBacking implements InputAudioTrackBacking {
currentSegmentIndex = nextPosition.startSegmentIndex;
}
return lastEncodedPacket;
return res.set(lastEncodedPacket);
}
// A slower but simpler and sequential algorithm for finding a packet in a file
async getPacketSequential(timestamp: number, options: PacketRetrievalOptions) {
const release = await this.sequentialScanMutex.acquire(); // Requires exclusivity because we write to a cache
async getPacketSequential(
res: ResultValue<EncodedPacket | null>,
timestamp: number,
options: PacketRetrievalOptions,
): Promise<Yo> {
using lock = this.sequentialScanMutex.lock(); // Requires exclusivity because we write to a cache
if (lock.pending) await lock.ready;
try {
const timestampInSamples = roundIfAlmostInteger(timestamp * this.internalSampleRate);
timestamp = timestampInSamples / this.internalSampleRate;
const timestampInSamples = roundIfAlmostInteger(timestamp * this.internalSampleRate);
timestamp = timestampInSamples / this.internalSampleRate;
const index = binarySearchLessOrEqual(
this.sequentialScanCache,
timestampInSamples,
x => x.timestampInSamples,
const index = binarySearchLessOrEqual(
this.sequentialScanCache,
timestampInSamples,
x => x.timestampInSamples,
);
const result = new ResultValue<EncodedPacket | null>();
let currentPacket: EncodedPacket | null;
if (index !== -1) {
// We don't need to start from the beginning, we can start at a previous scan point
const cacheEntry = this.sequentialScanCache[index]!;
currentPacket = this.createEncodedPacketFromOggPacket(
cacheEntry.packet,
{
timestampInSamples: cacheEntry.timestampInSamples,
vorbisLastBlocksize: cacheEntry.vorbisLastBlockSize,
},
options,
);
} else {
const promise = this.getFirstPacket(result, options);
if (result.pending) await promise;
let currentPacket: EncodedPacket | null;
if (index !== -1) {
// We don't need to start from the beginning, we can start at a previous scan point
const cacheEntry = this.sequentialScanCache[index]!;
currentPacket = this.createEncodedPacketFromOggPacket(
cacheEntry.packet,
{
timestampInSamples: cacheEntry.timestampInSamples,
vorbisLastBlocksize: cacheEntry.vorbisLastBlockSize,
},
options,
);
} else {
currentPacket = await this.getFirstPacket(options);
}
let i = 0;
while (currentPacket && currentPacket.timestamp < timestamp) {
const nextPacket = await this.getNextPacket(currentPacket, options);
if (!nextPacket || nextPacket.timestamp > timestamp) {
break;
}
currentPacket = nextPacket;
i++;
if (i === 100) {
// Add "checkpoints" every once in a while to speed up subsequent random accesses
i = 0;
const metadata = this.encodedPacketToMetadata.get(currentPacket);
assert(metadata);
if (this.sequentialScanCache.length > 0) {
// If we reach this case, we must be at the end of the cache
assert(last(this.sequentialScanCache)!.timestampInSamples <= metadata.timestampInSamples);
}
this.sequentialScanCache.push(metadata);
}
}
return currentPacket;
} finally {
release();
currentPacket = result.value;
}
let i = 0;
while (currentPacket && currentPacket.timestamp < timestamp) {
result.reset();
const promise = this.getNextPacket(result, currentPacket, options);
if (result.pending) await promise;
const nextPacket = result.value;
if (!nextPacket || nextPacket.timestamp > timestamp) {
break;
}
currentPacket = nextPacket;
i++;
if (i === 100) {
// Add "checkpoints" every once in a while to speed up subsequent random accesses
i = 0;
const metadata = currentPacket._internal as EncodedPacketMetadata | undefined;
assert(metadata);
if (this.sequentialScanCache.length > 0) {
// If we reach this case, we must be at the end of the cache
assert(last(this.sequentialScanCache)!.timestampInSamples <= metadata.timestampInSamples);
}
this.sequentialScanCache.push(metadata);
}
}
return res.set(currentPacket);
}
getKeyPacket(timestamp: number, options: PacketRetrievalOptions) {
return this.getPacket(timestamp, options);
getKeyPacket(res: ResultValue<EncodedPacket | null>, timestamp: number, options: PacketRetrievalOptions) {
// Correct since only audio codecs are supported
return this.getPacket(res, timestamp, options);
}
getNextKeyPacket(packet: EncodedPacket, options: PacketRetrievalOptions) {
return this.getNextPacket(packet, options);
getNextKeyPacket(res: ResultValue<EncodedPacket | null>, packet: EncodedPacket, options: PacketRetrievalOptions) {
// Correct since only audio codecs are supported
return this.getNextPacket(res, packet, options);
}
}
+43 -3
View File
@@ -3,7 +3,7 @@ import { Input } from '../../src/input.js';
import { BufferSource, FilePathSource } from '../../src/source.js';
import path from 'node:path';
import fs from 'node:fs';
import { ADTS, ALL_FORMATS, MP3, MP4, QTFF, WAVE, WEBM } from '../../src/input-format.js';
import { ADTS, ALL_FORMATS, MP3, MP4, OGG, QTFF, WAVE, WEBM } from '../../src/input-format.js';
import { PacketReader } from '../../src/cursors.js';
import { InputAudioTrack, InputTrack } from '../../src/input-track.js';
import { assert } from '../../src/misc.js';
@@ -50,6 +50,9 @@ const testBasicPacketReading = async (track: InputTrack) => {
const testSyncPacketReading = (track: InputTrack) => {
const reader = new PacketReader(track);
const seeked = reader.readAt(1) as EncodedPacket | null;
expect(seeked).toBeInstanceOf(EncodedPacket);
let current = reader.readFirst() as EncodedPacket | null;
expect(current).toBeInstanceOf(EncodedPacket);
@@ -261,7 +264,7 @@ test('ADTS demuxing', async () => {
await testBasicPacketReading(audioTrack);
const duration = await audioTrack.computeDuration();
expect(duration).toBeGreaterThan(0);
expect(duration).toBeCloseTo(4.992);
});
test('ADTS sync reading', async () => {
@@ -274,5 +277,42 @@ test('ADTS sync reading', async () => {
assert(audioTrack);
const count = testSyncPacketReading(audioTrack);
expect(count).toBeGreaterThan(0);
expect(count).toBe(234);
});
test('Ogg demuxing', async () => {
using input = new Input({
source: new FilePathSource(path.join(__dirname, '../public/beach-party.ogg')),
formats: ALL_FORMATS,
});
expect(await input.getFormat()).toBe(OGG);
expect(await input.getMimeType()).toBe('audio/ogg; codecs="vorbis"');
const tracks = await input.getTracks();
expect(tracks).toHaveLength(1);
const audioTrack = tracks[0] as InputAudioTrack;
expect(audioTrack).toBeInstanceOf(InputAudioTrack);
expect(audioTrack.codec).toBe('vorbis');
expect(audioTrack.numberOfChannels).toBeGreaterThan(0);
expect(audioTrack.sampleRate).toBeGreaterThan(0);
await testBasicPacketReading(audioTrack);
const duration = await audioTrack.computeDuration();
expect(duration).toBeCloseTo(57.325714285714284);
});
test('Ogg sync reading', async () => {
using input = new Input({
source: new BufferSource(fs.readFileSync(path.join(__dirname, '../public/beach-party.ogg'))),
formats: ALL_FORMATS,
});
const audioTrack = await input.getPrimaryAudioTrack();
assert(audioTrack);
const count = testSyncPacketReading(audioTrack);
expect(count).toBe(5041);
});
Binary file not shown.