Merge streaming-overhaul into dev (Opus low-data streaming, windowed streaming, HW-accel-off stabilization)
This commit is contained in:
@@ -0,0 +1,298 @@
|
||||
/**
|
||||
* AudioPlayer window-miss refill tests (Phase 21.3) — the seek-dispatch TRIGGER and the AC6
|
||||
* clean-failure recovery.
|
||||
*
|
||||
* What this pins (the genuinely-new 21.3 work):
|
||||
* - The window-miss TRIGGER. AudioPlayer.seek() routes by whether the target falls inside the
|
||||
* retained window [playbackOffset, playbackOffset + totalDuration]. After 21.1 partial eviction
|
||||
* playbackOffset is the absolute start of the retained back-window tail, so:
|
||||
* * seek back WITHIN the tail -> seekWithinBuffer, NO refetch (UC3 / AC4),
|
||||
* * seek back PAST the tail -> seekBeyondBuffer with the EARLIER resolved offset (UC4 / AC5),
|
||||
* using whichever resolver the active path ships (WAV calculateByteOffset; Opus
|
||||
* resolveOpusByteOffset over the sidecar index),
|
||||
* * seek forward past the decoded end -> seekBeyondBuffer forward, unchanged (UC2/UC5).
|
||||
* - The AC6 recovery. recoverFromFailedRefill() halts the scheduler (clearForSeek), anchors the
|
||||
* offset at the seek target, and leaves the player paused-but-loaded so no silent false end fires.
|
||||
*
|
||||
* The seek dispatch and recovery are pure given the scheduler + active decoder, so they are testable
|
||||
* in Node by white-box-injecting fakes for `scheduler`, `streamDecoder`, and `opusDecoder` (the same
|
||||
* private-field injection idiom the scheduler/Opus tests use). The AudioPlayer constructor itself is
|
||||
* Node-safe: it builds AudioContextManager/StreamDecoder/PlaybackScheduler, none of which touch Web
|
||||
* Audio until initialize(). No AudioContext, no WebCodecs.
|
||||
*
|
||||
* Same harness convention as the sibling tests (no runner in this repo); run a copy from the COMPILED
|
||||
* output so the `.js` import specifiers resolve:
|
||||
*
|
||||
* dotnet build DeepDrftPublic/DeepDrftPublic.csproj
|
||||
* cp DeepDrftPublic/Interop/audio/AudioPlayer.test.ts DeepDrftPublic/wwwroot/js/audio/
|
||||
* node DeepDrftPublic/wwwroot/js/audio/AudioPlayer.test.ts
|
||||
*
|
||||
* A thrown error / non-zero exit signals failure; "ALL <n> TESTS PASSED" signals success.
|
||||
* Excluded from the production tsc build via tsconfig `exclude: Interop/ ** /*.test.ts`.
|
||||
*/
|
||||
|
||||
import { AudioPlayer } from './AudioPlayer.js';
|
||||
import { parseSidecar } from './OpusSidecar.js';
|
||||
import type { OpusSeekData } from './OpusSidecar.js';
|
||||
|
||||
// --- tiny inline harness (no dependencies) ---------------------------------------------------
|
||||
let passed = 0;
|
||||
const failures: string[] = [];
|
||||
function test(name: string, fn: () => void): void {
|
||||
try {
|
||||
fn();
|
||||
passed++;
|
||||
} catch (e) {
|
||||
failures.push(`FAIL: ${name}\n ${(e as Error).message}`);
|
||||
}
|
||||
}
|
||||
function assertEqual(actual: unknown, expected: unknown, msg?: string): void {
|
||||
if (actual !== expected) {
|
||||
throw new Error(`${msg ?? 'assertEqual'}: expected ${String(expected)}, got ${String(actual)}`);
|
||||
}
|
||||
}
|
||||
function assertTrue(cond: boolean, msg?: string): void {
|
||||
if (!cond) throw new Error(msg ?? 'assertTrue failed');
|
||||
}
|
||||
|
||||
// --- fakes -----------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A scheduler stand-in exposing only what AudioPlayer.seek / seekWithinBuffer / seekBeyondBuffer /
|
||||
* recoverFromFailedRefill read or call. The retained window is [offset, offset + total]. Records the
|
||||
* methods that mutate so the recovery test can assert the cleanup happened.
|
||||
*/
|
||||
class FakeScheduler {
|
||||
private offset: number;
|
||||
private total: number;
|
||||
// hasBuffers reflects whether the scheduler holds decoded audio. Starts true when total > 0
|
||||
// (a populated window), set to false by clearForSeek() (recovery drains the buffers).
|
||||
private _hasBuffers: boolean;
|
||||
public clearedForSeek = false;
|
||||
public stoppedAllSources = false;
|
||||
public offsetSetTo: number | null = null;
|
||||
constructor(offset: number, total: number) {
|
||||
this.offset = offset;
|
||||
this.total = total;
|
||||
this._hasBuffers = total > 0;
|
||||
}
|
||||
|
||||
getPlaybackOffset(): number { return this.offset; }
|
||||
getTotalDuration(): number { return this.total; }
|
||||
hasBuffers(): boolean { return this._hasBuffers; }
|
||||
stopAllSources(): void { this.stoppedAllSources = true; }
|
||||
// seekWithinBuffer calls playFromPosition only when wasPlaying; isPlaying is false in these
|
||||
// unit constructions, so it is never invoked — present for completeness.
|
||||
playFromPosition(_position: number): void { /* no-op */ }
|
||||
clearForSeek(): void { this.clearedForSeek = true; this._hasBuffers = false; }
|
||||
setPlaybackOffset(o: number): void { this.offset = o; this.offsetSetTo = o; }
|
||||
}
|
||||
|
||||
/** A StreamDecoder stand-in for the WAV path: a format is parsed and byte math is identity-scaled. */
|
||||
class FakeStreamDecoder {
|
||||
private hasFormat: boolean;
|
||||
private bytesPerSecond: number;
|
||||
public requestedOffsetFor: number | null = null;
|
||||
constructor(hasFormat: boolean, bytesPerSecond: number) { this.hasFormat = hasFormat; this.bytesPerSecond = bytesPerSecond; }
|
||||
getFormatInfo(): unknown { return this.hasFormat ? { ok: true } : null; }
|
||||
calculateByteOffset(position: number): number {
|
||||
this.requestedOffsetFor = position;
|
||||
return Math.round(position * this.bytesPerSecond);
|
||||
}
|
||||
}
|
||||
|
||||
function makePlayer(): AudioPlayer {
|
||||
// Constructor is Node-safe (no Web Audio until initialize()).
|
||||
return new AudioPlayer();
|
||||
}
|
||||
|
||||
/** Inject the seek-relevant private fields and put the player in a loaded/streaming/playing state. */
|
||||
function arm(
|
||||
player: AudioPlayer,
|
||||
opts: {
|
||||
scheduler: FakeScheduler;
|
||||
duration: number;
|
||||
streamDecoder?: FakeStreamDecoder;
|
||||
opusDecoder?: object | null;
|
||||
sidecar?: OpusSeekData | null;
|
||||
}
|
||||
): void {
|
||||
const priv = player as unknown as Record<string, unknown>;
|
||||
priv.scheduler = opts.scheduler;
|
||||
priv.duration = opts.duration;
|
||||
priv.isStreamingMode = true;
|
||||
priv.isPlaying = false; // keep dispatch pure (no real playFromPosition needed)
|
||||
if (opts.streamDecoder) priv.streamDecoder = opts.streamDecoder;
|
||||
priv.opusDecoder = opts.opusDecoder ?? null;
|
||||
priv.activeOpusSidecar = opts.sidecar ?? null;
|
||||
}
|
||||
|
||||
/** Read back private fields the recovery sets. */
|
||||
function priv(player: AudioPlayer): Record<string, unknown> {
|
||||
return player as unknown as Record<string, unknown>;
|
||||
}
|
||||
|
||||
// A minimal real sidecar (parsed) so the Opus resolver returns a deterministic page offset.
|
||||
// Index: t=0 -> byte 4096, t=1s -> byte 9000 (granule uses 48 kHz + preSkip).
|
||||
function makeOpusSidecar(): OpusSeekData {
|
||||
const setup = [0x4f, 0x70, 0x75, 0x73, 0x48, 0x65, 0x61, 0x64];
|
||||
const SEEK_INDEX_HEADER_SIZE = 24;
|
||||
const SEEK_POINT_SIZE = 16;
|
||||
const preSkip = 312;
|
||||
const points = [
|
||||
{ granule: preSkip, byteOffset: 4096 }, // t = 0
|
||||
{ granule: preSkip + 48000, byteOffset: 9000 }, // t = 1 s
|
||||
];
|
||||
const total = 4 + setup.length + SEEK_INDEX_HEADER_SIZE + points.length * SEEK_POINT_SIZE;
|
||||
const bytes = new Uint8Array(total);
|
||||
const view = new DataView(bytes.buffer);
|
||||
view.setUint32(0, setup.length, true);
|
||||
bytes.set(setup, 4);
|
||||
let p = 4 + setup.length;
|
||||
const writeU64 = (off: number, v: number) => {
|
||||
view.setUint32(off, v >>> 0, true);
|
||||
view.setUint32(off + 4, Math.floor(v / 0x100000000), true);
|
||||
};
|
||||
writeU64(p, 500_000);
|
||||
view.setFloat64(p + 8, 100, true);
|
||||
view.setUint32(p + 16, points.length, true);
|
||||
view.setUint16(p + 20, preSkip, true);
|
||||
p += SEEK_INDEX_HEADER_SIZE;
|
||||
for (const pt of points) { writeU64(p, pt.granule); writeU64(p + 8, pt.byteOffset); p += SEEK_POINT_SIZE; }
|
||||
const parsed = parseSidecar(bytes);
|
||||
if (!parsed) throw new Error('test setup: sidecar failed to parse');
|
||||
return parsed;
|
||||
}
|
||||
|
||||
// --- TRIGGER: within-window vs past-tail vs forward ------------------------------------------
|
||||
|
||||
// UC3 / AC4: a backward seek INTO the retained tail resolves from buffer — NO seekBeyondBuffer,
|
||||
// NO refetch signal. Window is [30, 60); target 40 is inside.
|
||||
test('seek back within retained tail resolves in-buffer (no refetch) — AC4', () => {
|
||||
const player = makePlayer();
|
||||
const scheduler = new FakeScheduler(30, 30); // retained window [30, 60)
|
||||
arm(player, { scheduler, duration: 120, streamDecoder: new FakeStreamDecoder(true, 1000) });
|
||||
|
||||
const result = player.seek(40);
|
||||
assertEqual(result.success, true, 'seek succeeds');
|
||||
assertEqual(result.seekBeyondBuffer ?? false, false, 'within-window seek does NOT signal a refetch');
|
||||
// No clearForSeek / no offset request — the retained window served it.
|
||||
assertEqual(scheduler.clearedForSeek, false, 'no clearForSeek for an in-buffer seek');
|
||||
});
|
||||
|
||||
// UC4 / AC5 (WAV): a backward seek PAST the retained tail signals a refill at the EARLIER resolved
|
||||
// offset, using the WAV resolver. Window [30, 60); target 10 is before the tail.
|
||||
test('seek back past retained tail refetches at the WAV-resolved earlier offset — AC5', () => {
|
||||
const player = makePlayer();
|
||||
const scheduler = new FakeScheduler(30, 30); // retained window [30, 60)
|
||||
const wav = new FakeStreamDecoder(true, 2000); // 2000 bytes/sec
|
||||
arm(player, { scheduler, duration: 120, streamDecoder: wav });
|
||||
|
||||
const result = player.seek(10); // earlier than the retained tail start (30)
|
||||
assertEqual(result.success, true, 'seek succeeds');
|
||||
assertEqual(result.seekBeyondBuffer, true, 'past-tail back seek signals a refill (window miss)');
|
||||
assertEqual(wav.requestedOffsetFor, 10, 'WAV resolver consulted for the EARLIER target');
|
||||
assertEqual(result.byteOffset, 20000, 'refill offset is the WAV-resolved earlier byte offset');
|
||||
});
|
||||
|
||||
// UC4 / AC5 (Opus): the same window miss on the Opus path uses resolveOpusByteOffset over the
|
||||
// sidecar index (the live seek), not WAV byte math. Target 0.3 s resolves to the t=0 page (4096).
|
||||
test('seek back past retained tail refetches at the Opus index-resolved offset — AC5', () => {
|
||||
const player = makePlayer();
|
||||
const scheduler = new FakeScheduler(30, 30); // retained window [30, 60)
|
||||
arm(player, {
|
||||
scheduler,
|
||||
duration: 100,
|
||||
opusDecoder: {}, // presence routes seekBeyondBuffer down the Opus branch
|
||||
sidecar: makeOpusSidecar(),
|
||||
});
|
||||
|
||||
const result = player.seek(0.3); // earlier than the retained tail (30) -> window miss
|
||||
assertEqual(result.success, true, 'seek succeeds');
|
||||
assertEqual(result.seekBeyondBuffer, true, 'past-tail back seek signals a refill on Opus too');
|
||||
assertEqual(result.byteOffset, 4096, 'Opus index resolved the t=0 page start for the earlier target');
|
||||
// The landing time of the resolved page is captured for the decoder lead-trim (AC9 reuse).
|
||||
assertEqual(priv(player)._seekLandingTime, 0, 'landing time of the resolved page captured for lead-trim');
|
||||
});
|
||||
|
||||
// UC2/UC5: a forward seek past the decoded end still routes to seekBeyondBuffer forward, unchanged.
|
||||
test('forward seek past decoded end still routes to seekBeyondBuffer (unchanged)', () => {
|
||||
const player = makePlayer();
|
||||
const scheduler = new FakeScheduler(30, 30); // decoded [30, 60)
|
||||
const wav = new FakeStreamDecoder(true, 1500);
|
||||
arm(player, { scheduler, duration: 120, streamDecoder: wav });
|
||||
|
||||
const result = player.seek(90); // past the decoded end (60)
|
||||
assertEqual(result.seekBeyondBuffer, true, 'forward-beyond-buffer still signals a fetch');
|
||||
assertEqual(wav.requestedOffsetFor, 90, 'forward target resolved through the same WAV resolver');
|
||||
assertEqual(result.byteOffset, 135000, 'forward offset is the resolved later byte offset');
|
||||
});
|
||||
|
||||
// --- AC6: clean-failure recovery -------------------------------------------------------------
|
||||
|
||||
// A failed refill must leave the player recoverable: scheduler halted (clearForSeek), offset anchored
|
||||
// at the seek target, paused-but-loaded — never a starved "playing" scheduler that fires a false end.
|
||||
test('recoverFromFailedRefill halts the scheduler and leaves a paused-but-loaded state — AC6', () => {
|
||||
const player = makePlayer();
|
||||
const scheduler = new FakeScheduler(30, 30);
|
||||
arm(player, { scheduler, duration: 120, streamDecoder: new FakeStreamDecoder(true, 1000) });
|
||||
// Simulate the pre-failure "playing" state the drained pre-seek loop leaves behind.
|
||||
priv(player).isPlaying = true;
|
||||
priv(player).isPaused = false;
|
||||
priv(player).streamingStarted = true;
|
||||
|
||||
const result = player.recoverFromFailedRefill(15);
|
||||
assertEqual(result.success, true, 'recovery succeeds');
|
||||
assertTrue(scheduler.clearedForSeek, 'stale buffers dropped (no false end can fire)');
|
||||
assertEqual(scheduler.offsetSetTo, 15, 'offset anchored at the seek target for a retry');
|
||||
assertEqual(priv(player).isPlaying, false, 'not playing after recovery');
|
||||
assertEqual(priv(player).isPaused, true, 'paused after recovery');
|
||||
assertEqual(priv(player).pausePosition, 15, 'pause anchor is the seek target');
|
||||
assertEqual(priv(player).streamingStarted, false, 'streaming flagged not-started for a clean retry');
|
||||
});
|
||||
|
||||
// --- AC6 retry contract: same-target seek after recovery refetches -------------------------
|
||||
|
||||
// After recoverFromFailedRefill the scheduler is empty (clearForSeek was called). A seek to
|
||||
// the SAME position (seekPosition == playbackOffset) must route to seekBeyondBuffer — not
|
||||
// seekWithinBuffer, which would be a silent no-op against the degenerate [P,P] empty window.
|
||||
test('same-target seek after recovery routes to seekBeyondBuffer (AC6 retry)', () => {
|
||||
const player = makePlayer();
|
||||
const wav = new FakeStreamDecoder(true, 1000);
|
||||
// Start with a populated window [30, 60), then simulate recovery at position 15:
|
||||
// clearForSeek empties the scheduler; setPlaybackOffset anchors it to 15.
|
||||
const scheduler = new FakeScheduler(30, 30);
|
||||
arm(player, { scheduler, duration: 120, streamDecoder: wav });
|
||||
// Drive recovery state manually (the same state recoverFromFailedRefill leaves).
|
||||
player.recoverFromFailedRefill(15);
|
||||
// At this point: scheduler.hasBuffers() == false, playbackOffset == 15, totalDuration == 0.
|
||||
// A seek to 15 (the recovery anchor) must refetch, not silently resolve from the empty window.
|
||||
const result = player.seek(15);
|
||||
assertEqual(result.success, true, 'seek succeeds after recovery');
|
||||
assertEqual(result.seekBeyondBuffer, true, 'same-target seek after recovery signals a refetch (AC6 retry)');
|
||||
assertEqual(wav.requestedOffsetFor, 15, 'WAV resolver used for the retry offset');
|
||||
});
|
||||
|
||||
// AC4 not regressed: a seek within a POPULATED retained window still resolves from buffer.
|
||||
// This is the same test as the existing AC4 test but named explicitly to confirm the
|
||||
// hasBuffers() guard does not affect the populated case.
|
||||
test('seek within populated retained window still resolves in-buffer — AC4 not regressed', () => {
|
||||
const player = makePlayer();
|
||||
// Populated window [30, 60) — hasBuffers() starts true (total=30 > 0).
|
||||
const scheduler = new FakeScheduler(30, 30);
|
||||
arm(player, { scheduler, duration: 120, streamDecoder: new FakeStreamDecoder(true, 1000) });
|
||||
|
||||
const result = player.seek(45); // inside [30, 60)
|
||||
assertEqual(result.success, true, 'seek succeeds');
|
||||
assertEqual(result.seekBeyondBuffer ?? false, false, 'populated in-window seek does NOT signal a refetch');
|
||||
assertEqual(scheduler.clearedForSeek, false, 'scheduler not cleared for an in-buffer seek (no refetch)');
|
||||
});
|
||||
|
||||
// --- run -------------------------------------------------------------------------------------
|
||||
if (failures.length > 0) {
|
||||
console.error(failures.join('\n'));
|
||||
console.error(`\n${failures.length} FAILED, ${passed} passed`);
|
||||
process.exit(1);
|
||||
} else {
|
||||
console.log(`ALL ${passed} TESTS PASSED`);
|
||||
}
|
||||
@@ -11,9 +11,12 @@ import { AudioContextManager } from './AudioContextManager.js';
|
||||
import { StreamDecoder } from './StreamDecoder.js';
|
||||
import { PlaybackScheduler } from './PlaybackScheduler.js';
|
||||
import { IFormatDecoder } from './IFormatDecoder.js';
|
||||
import { IStreamingDecoder } from './IStreamingDecoder.js';
|
||||
import { WavFormatDecoder } from './WavFormatDecoder.js';
|
||||
import { Mp3FormatDecoder } from './Mp3FormatDecoder.js';
|
||||
import { FlacFormatDecoder } from './FlacFormatDecoder.js';
|
||||
import { OpusStreamDecoder } from './OpusStreamDecoder.js';
|
||||
import { OpusSeekData, parseSidecar, resolveOpusByteOffset, OpusSeekResolution, OPUS_SAMPLE_RATE } from './OpusSidecar.js';
|
||||
|
||||
export interface AudioResult {
|
||||
success: boolean;
|
||||
@@ -27,6 +30,10 @@ export interface StreamingResult extends AudioResult {
|
||||
headerParsed?: boolean;
|
||||
bufferCount?: number;
|
||||
duration?: number;
|
||||
// Phase 21.2a back-pressure signal piggybacked on the chunk result the C# read loop already
|
||||
// awaits — true means the scheduler's forward fill is over the high-water mark and the loop
|
||||
// should stop calling ReadAsync until it drains (no extra interop hop in the common case).
|
||||
productionPaused?: boolean;
|
||||
}
|
||||
|
||||
export interface AudioState {
|
||||
@@ -45,6 +52,19 @@ export class AudioPlayer {
|
||||
private streamDecoder: StreamDecoder;
|
||||
private scheduler: PlaybackScheduler;
|
||||
|
||||
// The Opus WebCodecs decode path (IStreamingDecoder seam), used INSTEAD of streamDecoder when the
|
||||
// active stream is Ogg Opus. Null for WAV/MP3/FLAC, which keep the streamDecoder path unchanged.
|
||||
// Holding both is deliberate: the change is the decode stage only; the same scheduler/Web Audio
|
||||
// graph feeds from whichever decoder is active for the current stream.
|
||||
private opusDecoder: IStreamingDecoder | null = null;
|
||||
// The sidecar in effect for the active Opus stream (its seek index resolves byte offsets). Distinct
|
||||
// from pendingOpusSidecar, which is the one set for the NEXT stream init.
|
||||
private activeOpusSidecar: OpusSeekData | null = null;
|
||||
// The landing time of the most recent seek-beyond-buffer page resolution (seconds). Set by
|
||||
// seekBeyondBuffer, consumed by reinitializeFromOffset to trim leading decoded frames so the
|
||||
// audible position matches the requested seek target (AC9 fine re-sync, §3.4a step 4).
|
||||
private _seekLandingTime: number = 0;
|
||||
|
||||
// Playback state
|
||||
private isPlaying: boolean = false;
|
||||
private isPaused: boolean = false;
|
||||
@@ -62,6 +82,11 @@ export class AudioPlayer {
|
||||
private onEndCallback: EndCallback | null = null;
|
||||
private progressInterval: number | null = null;
|
||||
|
||||
// Pending Opus sidecar (setup header + seek index), parsed from the one-time sidecar fetch and
|
||||
// applied to the OpusFormatDecoder when the next Opus stream initializes. Wave 18.5 sets this
|
||||
// (via setOpusSidecar) before initializeStreaming; this class never fetches it.
|
||||
private pendingOpusSidecar: OpusSeekData | null = null;
|
||||
|
||||
constructor() {
|
||||
this.contextManager = new AudioContextManager();
|
||||
this.streamDecoder = new StreamDecoder(this.contextManager);
|
||||
@@ -93,17 +118,53 @@ export class AudioPlayer {
|
||||
|
||||
// ==================== Streaming ====================
|
||||
|
||||
initializeStreaming(totalStreamLength: number, contentType: string): AudioResult {
|
||||
async initializeStreaming(totalStreamLength: number, contentType: string): Promise<AudioResult> {
|
||||
try {
|
||||
// Full cleanup before starting new stream
|
||||
this.stopProgressTracking();
|
||||
this.scheduler.clear();
|
||||
this.streamDecoder.reset();
|
||||
this.disposeOpusDecoder();
|
||||
this.resetState();
|
||||
|
||||
// Initialize new stream with the format decoder selected from Content-Type.
|
||||
this.isStreamingMode = true;
|
||||
const formatDecoder = AudioPlayer.createFormatDecoder(contentType);
|
||||
|
||||
// Opus routes to the WebCodecs streaming seam (IStreamingDecoder); WAV/MP3/FLAC keep the
|
||||
// StreamDecoder wrap-and-decode path byte-for-byte. The sidecar (setup header + seek index)
|
||||
// must already be set (setOpusSidecar, before init) — without it Opus cannot be decoded or
|
||||
// seeked, so we fall back by leaving opusDecoder null and using the StreamDecoder path,
|
||||
// which the server's C2 fallback (lossless bytes) matches. In practice the C# resolver only
|
||||
// selects Opus when the sidecar parsed, so the null branch is defensive.
|
||||
if (this.isOpusContentType(contentType) && this.pendingOpusSidecar) {
|
||||
this.activeOpusSidecar = this.pendingOpusSidecar;
|
||||
|
||||
// Align the AudioContext to 48 kHz NOW, before any Opus bytes flow — the format is
|
||||
// already resolved (C# resolves Opus + injects the sidecar before this call), so the
|
||||
// target rate is known up front. Done here, the decoder's own lazy
|
||||
// recreateWithSampleRate(48000) in ensureConfigured hits its sampleRate-equal early
|
||||
// return and is a no-op; the live graph is never close()'d and rebuilt mid-decode (the
|
||||
// teardown that double-decoded the stream and OOM'd the tab with HW accel off). The
|
||||
// recreate seam itself stays — it is the WAV path's mechanism for non-44.1 sources and
|
||||
// remains the defensive backstop here.
|
||||
if (this.contextManager.sampleRate !== OPUS_SAMPLE_RATE) {
|
||||
await this.contextManager.recreateWithSampleRate(OPUS_SAMPLE_RATE);
|
||||
}
|
||||
|
||||
// Pass the shared back-pressure signal (21.2b): the Opus decoder stops demuxing/
|
||||
// decoding new packets while the scheduler is full, so the WebCodecs decode queue
|
||||
// and decodedQueue do not balloon behind a throttled socket (OQ7). Same signal the
|
||||
// C# read loop honors — one policy, two thin hooks.
|
||||
this.opusDecoder = new OpusStreamDecoder(
|
||||
this.contextManager,
|
||||
this.pendingOpusSidecar,
|
||||
() => this.scheduler.evaluateProductionPause());
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// Non-Opus (or Opus-without-sidecar): the existing StreamDecoder path, unchanged. The
|
||||
// context sample rate is untouched here, so the WAV/lossless path is byte-for-byte
|
||||
// unaffected by the Opus up-front alignment above.
|
||||
const formatDecoder = this.createFormatDecoder(contentType);
|
||||
this.streamDecoder.initialize(totalStreamLength, formatDecoder);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
@@ -111,10 +172,41 @@ export class AudioPlayer {
|
||||
}
|
||||
}
|
||||
|
||||
private isOpusContentType(contentType: string): boolean {
|
||||
return contentType.includes('audio/ogg') || contentType.includes('audio/opus');
|
||||
}
|
||||
|
||||
private disposeOpusDecoder(): void {
|
||||
if (this.opusDecoder) {
|
||||
this.opusDecoder.dispose();
|
||||
this.opusDecoder = null;
|
||||
}
|
||||
this.activeOpusSidecar = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Select a format decoder from the response Content-Type.
|
||||
* Inject the Opus sidecar (setup header + seek index) for the next Opus stream. Wave 18.5 calls
|
||||
* this with the raw sidecar bytes (from its one-time HTTP fetch) BEFORE initializeStreaming; the
|
||||
* parsed result is applied to the OpusFormatDecoder when the stream initializes. This is the
|
||||
* injection seam — the player owns no transport, only the parse + hand-off.
|
||||
*
|
||||
* @returns success:false with an error if the bytes are not a valid sidecar blob.
|
||||
*/
|
||||
private static createFormatDecoder(contentType: string): IFormatDecoder {
|
||||
setOpusSidecar(sidecarBytes: Uint8Array): AudioResult {
|
||||
const parsed = parseSidecar(sidecarBytes);
|
||||
if (!parsed) {
|
||||
return { success: false, error: 'Invalid Opus sidecar blob' };
|
||||
}
|
||||
this.pendingOpusSidecar = parsed;
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Select a format decoder from the response Content-Type for the StreamDecoder (wrap-and-decode)
|
||||
* path. Opus is NOT handled here — it routes to the WebCodecs IStreamingDecoder seam in
|
||||
* initializeStreaming. This factory serves WAV/MP3/FLAC only.
|
||||
*/
|
||||
private createFormatDecoder(contentType: string): IFormatDecoder {
|
||||
if (contentType.includes('audio/mpeg') || contentType.includes('audio/mp3')) {
|
||||
return new Mp3FormatDecoder();
|
||||
}
|
||||
@@ -133,16 +225,24 @@ export class AudioPlayer {
|
||||
*/
|
||||
async markStreamComplete(): Promise<StreamingResult> {
|
||||
try {
|
||||
const results = await this.streamDecoder.markStreamComplete();
|
||||
const results = this.opusDecoder
|
||||
? await this.opusDecoder.complete()
|
||||
: (await this.streamDecoder.markStreamComplete()).map(r => r.buffer);
|
||||
if (results.length > 0) {
|
||||
for (const result of results) {
|
||||
this.scheduler.addBuffer(result.buffer);
|
||||
for (const buffer of results) {
|
||||
this.scheduler.addBuffer(buffer);
|
||||
}
|
||||
if (this.streamingStarted && this.isPlaying) {
|
||||
this.scheduler.scheduleNewBuffers();
|
||||
}
|
||||
}
|
||||
this.streamingCompleted = true;
|
||||
// Hand the genuine-end signal to the scheduler AFTER the tail buffers are added and
|
||||
// scheduled: now an empty scheduled queue is a real end-of-track, not a startup gap, so
|
||||
// the scheduler may fire onPlaybackEnded when its queue drains. If the queue was already
|
||||
// empty at this point (the tail produced no buffers, or they were already played),
|
||||
// setStreamComplete finalises immediately.
|
||||
this.scheduler.setStreamComplete(true);
|
||||
return { success: true, bufferCount: this.scheduler.getBufferCount() };
|
||||
} catch (error) {
|
||||
return { success: false, error: (error as Error).message };
|
||||
@@ -150,6 +250,66 @@ export class AudioPlayer {
|
||||
}
|
||||
|
||||
async processStreamingChunk(chunk: Uint8Array): Promise<StreamingResult> {
|
||||
return this.opusDecoder
|
||||
? this.processOpusChunk(chunk)
|
||||
: this.processFormatChunk(chunk);
|
||||
}
|
||||
|
||||
/** Opus (WebCodecs) chunk path. Mirrors processFormatChunk's add->schedule->report shape. */
|
||||
private async processOpusChunk(chunk: Uint8Array): Promise<StreamingResult> {
|
||||
try {
|
||||
const decoder = this.opusDecoder!;
|
||||
const buffers = await decoder.push(chunk);
|
||||
|
||||
// Duration is known up front from the sidecar — surface it as soon as the decoder reports it,
|
||||
// NOT gated on the first decoded buffers. The C# layer locks Duration on the first chunk whose
|
||||
// result carries a value (the `Duration == null` guard), and WebCodecs decode is async, so the
|
||||
// earliest chunks can return zero buffers; gating duration on buffers means C# captures the
|
||||
// initial 0 and never overwrites it — the WAV header path sets duration on chunk 1 because its
|
||||
// header parses synchronously, which is the asymmetry this closes. Set once so a seek (which
|
||||
// reinitialises the decoder) cannot overwrite it.
|
||||
if (this.duration === 0 && decoder.totalDuration) {
|
||||
this.duration = decoder.totalDuration;
|
||||
}
|
||||
|
||||
if (buffers.length > 0) {
|
||||
for (const buffer of buffers) {
|
||||
this.scheduler.addBuffer(buffer);
|
||||
}
|
||||
if (this.streamingStarted && this.isPlaying) {
|
||||
this.scheduler.scheduleNewBuffers();
|
||||
}
|
||||
}
|
||||
|
||||
if (decoder.hasFatalError) {
|
||||
return { success: false, error: 'Opus decode failed' };
|
||||
}
|
||||
|
||||
// "headerParsed" maps to the decoder being configured (codec ready). canStart needs a
|
||||
// healthy decoded lead before first playback — measured in SECONDS, not a buffer count.
|
||||
// An Opus WebCodecs packet is ~20 ms, so the WAV-tuned 6-BUFFER minimum is only ~0.12 s of
|
||||
// lead: playback would start, drain it before the async decode ramps, and underrun
|
||||
// immediately. The seconds-based lead gate (same threshold the scheduler's underrun-resume
|
||||
// hysteresis uses) gives Opus the cushion its decode ramp needs. WAV keeps the buffer-count
|
||||
// gate below — its large synchronous segments rarely underrun and its start must not change.
|
||||
const headerParsed = decoder.ready;
|
||||
const canStart = headerParsed && this.scheduler.hasMinimumPlaybackLead();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
canStartStreaming: canStart,
|
||||
headerParsed,
|
||||
bufferCount: this.scheduler.getBufferCount(),
|
||||
duration: this.duration,
|
||||
productionPaused: this.scheduler.evaluateProductionPause()
|
||||
};
|
||||
} catch (error) {
|
||||
return { success: false, error: (error as Error).message };
|
||||
}
|
||||
}
|
||||
|
||||
/** WAV/MP3/FLAC (StreamDecoder) chunk path — unchanged from before the Opus seam split. */
|
||||
private async processFormatChunk(chunk: Uint8Array): Promise<StreamingResult> {
|
||||
try {
|
||||
const results = await this.streamDecoder.processChunk(chunk);
|
||||
|
||||
@@ -172,9 +332,13 @@ export class AudioPlayer {
|
||||
}
|
||||
}
|
||||
|
||||
// Check if streaming is complete
|
||||
// Check if streaming is complete. The StreamDecoder self-detects completion by byte
|
||||
// count (WAV/MP3/FLAC); propagate that to the scheduler so a drained queue past this
|
||||
// point is treated as a genuine end. Buffers from this chunk were already added above,
|
||||
// so any final end fires through handleSourceEnded when they drain.
|
||||
if (this.streamDecoder.isComplete) {
|
||||
this.streamingCompleted = true;
|
||||
this.scheduler.setStreamComplete(true);
|
||||
}
|
||||
|
||||
const canStart = this.streamDecoder.headerParsed &&
|
||||
@@ -185,7 +349,8 @@ export class AudioPlayer {
|
||||
canStartStreaming: canStart,
|
||||
headerParsed: this.streamDecoder.headerParsed,
|
||||
bufferCount: this.scheduler.getBufferCount(),
|
||||
duration: this.duration
|
||||
duration: this.duration,
|
||||
productionPaused: this.scheduler.evaluateProductionPause()
|
||||
};
|
||||
} catch (error) {
|
||||
return { success: false, error: (error as Error).message };
|
||||
@@ -278,6 +443,7 @@ export class AudioPlayer {
|
||||
try {
|
||||
this.scheduler.clear();
|
||||
this.streamDecoder.reset();
|
||||
this.disposeOpusDecoder();
|
||||
this.resetState();
|
||||
this.stopProgressTracking();
|
||||
|
||||
@@ -296,18 +462,36 @@ export class AudioPlayer {
|
||||
return { success: false, error: 'Invalid seek position' };
|
||||
}
|
||||
|
||||
// bufferStart is the absolute track time at which buffers[0] begins. Under Phase 21.1
|
||||
// partial eviction this is the start of the RETAINED BACK-WINDOW TAIL — eviction advances
|
||||
// playbackOffset as it drops played buffers off the front — so [bufferStart, bufferEnd] is
|
||||
// exactly the window currently held in memory.
|
||||
const bufferStart = this.scheduler.getPlaybackOffset();
|
||||
const bufferEnd = this.scheduler.getTotalDuration() + bufferStart;
|
||||
|
||||
// Position must be within [bufferStart, bufferEnd] to use buffered content.
|
||||
// A lower-bound check is required: after a seek-beyond-buffer, bufferStart is
|
||||
// set to the prior seek position. Seeking to a position below bufferStart would
|
||||
// produce a negative bufferRelativePosition in seekWithinBuffer, silently
|
||||
// clamping to position 0 of the offset buffer instead of the requested time.
|
||||
if (position >= bufferStart && position <= bufferEnd) {
|
||||
// The window-miss test for BOTH directions, and the 21.3 refill trigger for backward seeks.
|
||||
// Position must be within [bufferStart, bufferEnd] AND the scheduler must hold buffers to
|
||||
// resolve from the retained window:
|
||||
// - position >= bufferStart AND hasBuffers : UC3 — seek back within the retained back-window.
|
||||
// Served from buffer with NO network refetch. (The lower bound is load-bearing: after
|
||||
// eviction or a prior seek-beyond-buffer, bufferStart > 0, and a target below it would
|
||||
// otherwise produce a negative bufferRelativePosition in seekWithinBuffer, silently clamping
|
||||
// to position 0.)
|
||||
// - position < bufferStart : UC4 — seek back PAST the retained tail (the window was evicted).
|
||||
// Falls through to seekBeyondBuffer, which is the existing Range path run toward an EARLIER
|
||||
// offset. This is the 21.3 window-miss refill: "a seek the listener didn't initiate" reuses
|
||||
// the same per-path resolver + reinit a forward seek-beyond-buffer uses, no new mechanism.
|
||||
// - position > bufferEnd : UC2/UC5 — forward seek beyond buffer, unchanged.
|
||||
// - !hasBuffers (degenerate [P,P] window post-recovery): the window check above would
|
||||
// spuriously route ANY target to seekWithinBuffer (bufferStart==bufferEnd==seekPosition
|
||||
// after recoverFromFailedRefill). Force seekBeyondBuffer so a same-target retry actually
|
||||
// refetches (AC6 retry contract). The !hasBuffers guard only fires in the degenerate case —
|
||||
// a populated retained window has buffers and is unaffected (AC4 not regressed).
|
||||
if (position >= bufferStart && position <= bufferEnd && this.scheduler.hasBuffers()) {
|
||||
return this.seekWithinBuffer(position);
|
||||
} else {
|
||||
// Seeking outside buffered window - signal C# to fetch new stream
|
||||
// Seeking outside the retained window, or to any position in an empty scheduler —
|
||||
// signal C# to fetch a new stream from the resolved offset.
|
||||
return this.seekBeyondBuffer(position);
|
||||
}
|
||||
}
|
||||
@@ -339,8 +523,25 @@ export class AudioPlayer {
|
||||
*/
|
||||
private seekBeyondBuffer(position: number): AudioResult {
|
||||
try {
|
||||
// The header must be parsed for byte-offset math; without it we cannot
|
||||
// build a valid Range request.
|
||||
// Opus: resolve the offset from the precomputed seek index (the accurate VBR-safe transfer
|
||||
// function). The returned offset is a real page start, so the Range continuation lands the
|
||||
// demuxer/decoder Ogg-sync-aligned. Also capture the landing time (t_page ≤ position) so
|
||||
// reinitializeFromOffset can trim the leading decoded frames and land precisely at `position`
|
||||
// (AC9 fine re-sync, §3.4a step 4).
|
||||
if (this.opusDecoder) {
|
||||
if (!this.activeOpusSidecar) {
|
||||
return { success: false, error: 'Cannot calculate byte offset' };
|
||||
}
|
||||
const resolution: OpusSeekResolution = resolveOpusByteOffset(this.activeOpusSidecar, position);
|
||||
this._seekLandingTime = resolution.landingTimeSeconds;
|
||||
return {
|
||||
success: true,
|
||||
seekBeyondBuffer: true,
|
||||
byteOffset: resolution.byteOffset
|
||||
};
|
||||
}
|
||||
|
||||
// WAV/MP3/FLAC: the header must be parsed for byte-offset math.
|
||||
if (!this.streamDecoder.getFormatInfo()) {
|
||||
return { success: false, error: 'Cannot calculate byte offset' };
|
||||
}
|
||||
@@ -361,6 +562,22 @@ export class AudioPlayer {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the file-absolute byte offset to begin a stream at `position`, WITHOUT requiring active
|
||||
* playback or buffered audio (the "load at timestamp" entry point — Phase 18 wave 18.6 format switch).
|
||||
* Unlike seek(), it has no duration guard and never routes to the within-buffer path: a fresh load has
|
||||
* no scheduler window, so the answer is always "start the byte stream here". For Opus the sidecar
|
||||
* resolves the offset (and captures the page landing time for the lead-trim) immediately after init; for
|
||||
* WAV the header must already be parsed (feed the byte-0 segment first). Returns success:false when the
|
||||
* decoder cannot yet resolve an offset (no header / no sidecar), so the caller can probe and retry.
|
||||
*/
|
||||
resolveStreamOffset(position: number): AudioResult {
|
||||
if (!this.isStreamingMode) {
|
||||
return { success: false, error: 'Not in streaming mode' };
|
||||
}
|
||||
return this.seekBeyondBuffer(position);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the total buffered duration (for C# to check if seek is within buffer)
|
||||
*/
|
||||
@@ -368,10 +585,26 @@ export class AudioPlayer {
|
||||
return this.scheduler.getTotalDuration() + this.scheduler.getPlaybackOffset();
|
||||
}
|
||||
|
||||
/**
|
||||
* The shared back-pressure signal (Phase 21.2a), polled by the C# read loop WHILE it is
|
||||
* already throttled to learn when the forward fill has drained below the low-water mark and it
|
||||
* may resume reading. The steady-state (unthrottled) loop never calls this — it reads the
|
||||
* piggybacked productionPaused flag off each chunk result instead, so there is no extra
|
||||
* interop hop until back-pressure actually engages.
|
||||
*/
|
||||
isProductionPaused(): boolean {
|
||||
return this.scheduler.evaluateProductionPause();
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate byte offset for a time position (for C# layer)
|
||||
*/
|
||||
calculateByteOffset(positionSeconds: number): number {
|
||||
if (this.opusDecoder) {
|
||||
return this.activeOpusSidecar
|
||||
? resolveOpusByteOffset(this.activeOpusSidecar, positionSeconds).byteOffset
|
||||
: 0;
|
||||
}
|
||||
if (!this.streamDecoder.getFormatInfo()) return 0;
|
||||
return this.streamDecoder.calculateByteOffset(positionSeconds);
|
||||
}
|
||||
@@ -384,17 +617,21 @@ export class AudioPlayer {
|
||||
try {
|
||||
// Stop current playback
|
||||
this.stopProgressTracking();
|
||||
const wasPlaying = this.isPlaying;
|
||||
this.isPlaying = false;
|
||||
|
||||
// Clear buffers and set new offset
|
||||
this.scheduler.clearForSeek();
|
||||
this.scheduler.setPlaybackOffset(seekPosition);
|
||||
|
||||
// Reinitialize decoder for the Range-continuation stream. totalStreamLength
|
||||
// here is the 206 Content-Length (range start → EOF), not the full file size —
|
||||
// the decoder uses it to detect stream-complete against raw audio bytes.
|
||||
this.streamDecoder.reinitializeForRangeContinuation(totalStreamLength);
|
||||
// Reinitialize the active decoder for the Range-continuation stream (206 body, no header/
|
||||
// setup pages). Opus resets demux + codec state (keeping the cached config) and arms the
|
||||
// lead-trim so decoded audio starts at `seekPosition`, not at the page boundary (AC9). The
|
||||
// StreamDecoder path uses totalStreamLength (the 206 Content-Length) to detect completion.
|
||||
if (this.opusDecoder) {
|
||||
this.opusDecoder.reinitializeForRangeContinuation(this._seekLandingTime, seekPosition);
|
||||
} else {
|
||||
this.streamDecoder.reinitializeForRangeContinuation(totalStreamLength);
|
||||
}
|
||||
|
||||
// Update state
|
||||
this.pausePosition = seekPosition;
|
||||
@@ -407,6 +644,44 @@ export class AudioPlayer {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recover the player into a clean, paused-but-loaded state after a window-miss REFILL failed
|
||||
* (Phase 21.3 / AC6). A refill is "a seek the listener didn't initiate"; when its Range fetch or
|
||||
* reinit fails mid-stream, the pre-seek loop has already been cancelled and drained, but the
|
||||
* scheduler is still holding stale pre-seek buffers and is still `isActive_`. Left alone it would
|
||||
* play the retained tail to exhaustion and fire `onPlaybackEnded` — a SILENT FALSE END (the
|
||||
* "wedged playing with a starved scheduler" AC6 forbids).
|
||||
*
|
||||
* The recovery mirrors `PlaybackScheduler.playFromPosition`'s end-of-buffer recovery in spirit:
|
||||
* stop pretending to play. We stop all sources and clear the buffers for a seek (clearForSeek
|
||||
* keeps no stale audio but is ready to accept a fresh continuation), set the offset to the
|
||||
* requested seek position, and leave the player paused there. The track stays loaded so the
|
||||
* listener can retry the seek or pick another track — no new transport control, only a recoverable
|
||||
* stop (C4). A subsequent seek to the same target re-enters seekBeyondBuffer cleanly because the
|
||||
* offset names the seek position and the scheduler is empty (so it routes to a fresh fetch).
|
||||
*
|
||||
* @param seekPosition The seek target the failed refill was aiming for; becomes the resume anchor.
|
||||
*/
|
||||
recoverFromFailedRefill(seekPosition: number): AudioResult {
|
||||
try {
|
||||
this.stopProgressTracking();
|
||||
// Halt the starved scheduler and drop the stale pre-seek buffers so no false end can fire.
|
||||
this.scheduler.clearForSeek();
|
||||
this.scheduler.setPlaybackOffset(seekPosition);
|
||||
|
||||
// Paused-but-loaded: not playing, not mid-seek-stream. pausePosition anchors a retry.
|
||||
this.isPlaying = false;
|
||||
this.isPaused = true;
|
||||
this.pausePosition = seekPosition;
|
||||
this.streamingStarted = false;
|
||||
this.streamingCompleted = false;
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: (error as Error).message };
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Volume ====================
|
||||
|
||||
setVolume(volume: number): AudioResult {
|
||||
@@ -510,6 +785,7 @@ export class AudioPlayer {
|
||||
this.isStreamingMode = false;
|
||||
this.streamingStarted = false;
|
||||
this.streamingCompleted = false;
|
||||
this._seekLandingTime = 0;
|
||||
}
|
||||
|
||||
private handlePlaybackEnded(): void {
|
||||
|
||||
@@ -36,6 +36,8 @@ export interface FormatInfo {
|
||||
* MP3 VBR: Xing/VBRI TOC (100-entry Uint8Array, values are file-percentage * 255).
|
||||
* FLAC: SeekTable (array of {sampleNumber: number, streamOffset: number} — stream_offset
|
||||
* is bytes from the start of audio frames, i.e. after all metadata blocks).
|
||||
* Opus does NOT flow through this seam — it uses the WebCodecs IStreamingDecoder path and resolves
|
||||
* seek offsets via OpusSidecar.resolveOpusByteOffset, not FormatInfo.seekData.
|
||||
*/
|
||||
seekData?: Mp3VbrSeekData | FlacSeekData | null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* IStreamingDecoder - the stateful streaming-decode seam, parallel to IFormatDecoder.
|
||||
*
|
||||
* Why a second seam. `IFormatDecoder` (WAV/MP3/FLAC) is a *wrap-and-decode-each-segment* strategy:
|
||||
* `StreamDecoder` cuts the stream into independently-decodable segments, `wrapSegment` makes each a
|
||||
* standalone file, and `decodeAudioData` decodes each in isolation. That model is correct for raw PCM
|
||||
* (WAV) and independently-decodable frames (FLAC), but it is fundamentally wrong for Opus: Opus has
|
||||
* pre-skip (encoder delay) and inter-frame state (MDCT overlap-add, SILK/CELT continuity), so decoding
|
||||
* page-runs independently re-applies the pre-skip and starts from cold codec state at every boundary —
|
||||
* audible glitching and a broken timeline.
|
||||
*
|
||||
* A WebCodecs `AudioDecoder` is the right tool: one stateful decoder fed packets sequentially, decoding
|
||||
* continuously with correct pre-skip-once handling and full inter-frame continuity. But it does NOT fit
|
||||
* `IFormatDecoder` — it is async/callback-driven and owns its own buffering. So Opus gets this seam
|
||||
* instead. `AudioPlayer` dispatches by content-type: WAV/MP3/FLAC keep the `StreamDecoder` path
|
||||
* byte-for-byte; Opus routes here. Both feed the SAME `PlaybackScheduler` — the change is the decode
|
||||
* stage only, never the schedule/playback stage.
|
||||
*
|
||||
* The seam is intentionally minimal and mirrors the lifecycle `StreamDecoder` already exposes so
|
||||
* `AudioPlayer` can treat the two uniformly: initialize -> push chunks -> mark complete, plus a
|
||||
* range-continuation reinit for seek-beyond-buffer.
|
||||
*/
|
||||
|
||||
export interface IStreamingDecoder {
|
||||
/**
|
||||
* Decoded buffers ready to schedule, drained by AudioPlayer after each push/flush. Each entry is a
|
||||
* standard AudioBuffer at the AudioContext's sample rate, ready for PlaybackScheduler.addBuffer.
|
||||
*/
|
||||
readonly hasFatalError: boolean;
|
||||
|
||||
/** True once the decoder has enough to begin playback (header/config established). */
|
||||
readonly ready: boolean;
|
||||
|
||||
/** Total stream duration in seconds if known up front (Opus knows it from the sidecar), else null. */
|
||||
readonly totalDuration: number | null;
|
||||
|
||||
/**
|
||||
* Push raw stream bytes. Returns decoded AudioBuffers that became ready (possibly empty — WebCodecs
|
||||
* decode is async, so a push may return nothing and a later push returns several).
|
||||
*/
|
||||
push(chunk: Uint8Array): Promise<AudioBuffer[]>;
|
||||
|
||||
/**
|
||||
* Signal end-of-stream. Flushes the decoder and returns any residual decoded buffers (including the
|
||||
* end-trimmed final buffer).
|
||||
*/
|
||||
complete(): Promise<AudioBuffer[]>;
|
||||
|
||||
/**
|
||||
* Reinitialize for a Range-continuation after seek-beyond-buffer. The 206 body begins on an Ogg page
|
||||
* boundary and carries no setup pages — the decoder reuses the cached config and resets demux/codec
|
||||
* state so inter-frame continuity restarts cleanly from the new offset.
|
||||
*
|
||||
* @param landingTimeSeconds The actual presentation time of the resolved seek page (t_page ≤ target).
|
||||
* @param targetTimeSeconds The user-requested seek position. The decoder trims the leading
|
||||
* `(target - landing) * sampleRate` frames so playback lands at target
|
||||
* (AC9 fine re-sync, §3.4a step 4).
|
||||
*/
|
||||
reinitializeForRangeContinuation(landingTimeSeconds: number, targetTimeSeconds: number): void;
|
||||
|
||||
/** Tear down the underlying WebCodecs decoder and release resources. */
|
||||
dispose(): void;
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
/**
|
||||
* OggDemuxer - streaming Ogg-page -> Opus-packet demuxer for the WebCodecs decode path.
|
||||
*
|
||||
* Ogg Opus is a containerized, paged format. To feed a WebCodecs `AudioDecoder` we must extract the
|
||||
* individual Opus *packets* from the Ogg container — the decoder takes packets (as `EncodedAudioChunk`s),
|
||||
* not raw container bytes. This module is the client-side analogue of the C# `OggOpusParser`: it reads
|
||||
* the page structure directly (the "OggS" capture pattern + the 27-byte page header + segment table) and
|
||||
* reassembles packets across the lacing, tracking the granule position that gives each packet its time.
|
||||
*
|
||||
* It is deliberately *streaming*: `push(bytes)` accepts arbitrary network chunks (a packet, a page, or a
|
||||
* fraction of either) and returns whatever WHOLE packets have become available, holding partial state
|
||||
* across calls. This matches how `StreamAudioWithEarlyPlayback` feeds bytes in adaptive 16–64 KB chunks.
|
||||
*
|
||||
* Lacing rules (RFC 3533 §6): a page's segment table lists N segment lengths (0–255). A packet is the
|
||||
* concatenation of consecutive segments up to and including the first segment whose length is < 255. A
|
||||
* segment of exactly 255 means "this packet continues into the next segment" — and if it is the page's
|
||||
* LAST segment, the packet continues into the next page (the next page's header-type has the
|
||||
* continuation flag set). The granule position on a page is the end-granule of the LAST packet that
|
||||
* *completes* on that page.
|
||||
*
|
||||
* The two leading setup packets (OpusHead, OpusTags) are NOT audio and are skipped — they configure the
|
||||
* decoder (the sidecar carries them as the codec description), they are never decoded as audio packets.
|
||||
*/
|
||||
|
||||
const OGG_CAPTURE = [0x4f, 0x67, 0x67, 0x53]; // "OggS"
|
||||
const OGG_PAGE_HEADER_SIZE = 27;
|
||||
const GRANULE_OFFSET = 6; // 64-bit granule position within the page header
|
||||
const HEADER_TYPE_OFFSET = 5; // bit 0x01 = continued packet, 0x02 = BOS, 0x04 = EOS
|
||||
const SEGMENT_COUNT_OFFSET = 26; // number of segment-table entries
|
||||
const CONTINUATION_FLAG = 0x01;
|
||||
|
||||
const OPUS_HEAD_SIG = [0x4f, 0x70, 0x75, 0x73, 0x48, 0x65, 0x61, 0x64]; // "OpusHead"
|
||||
const OPUS_TAGS_SIG = [0x4f, 0x70, 0x75, 0x73, 0x54, 0x61, 0x67, 0x73]; // "OpusTags"
|
||||
|
||||
/** A demuxed Opus audio packet plus the timing context needed to schedule and trim it. */
|
||||
export interface OpusPacket {
|
||||
/** Raw Opus packet bytes (one Opus frame's worth — fed straight to the AudioDecoder). */
|
||||
data: Uint8Array;
|
||||
/**
|
||||
* The end-granule of the page this packet completed on, or null if the page carried no usable
|
||||
* granule (mid-stream pages between completion points share the next completing page's granule —
|
||||
* we attach the granule only to the packet that completes on a granule-bearing page). A 48 kHz
|
||||
* sample count; presentation time = (granule - preSkip) / 48000.
|
||||
*/
|
||||
pageGranule: number | null;
|
||||
/** True when this packet completed on the stream's final (EOS) page — drives end-trim. */
|
||||
isLastPage: boolean;
|
||||
}
|
||||
|
||||
/** Read a little-endian uint64 as a JS number (exact to 2^53 — far beyond any real granule). */
|
||||
function readUint64LE(buf: Uint8Array, offset: number): number {
|
||||
let lo = 0;
|
||||
let hi = 0;
|
||||
for (let i = 0; i < 4; i++) lo += buf[offset + i] * 2 ** (8 * i);
|
||||
for (let i = 0; i < 4; i++) hi += buf[offset + 4 + i] * 2 ** (8 * i);
|
||||
return hi * 0x100000000 + lo;
|
||||
}
|
||||
|
||||
function startsWith(buf: Uint8Array, sig: number[]): boolean {
|
||||
if (buf.length < sig.length) return false;
|
||||
for (let i = 0; i < sig.length; i++) if (buf[i] !== sig[i]) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
export class OggDemuxer {
|
||||
// Unconsumed raw bytes carried across push() calls (a page may straddle a network-chunk boundary).
|
||||
private pending: Uint8Array = new Uint8Array(0);
|
||||
// Bytes of a packet that spans pages (255-length last segment + continuation flag next page).
|
||||
private partialPacket: Uint8Array[] = [];
|
||||
// Once both setup packets are seen, every subsequent packet is audio.
|
||||
private setupPacketsSeen = 0;
|
||||
|
||||
/**
|
||||
* Feed raw stream bytes (any size). Returns all WHOLE Opus AUDIO packets that became decodable,
|
||||
* in order. Setup packets (OpusHead/OpusTags) are consumed and skipped. Incomplete trailing bytes
|
||||
* are retained for the next push.
|
||||
*/
|
||||
push(bytes: Uint8Array): OpusPacket[] {
|
||||
this.pending = this.concat(this.pending, bytes);
|
||||
return this.drainPages();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset to a fresh stream. Used on seek/range-continuation: the new 206 body begins on a page
|
||||
* boundary, so all partial-packet and pending state must be dropped. setupPacketsSeen is reset to
|
||||
* 2 (already configured) for a continuation — a mid-stream slice carries no setup pages, only audio
|
||||
* pages — so the demuxer treats the first page's packets as audio immediately.
|
||||
*/
|
||||
reset(isContinuation: boolean): void {
|
||||
this.pending = new Uint8Array(0);
|
||||
this.partialPacket = [];
|
||||
this.setupPacketsSeen = isContinuation ? 2 : 0;
|
||||
}
|
||||
|
||||
private drainPages(): OpusPacket[] {
|
||||
const packets: OpusPacket[] = [];
|
||||
|
||||
for (;;) {
|
||||
const page = this.tryReadPage();
|
||||
if (!page) break;
|
||||
this.parsePage(page, packets);
|
||||
}
|
||||
|
||||
return packets;
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to slice one complete Ogg page off the front of `pending`. Returns null (and leaves `pending`
|
||||
* intact) when a whole page is not yet buffered. Resynchronises by scanning for "OggS" if `pending`
|
||||
* does not start on a page boundary (defensive — the encoder writes contiguous pages, but a
|
||||
* continuation stream could in theory begin mid-garbage; the seek offset is always a page start).
|
||||
*/
|
||||
private tryReadPage(): { header: Uint8Array; segTable: Uint8Array; payload: Uint8Array; total: number } | null {
|
||||
const buf = this.pending;
|
||||
if (buf.length < OGG_PAGE_HEADER_SIZE) return null;
|
||||
|
||||
// Resync: ensure we are positioned at a capture pattern.
|
||||
if (!startsWith(buf, OGG_CAPTURE)) {
|
||||
const sync = this.findCapture(buf, 0);
|
||||
if (sync < 0) {
|
||||
// No capture pattern at all — keep only the last 3 bytes (a capture could straddle).
|
||||
this.pending = buf.subarray(Math.max(0, buf.length - 3));
|
||||
return null;
|
||||
}
|
||||
this.pending = buf.subarray(sync);
|
||||
return this.tryReadPage();
|
||||
}
|
||||
|
||||
const segCount = buf[SEGMENT_COUNT_OFFSET];
|
||||
const segTableEnd = OGG_PAGE_HEADER_SIZE + segCount;
|
||||
if (buf.length < segTableEnd) return null; // segment table not fully buffered yet
|
||||
|
||||
const segTable = buf.subarray(OGG_PAGE_HEADER_SIZE, segTableEnd);
|
||||
let payloadSize = 0;
|
||||
for (let i = 0; i < segCount; i++) payloadSize += segTable[i];
|
||||
|
||||
const total = segTableEnd + payloadSize;
|
||||
if (buf.length < total) return null; // payload not fully buffered yet
|
||||
|
||||
const header = buf.subarray(0, OGG_PAGE_HEADER_SIZE);
|
||||
const payload = buf.subarray(segTableEnd, total);
|
||||
|
||||
// Advance past this page.
|
||||
this.pending = buf.subarray(total);
|
||||
return { header, segTable, payload, total };
|
||||
}
|
||||
|
||||
private parsePage(
|
||||
page: { header: Uint8Array; segTable: Uint8Array; payload: Uint8Array; total: number },
|
||||
out: OpusPacket[]
|
||||
): void {
|
||||
const { header, segTable, payload } = page;
|
||||
const headerType = header[HEADER_TYPE_OFFSET];
|
||||
const continued = (headerType & CONTINUATION_FLAG) !== 0;
|
||||
const isEos = (headerType & 0x04) !== 0;
|
||||
const granule = readUint64LE(header, GRANULE_OFFSET);
|
||||
// 0xFFFFFFFFFFFFFFFF (-1) means "no packet completed on this page" — no usable timestamp.
|
||||
// We check the raw bytes rather than comparing `granule === -1` (or the equivalent JS number):
|
||||
// the full 64-bit sentinel exceeds 2^53 and cannot be represented exactly as an IEEE-754 double,
|
||||
// so the parsed value from readUint64LE would not equal the sentinel. The byte check is exact.
|
||||
const hasGranule = !(header[GRANULE_OFFSET] === 0xff && header[GRANULE_OFFSET + 1] === 0xff &&
|
||||
header[GRANULE_OFFSET + 2] === 0xff && header[GRANULE_OFFSET + 3] === 0xff &&
|
||||
header[GRANULE_OFFSET + 4] === 0xff && header[GRANULE_OFFSET + 5] === 0xff &&
|
||||
header[GRANULE_OFFSET + 6] === 0xff && header[GRANULE_OFFSET + 7] === 0xff);
|
||||
|
||||
// If this page does NOT begin with a continuation, any half-built packet from a prior page is
|
||||
// orphaned (should not happen in a well-formed stream, but never carry garbage forward).
|
||||
if (!continued) this.partialPacket = [];
|
||||
|
||||
// Walk the segment table, reassembling packets. A packet ends at the first segment < 255.
|
||||
const completedPackets: Uint8Array[] = [];
|
||||
let segStart = 0;
|
||||
let cursor = 0;
|
||||
for (let i = 0; i < segTable.length; i++) {
|
||||
const len = segTable[i];
|
||||
cursor += len;
|
||||
if (len < 255) {
|
||||
// Packet boundary: segments [segStart, cursor) form (the tail of) a packet.
|
||||
const slice = payload.subarray(segStart, cursor);
|
||||
if (this.partialPacket.length > 0) {
|
||||
this.partialPacket.push(slice);
|
||||
completedPackets.push(this.flattenPartial());
|
||||
this.partialPacket = [];
|
||||
} else {
|
||||
completedPackets.push(slice);
|
||||
}
|
||||
segStart = cursor;
|
||||
}
|
||||
// len === 255 with i === last segment -> packet spans into the next page (handled below).
|
||||
}
|
||||
|
||||
// Any trailing 255-run that did not terminate is a packet continuing into the next page.
|
||||
if (segStart < cursor) {
|
||||
this.partialPacket.push(payload.subarray(segStart, cursor));
|
||||
}
|
||||
|
||||
// Classify completed packets: the first two whole packets in the whole stream are the setup
|
||||
// packets (OpusHead, OpusTags) and are skipped. Everything after is audio. The page granule is
|
||||
// attached to the LAST completing audio packet on a granule-bearing page (the granule is that
|
||||
// page's end-granule per RFC 7845).
|
||||
for (let p = 0; p < completedPackets.length; p++) {
|
||||
const pkt = completedPackets[p];
|
||||
if (this.setupPacketsSeen < 2) {
|
||||
// Only count packets that are actually the Opus setup headers; guard against a stray
|
||||
// first audio packet being mistaken for setup on a continuation (reset handles that).
|
||||
if (this.setupPacketsSeen === 0 && startsWith(pkt, OPUS_HEAD_SIG)) {
|
||||
this.setupPacketsSeen = 1;
|
||||
continue;
|
||||
}
|
||||
if (this.setupPacketsSeen === 1 && startsWith(pkt, OPUS_TAGS_SIG)) {
|
||||
this.setupPacketsSeen = 2;
|
||||
continue;
|
||||
}
|
||||
// Not a recognised setup packet while we still expected one — treat as audio (a
|
||||
// continuation slice that began mid-stream). Fall through.
|
||||
}
|
||||
|
||||
const isLastCompleting = p === completedPackets.length - 1;
|
||||
out.push({
|
||||
data: pkt,
|
||||
pageGranule: hasGranule && isLastCompleting ? granule : null,
|
||||
isLastPage: isEos
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private flattenPartial(): Uint8Array {
|
||||
if (this.partialPacket.length === 1) return this.partialPacket[0];
|
||||
let len = 0;
|
||||
for (const s of this.partialPacket) len += s.length;
|
||||
const out = new Uint8Array(len);
|
||||
let o = 0;
|
||||
for (const s of this.partialPacket) {
|
||||
out.set(s, o);
|
||||
o += s.length;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private findCapture(buf: Uint8Array, from: number): number {
|
||||
for (let i = from; i + 4 <= buf.length; i++) {
|
||||
if (buf[i] === OGG_CAPTURE[0] && buf[i + 1] === OGG_CAPTURE[1] &&
|
||||
buf[i + 2] === OGG_CAPTURE[2] && buf[i + 3] === OGG_CAPTURE[3]) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
private concat(a: Uint8Array, b: Uint8Array): Uint8Array {
|
||||
if (a.length === 0) return b;
|
||||
if (b.length === 0) return a;
|
||||
const out = new Uint8Array(a.length + b.length);
|
||||
out.set(a, 0);
|
||||
out.set(b, a.length);
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the raw OpusHead identification-header *packet* from the sidecar's setup-header bytes (which
|
||||
* are the verbatim Ogg PAGES wrapping OpusHead + OpusTags). WebCodecs' `AudioDecoderConfig.description`
|
||||
* for Opus is the OpusHead packet (RFC 7845 §5.1), not the Ogg page — so we demux the setup pages and
|
||||
* return the first packet's bytes. Returns null if no OpusHead packet is found.
|
||||
*/
|
||||
export function extractOpusHead(setupHeaderBytes: Uint8Array): Uint8Array | null {
|
||||
// Walk pages manually (the setup region is small — at most two pages) and return the first packet
|
||||
// that starts with the OpusHead signature.
|
||||
let offset = 0;
|
||||
while (offset + OGG_PAGE_HEADER_SIZE <= setupHeaderBytes.length) {
|
||||
if (!(setupHeaderBytes[offset] === OGG_CAPTURE[0] && setupHeaderBytes[offset + 1] === OGG_CAPTURE[1] &&
|
||||
setupHeaderBytes[offset + 2] === OGG_CAPTURE[2] && setupHeaderBytes[offset + 3] === OGG_CAPTURE[3])) {
|
||||
return null;
|
||||
}
|
||||
const segCount = setupHeaderBytes[offset + SEGMENT_COUNT_OFFSET];
|
||||
const segTableEnd = offset + OGG_PAGE_HEADER_SIZE + segCount;
|
||||
if (segTableEnd > setupHeaderBytes.length) return null;
|
||||
let payloadSize = 0;
|
||||
for (let i = 0; i < segCount; i++) payloadSize += setupHeaderBytes[segTableEnd - segCount + i];
|
||||
const payloadStart = segTableEnd;
|
||||
const payloadEnd = payloadStart + payloadSize;
|
||||
if (payloadEnd > setupHeaderBytes.length) return null;
|
||||
|
||||
const payload = setupHeaderBytes.subarray(payloadStart, payloadEnd);
|
||||
if (startsWith(payload, OPUS_HEAD_SIG)) {
|
||||
// The OpusHead packet is the whole first-page payload (it always fits one segment / page).
|
||||
return payload;
|
||||
}
|
||||
offset = payloadEnd;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Channel count from an OpusHead packet (RFC 7845 §5.1: byte 9, after the 8-byte magic + version). */
|
||||
export function opusHeadChannelCount(opusHead: Uint8Array): number {
|
||||
if (opusHead.length < 10) return 2; // safe nominal
|
||||
return opusHead[9];
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* OpusCapability - runtime detection of WebCodecs Ogg-Opus decode support.
|
||||
*
|
||||
* The Opus decode path is a WebCodecs `AudioDecoder` streaming pipeline (OpusStreamDecoder), NOT
|
||||
* `decodeAudioData`. So the capability gate must test the path actually used: whether the browser has
|
||||
* `AudioDecoder` AND supports the `codec: 'opus'` config. `AudioDecoder` is available on Chrome/Edge,
|
||||
* Firefox 130+, and Safari 16.4+; older Safari and older Firefox lack it, and those listeners fall back
|
||||
* to the universal lossless WAV path (§3.4 / OQ2 / AC7 — no listener ever gets silence over a codec gap).
|
||||
*
|
||||
* This module is the detection *seam* only — it answers "can this browser stream-decode Opus via
|
||||
* WebCodecs?". The player (StreamingAudioPlayerService.ResolveStreamFormatAsync) consumes the answer to
|
||||
* choose the delivery format; this module never touches the player or the stream request. The result is
|
||||
* cached after the first probe (capability does not change within a session).
|
||||
*/
|
||||
|
||||
let cachedSupport: Promise<boolean> | null = null;
|
||||
|
||||
/**
|
||||
* Resolve whether this browser can stream-decode Ogg Opus via WebCodecs. Cached after the first call.
|
||||
* Never rejects — any failure (no AudioDecoder, unsupported config, thrown probe) resolves to `false`
|
||||
* (treat as unsupported, fall back to lossless) so an interop error can never silence playback.
|
||||
*/
|
||||
export function canDecodeOggOpus(): Promise<boolean> {
|
||||
if (cachedSupport === null) {
|
||||
cachedSupport = probe();
|
||||
}
|
||||
return cachedSupport;
|
||||
}
|
||||
|
||||
async function probe(): Promise<boolean> {
|
||||
try {
|
||||
if (typeof AudioDecoder === 'undefined' || typeof AudioDecoder.isConfigSupported !== 'function') {
|
||||
return false;
|
||||
}
|
||||
// 48 kHz stereo is the canonical fullband Opus shape this site produces. isConfigSupported does
|
||||
// not need the OpusHead `description` to report codec support, so we probe without it.
|
||||
const result = await AudioDecoder.isConfigSupported({
|
||||
codec: 'opus',
|
||||
sampleRate: 48000,
|
||||
numberOfChannels: 2
|
||||
});
|
||||
return result.supported === true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* OpusSidecar - parser for the per-track Opus seek/setup sidecar artifact.
|
||||
*
|
||||
* The sidecar is built once at transcode time (wave 18.1, C# `OpusSidecar` /
|
||||
* `OggOpusSeekIndex`) and fetched once on track load (wired by wave 18.5). It carries
|
||||
* everything the client needs to seek a VBR Opus stream accurately and to decode any
|
||||
* mid-stream slice:
|
||||
* - the verbatim OpusHead + OpusTags setup pages (prepended to every post-seek slice),
|
||||
* - the precomputed granule->byte seek index (the exact time->byte transfer function),
|
||||
* - the pre_skip and totals needed for presentation-time math and seek clamping.
|
||||
*
|
||||
* This module is the byte-for-byte counterpart to the C# serializer. It is pure: it parses
|
||||
* a blob into an `OpusSeekData` accelerator with no I/O. Wave 18.5 owns the HTTP fetch and
|
||||
* injects the parsed result into `OpusFormatDecoder.setSidecar`.
|
||||
*
|
||||
* Binary layout (all little-endian), matching DeepDrftContent.Processors.Opus:
|
||||
* [uint32 setupHeaderLength]
|
||||
* [setupHeaderLength bytes -> OpusHead + OpusTags pages]
|
||||
* [seek-index blob]:
|
||||
* header (24 bytes):
|
||||
* uint64 totalByteLength
|
||||
* double totalDurationSeconds (pre-skip-corrected)
|
||||
* uint32 pointCount
|
||||
* uint16 preSkip
|
||||
* uint16 reserved
|
||||
* pointCount x 16-byte points:
|
||||
* uint64 granulePosition (48 kHz sample count)
|
||||
* uint64 byteOffset (page-start offset in the Opus file)
|
||||
*/
|
||||
|
||||
/** Opus granule positions are always 48 kHz sample counts, regardless of input rate. */
|
||||
export const OPUS_SAMPLE_RATE = 48000;
|
||||
|
||||
/** Size of the seek-index blob header: totalBytes(8) + duration(8) + count(4) + preSkip(2) + reserved(2). */
|
||||
const SEEK_INDEX_HEADER_SIZE = 24;
|
||||
/** Size of one serialized seek point: granulepos(8) + byteOffset(8). */
|
||||
const SEEK_POINT_SIZE = 16;
|
||||
|
||||
/** One (granule, byteOffset) seek-index entry. Both are page-start-accurate. */
|
||||
export interface OpusSeekPoint {
|
||||
/** Page end granule position — a 48 kHz sample count. */
|
||||
granulePosition: number;
|
||||
/** Byte offset of the page start in the Opus file. */
|
||||
byteOffset: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parsed sidecar: the `seekData` accelerator the `OpusFormatDecoder` holds for the stream's
|
||||
* lifetime. Holds the setup bytes (for `wrapSegment` carry) and the index (for `calculateByteOffset`).
|
||||
*/
|
||||
export interface OpusSeekData {
|
||||
kind: 'opus-sidecar';
|
||||
/** Verbatim OpusHead + OpusTags pages, prepended to every decodable segment. */
|
||||
setupHeaderBytes: Uint8Array;
|
||||
/** Ordered (granule, byteOffset) entries, ascending by granule. */
|
||||
points: OpusSeekPoint[];
|
||||
/** Pre-skip-corrected total stream duration in seconds. */
|
||||
totalDurationSeconds: number;
|
||||
/** Total Opus file byte length, for clamping a seek past the end. */
|
||||
totalByteLength: number;
|
||||
/** pre_skip from OpusHead (RFC 7845 §5.1); samples to discard before presentation. */
|
||||
preSkip: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a sidecar blob produced by the C# `OpusSidecar.ToBytes`. Returns null on any structural
|
||||
* inconsistency (short blob, length prefix overrun, declared point count that does not fit) —
|
||||
* the format is exact, so a malformed blob is corruption, not a recoverable shape.
|
||||
*
|
||||
* Accepts a `Uint8Array`, an `ArrayBuffer`, or a typed-array view; copies nothing it can borrow.
|
||||
*/
|
||||
export function parseSidecar(input: Uint8Array | ArrayBuffer | ArrayBufferView): OpusSeekData | null {
|
||||
const bytes = toUint8Array(input);
|
||||
// DataView over the same backing buffer; honour the view's byteOffset so a borrowed view parses.
|
||||
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||
|
||||
if (bytes.byteLength < 4) return null;
|
||||
|
||||
const setupLength = view.getUint32(0, true);
|
||||
const indexStart = 4 + setupLength;
|
||||
// Need the setup region plus at least the index header.
|
||||
if (bytes.byteLength < indexStart + SEEK_INDEX_HEADER_SIZE) return null;
|
||||
|
||||
// subarray is zero-copy; setup bytes are retained for wrapSegment for the stream's lifetime.
|
||||
const setupHeaderBytes = bytes.subarray(4, indexStart);
|
||||
|
||||
// Seek-index blob header (relative to the DataView, which is bytes-relative).
|
||||
const totalByteLength = readUint64(view, indexStart);
|
||||
const totalDurationSeconds = view.getFloat64(indexStart + 8, true);
|
||||
const pointCount = view.getUint32(indexStart + 16, true);
|
||||
const preSkip = view.getUint16(indexStart + 20, true);
|
||||
// bytes 22-23: reserved — ignored on read, for forward-compatibility (matches C#).
|
||||
|
||||
const pointsStart = indexStart + SEEK_INDEX_HEADER_SIZE;
|
||||
const expectedEnd = pointsStart + pointCount * SEEK_POINT_SIZE;
|
||||
if (bytes.byteLength < expectedEnd) return null;
|
||||
|
||||
const points: OpusSeekPoint[] = new Array(pointCount);
|
||||
let cursor = pointsStart;
|
||||
for (let i = 0; i < pointCount; i++) {
|
||||
const granulePosition = readUint64(view, cursor);
|
||||
const byteOffset = readUint64(view, cursor + 8);
|
||||
points[i] = { granulePosition, byteOffset };
|
||||
cursor += SEEK_POINT_SIZE;
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'opus-sidecar',
|
||||
setupHeaderBytes,
|
||||
points,
|
||||
totalDurationSeconds,
|
||||
totalByteLength,
|
||||
preSkip
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-skip-corrected presentation time for a granule position: max(0, (granule - preSkip) / 48000).
|
||||
* Matches the C# `OggOpusSeekIndex.PresentationTimeSeconds` so client and server agree on the
|
||||
* seek transfer function.
|
||||
*/
|
||||
export function presentationTimeSeconds(granulePosition: number, preSkip: number): number {
|
||||
return Math.max(0, (granulePosition - preSkip) / OPUS_SAMPLE_RATE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of resolving a seek time to a page-start byte offset.
|
||||
* `byteOffset` is the Range request origin; `landingTimeSeconds` is the actual presentation time of that
|
||||
* page (t_page ≤ positionSeconds). The caller uses the delta `positionSeconds - landingTimeSeconds` to
|
||||
* trim the decoded leading frames so playback lands at the requested position, not at t_page (AC9).
|
||||
*/
|
||||
export interface OpusSeekResolution {
|
||||
/** Page-start byte offset to use as the Range request origin (Ogg-sync-aligned). */
|
||||
byteOffset: number;
|
||||
/**
|
||||
* Presentation time of the resolved index page (seconds). Always ≤ positionSeconds. The decoder
|
||||
* must trim `(positionSeconds - landingTimeSeconds) * OPUS_SAMPLE_RATE` leading frames so the
|
||||
* audible start and the reported clock both land at positionSeconds, not at landingTimeSeconds.
|
||||
*/
|
||||
landingTimeSeconds: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a seek time (seconds) to a file-absolute, page-start byte offset via the precomputed index —
|
||||
* the accurate VBR-safe transfer function (§3.4a A/C). Binary-searches for the largest entry whose
|
||||
* presentation time is <= `positionSeconds`. Returns both the page-start byte offset AND the actual
|
||||
* landing time of that page, so callers can trim leading decoded frames to land precisely at
|
||||
* `positionSeconds` (AC9 fine re-sync). NOT interpolation, NOT byteRate math.
|
||||
*
|
||||
* With an empty index it degrades to the start of audio (offset == setup-header length, landing == 0).
|
||||
*
|
||||
* This is the single source of truth for Opus seek-offset math, shared by the seek-beyond-buffer path
|
||||
* (AudioPlayer) and any byte-offset resolver. The Range fetch from this offset lands the decoder
|
||||
* Ogg-sync-aligned because every indexed offset is a real page start.
|
||||
*/
|
||||
export function resolveOpusByteOffset(sidecar: OpusSeekData, positionSeconds: number): OpusSeekResolution {
|
||||
const points = sidecar.points;
|
||||
if (points.length === 0) {
|
||||
return { byteOffset: sidecar.setupHeaderBytes.length, landingTimeSeconds: 0 };
|
||||
}
|
||||
|
||||
let lo = 0;
|
||||
let hi = points.length - 1;
|
||||
let best = 0;
|
||||
while (lo <= hi) {
|
||||
const mid = (lo + hi) >> 1;
|
||||
const t = presentationTimeSeconds(points[mid].granulePosition, sidecar.preSkip);
|
||||
if (t <= positionSeconds) {
|
||||
best = mid;
|
||||
lo = mid + 1;
|
||||
} else {
|
||||
hi = mid - 1;
|
||||
}
|
||||
}
|
||||
return {
|
||||
byteOffset: points[best].byteOffset,
|
||||
landingTimeSeconds: presentationTimeSeconds(points[best].granulePosition, sidecar.preSkip)
|
||||
};
|
||||
}
|
||||
|
||||
function toUint8Array(input: Uint8Array | ArrayBuffer | ArrayBufferView): Uint8Array {
|
||||
if (input instanceof Uint8Array) return input;
|
||||
if (input instanceof ArrayBuffer) return new Uint8Array(input);
|
||||
return new Uint8Array(input.buffer, input.byteOffset, input.byteLength);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a little-endian uint64 as a JS number. Opus byte offsets and granule positions are exact
|
||||
* to 2^53 (~8 PB / ~5,700 years of audio at 48 kHz), far beyond any real file — no BigInt needed,
|
||||
* matching the FLAC seektable's same 2^53 assumption.
|
||||
*/
|
||||
function readUint64(view: DataView, offset: number): number {
|
||||
const lo = view.getUint32(offset, true);
|
||||
const hi = view.getUint32(offset + 4, true);
|
||||
return hi * 0x100000000 + lo;
|
||||
}
|
||||
@@ -0,0 +1,524 @@
|
||||
/**
|
||||
* Opus WebCodecs decode-path tests — the browser-independent pieces.
|
||||
*
|
||||
* The WebCodecs decode/playback/seek itself can only run in a real browser (verified by Daniel), so
|
||||
* these tests cover the pure logic that surrounds it and that determines correctness:
|
||||
* - OggSidecar parse: byte-for-byte round-trip against the C# wire format.
|
||||
* - resolveOpusByteOffset: the seek transfer function (binary search over the precomputed index).
|
||||
* - OggDemuxer: Ogg page -> Opus packet extraction (segment-table lacing, packets spanning pages,
|
||||
* granule tracking, OpusHead/OpusTags setup-packet skipping, continuation reset).
|
||||
* - extractOpusHead / opusHeadChannelCount: pulling the WebCodecs `description` out of the sidecar.
|
||||
*
|
||||
* There is no TS test runner configured in this repo (no package.json, no jest/vitest). This is a
|
||||
* self-contained, zero-dependency test: a tiny inline assert harness, no `node:` imports beyond Buffer
|
||||
* (Node global). It is EXCLUDED from the production tsc build (tsconfig `exclude: Interop/**\/*.test.ts`)
|
||||
* so it never ships in wwwroot/js. To run it (Node 22+ strips TS types natively — no tsc, no deps), the
|
||||
* `.js` import specifiers must resolve to the COMPILED modules, so run a copy from the compiled output:
|
||||
*
|
||||
* # 1. produce the compiled modules (the normal build already does this):
|
||||
* dotnet build DeepDrftPublic/DeepDrftPublic.csproj
|
||||
* # 2. run this test next to the compiled .js siblings (Node strips the types at load):
|
||||
* cp DeepDrftPublic/Interop/audio/OpusStreamDecoder.test.ts DeepDrftPublic/wwwroot/js/audio/
|
||||
* node DeepDrftPublic/wwwroot/js/audio/OpusStreamDecoder.test.ts
|
||||
*
|
||||
* A thrown error / non-zero exit signals failure; "ALL <n> TESTS PASSED" signals success.
|
||||
*
|
||||
* The sidecar bytes built in `makeSidecar` reproduce the C# wire format byte-for-byte
|
||||
* (DeepDrftContent.Processors.Opus.OpusSidecar.ToBytes / OggOpusSeekIndex.ToBytes):
|
||||
* [uint32 setupHeaderLength][setup bytes]
|
||||
* [uint64 totalByteLength][double totalDuration][uint32 count][uint16 preSkip][uint16 reserved]
|
||||
* count x [uint64 granulePosition][uint64 byteOffset] — all little-endian.
|
||||
*/
|
||||
|
||||
import { parseSidecar, presentationTimeSeconds, resolveOpusByteOffset, OPUS_SAMPLE_RATE } from './OpusSidecar.js';
|
||||
import type { OpusSeekData, OpusSeekResolution } from './OpusSidecar.js';
|
||||
import { OggDemuxer, extractOpusHead, opusHeadChannelCount } from './OggDemuxer.js';
|
||||
import { OpusStreamDecoder } from './OpusStreamDecoder.js';
|
||||
|
||||
// --- tiny inline harness (no dependencies) ---------------------------------------------------
|
||||
let passed = 0;
|
||||
const failures: string[] = [];
|
||||
function test(name: string, fn: () => void): void {
|
||||
try {
|
||||
fn();
|
||||
passed++;
|
||||
} catch (e) {
|
||||
failures.push(`FAIL: ${name}\n ${(e as Error).message}`);
|
||||
}
|
||||
}
|
||||
function assertEqual(actual: unknown, expected: unknown, msg?: string): void {
|
||||
if (actual !== expected) {
|
||||
throw new Error(`${msg ?? 'assertEqual'}: expected ${String(expected)}, got ${String(actual)}`);
|
||||
}
|
||||
}
|
||||
function assertArray(actual: ArrayLike<number>, expected: number[], msg?: string): void {
|
||||
const a = Array.from(actual);
|
||||
if (a.length !== expected.length || a.some((v, i) => v !== expected[i])) {
|
||||
throw new Error(`${msg ?? 'assertArray'}: expected [${expected}], got [${a}]`);
|
||||
}
|
||||
}
|
||||
function assertNull(actual: unknown, msg?: string): void {
|
||||
if (actual !== null) throw new Error(`${msg ?? 'assertNull'}: expected null, got ${String(actual)}`);
|
||||
}
|
||||
function assertNotNull<T>(actual: T | null, msg?: string): T {
|
||||
if (actual === null) throw new Error(`${msg ?? 'assertNotNull'}: got null`);
|
||||
return actual;
|
||||
}
|
||||
|
||||
interface SidecarSpec {
|
||||
setupHeader: number[];
|
||||
totalByteLength: number;
|
||||
totalDuration: number;
|
||||
preSkip: number;
|
||||
points: Array<{ granule: number; byteOffset: number }>;
|
||||
}
|
||||
|
||||
/** Serialize a sidecar blob exactly as the C# OpusSidecar/OggOpusSeekIndex writers do. */
|
||||
function makeSidecar(spec: SidecarSpec): Uint8Array {
|
||||
const SEEK_INDEX_HEADER_SIZE = 24;
|
||||
const SEEK_POINT_SIZE = 16;
|
||||
const setupLen = spec.setupHeader.length;
|
||||
const total = 4 + setupLen + SEEK_INDEX_HEADER_SIZE + spec.points.length * SEEK_POINT_SIZE;
|
||||
|
||||
const bytes = new Uint8Array(total);
|
||||
const view = new DataView(bytes.buffer);
|
||||
|
||||
view.setUint32(0, setupLen, true);
|
||||
bytes.set(spec.setupHeader, 4);
|
||||
|
||||
let p = 4 + setupLen;
|
||||
writeUint64(view, p, spec.totalByteLength);
|
||||
view.setFloat64(p + 8, spec.totalDuration, true);
|
||||
view.setUint32(p + 16, spec.points.length, true);
|
||||
view.setUint16(p + 20, spec.preSkip, true);
|
||||
// bytes 22-23 reserved (zero)
|
||||
|
||||
p += SEEK_INDEX_HEADER_SIZE;
|
||||
for (const pt of spec.points) {
|
||||
writeUint64(view, p, pt.granule);
|
||||
writeUint64(view, p + 8, pt.byteOffset);
|
||||
p += SEEK_POINT_SIZE;
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function writeUint64(view: DataView, offset: number, value: number): void {
|
||||
view.setUint32(offset, value >>> 0, true);
|
||||
view.setUint32(offset + 4, Math.floor(value / 0x100000000), true);
|
||||
}
|
||||
|
||||
// --- parseSidecar: byte-for-byte round-trip against the C# layout -----------------------------
|
||||
|
||||
test('parseSidecar round-trips the C# binary layout exactly', () => {
|
||||
const setup = [0x4f, 0x70, 0x75, 0x73, 0x48, 0x65, 0x61, 0x64]; // "OpusHead" stand-in
|
||||
const spec: SidecarSpec = {
|
||||
setupHeader: setup,
|
||||
totalByteLength: 1_234_567,
|
||||
totalDuration: 212.5,
|
||||
preSkip: 312,
|
||||
points: [
|
||||
{ granule: 312, byteOffset: 4096 }, // first point: granule == preSkip -> t=0
|
||||
{ granule: 312 + 24000, byteOffset: 9000 }, // +0.5 s
|
||||
{ granule: 312 + 48000, byteOffset: 14000 }, // +1.0 s
|
||||
],
|
||||
};
|
||||
|
||||
const parsed: OpusSeekData = assertNotNull(parseSidecar(makeSidecar(spec)));
|
||||
assertEqual(parsed.kind, 'opus-sidecar', 'kind');
|
||||
assertArray(parsed.setupHeaderBytes, setup, 'setup header bytes');
|
||||
assertEqual(parsed.totalByteLength, spec.totalByteLength, 'totalByteLength');
|
||||
assertEqual(parsed.totalDurationSeconds, spec.totalDuration, 'totalDuration');
|
||||
assertEqual(parsed.preSkip, spec.preSkip, 'preSkip');
|
||||
assertEqual(parsed.points.length, 3, 'point count');
|
||||
assertEqual(parsed.points[1].granulePosition, 312 + 24000, 'point[1].granule');
|
||||
assertEqual(parsed.points[1].byteOffset, 9000, 'point[1].byteOffset');
|
||||
});
|
||||
|
||||
test('parseSidecar honours a borrowed view byteOffset (sidecar not at buffer start)', () => {
|
||||
const blob = makeSidecar({
|
||||
setupHeader: [1, 2, 3, 4],
|
||||
totalByteLength: 100,
|
||||
totalDuration: 1.0,
|
||||
preSkip: 0,
|
||||
points: [{ granule: 0, byteOffset: 8 }],
|
||||
});
|
||||
const padded = new Uint8Array(blob.length + 7);
|
||||
padded.set(blob, 7);
|
||||
const parsed = assertNotNull(parseSidecar(padded.subarray(7)));
|
||||
assertArray(parsed.setupHeaderBytes, [1, 2, 3, 4], 'borrowed setup bytes');
|
||||
assertEqual(parsed.points[0].byteOffset, 8, 'borrowed point offset');
|
||||
});
|
||||
|
||||
test('parseSidecar returns null on a truncated blob', () => {
|
||||
const blob = makeSidecar({
|
||||
setupHeader: [0],
|
||||
totalByteLength: 1,
|
||||
totalDuration: 0,
|
||||
preSkip: 0,
|
||||
points: [{ granule: 0, byteOffset: 0 }],
|
||||
});
|
||||
assertNull(parseSidecar(blob.subarray(0, 3)), 'short of length prefix');
|
||||
assertNull(parseSidecar(blob.subarray(0, blob.length - 4)), 'declared count overruns');
|
||||
});
|
||||
|
||||
test('presentationTimeSeconds applies preSkip and clamps at zero (RFC 7845)', () => {
|
||||
assertEqual(presentationTimeSeconds(312, 312), 0, 'granule == preSkip');
|
||||
assertEqual(presentationTimeSeconds(0, 312), 0, 'below preSkip clamps');
|
||||
assertEqual(presentationTimeSeconds(312 + OPUS_SAMPLE_RATE, 312), 1.0, '+48000 -> 1 s');
|
||||
});
|
||||
|
||||
// --- resolveOpusByteOffset: binary search over the precomputed index (exact, not interpolation) -
|
||||
|
||||
function sidecarFrom(spec: SidecarSpec): OpusSeekData {
|
||||
return assertNotNull(parseSidecar(makeSidecar(spec)), 'sidecar should parse');
|
||||
}
|
||||
|
||||
test('resolveOpusByteOffset returns the page-start of the largest entry with time <= t', () => {
|
||||
const points = [0, 1, 2, 3].map(i => ({
|
||||
granule: 1000 + i * (OPUS_SAMPLE_RATE / 2),
|
||||
byteOffset: 4096 + i * 5000,
|
||||
}));
|
||||
const sc = sidecarFrom({
|
||||
setupHeader: [9, 9, 9, 9], totalByteLength: 999_999, totalDuration: 1.5, preSkip: 1000, points,
|
||||
});
|
||||
assertEqual(resolveOpusByteOffset(sc, 0.0).byteOffset, 4096, 't=0 -> first point');
|
||||
assertEqual(resolveOpusByteOffset(sc, 0.4).byteOffset, 4096, 'just before bucket 1');
|
||||
assertEqual(resolveOpusByteOffset(sc, 0.5).byteOffset, 9096, 'exactly bucket 1');
|
||||
assertEqual(resolveOpusByteOffset(sc, 0.9).byteOffset, 9096, 'within bucket 1');
|
||||
assertEqual(resolveOpusByteOffset(sc, 1.0).byteOffset, 14096, 'exactly bucket 2');
|
||||
assertEqual(resolveOpusByteOffset(sc, 99).byteOffset, 19096, 'past end -> last point');
|
||||
});
|
||||
|
||||
test('resolveOpusByteOffset never interpolates between points', () => {
|
||||
const sc = sidecarFrom({
|
||||
setupHeader: [0], totalByteLength: 10_000, totalDuration: 1.0, preSkip: 0,
|
||||
points: [{ granule: 0, byteOffset: 100 }, { granule: OPUS_SAMPLE_RATE, byteOffset: 9000 }],
|
||||
});
|
||||
assertEqual(resolveOpusByteOffset(sc, 0.5).byteOffset, 100, 'midpoint snaps to lower page start');
|
||||
});
|
||||
|
||||
test('resolveOpusByteOffset degrades to start of audio with an empty index', () => {
|
||||
const sc = sidecarFrom({
|
||||
setupHeader: [1, 2, 3, 4, 5], totalByteLength: 0, totalDuration: 0, preSkip: 0, points: [],
|
||||
});
|
||||
// start of audio == setup header length (server emits [setup pages][audio pages]).
|
||||
assertEqual(resolveOpusByteOffset(sc, 10).byteOffset, 5, 'empty index degrades to audio start');
|
||||
});
|
||||
|
||||
// --- resolveOpusByteOffset: landingTimeSeconds (AC9 fine re-sync, §3.4a step 4) -----------------
|
||||
|
||||
test('resolveOpusByteOffset landingTimeSeconds equals the resolved page time, not the requested time', () => {
|
||||
// Index: two points at t=0 s and t=0.5 s.
|
||||
const preSkip = 312;
|
||||
const sc = sidecarFrom({
|
||||
setupHeader: [0], totalByteLength: 50_000, totalDuration: 1.5, preSkip,
|
||||
points: [
|
||||
{ granule: preSkip, byteOffset: 4096 }, // t=0
|
||||
{ granule: preSkip + OPUS_SAMPLE_RATE / 2, byteOffset: 9000 }, // t=0.5 s
|
||||
],
|
||||
});
|
||||
// Seeking to 0.3 s lands on the t=0 page; landing should be 0, not 0.3.
|
||||
const r03: OpusSeekResolution = resolveOpusByteOffset(sc, 0.3);
|
||||
assertEqual(r03.byteOffset, 4096, 'seek 0.3 -> first page offset');
|
||||
assertEqual(r03.landingTimeSeconds, 0, 'landing at t=0 (page time, not target)');
|
||||
|
||||
// Seeking to exactly 0.5 s lands on the second page; landing == requested time.
|
||||
const r05: OpusSeekResolution = resolveOpusByteOffset(sc, 0.5);
|
||||
assertEqual(r05.byteOffset, 9000, 'seek 0.5 -> second page offset');
|
||||
assertEqual(r05.landingTimeSeconds, 0.5, 'landing == requested when exact page boundary');
|
||||
});
|
||||
|
||||
test('resolveOpusByteOffset empty index returns landingTimeSeconds = 0', () => {
|
||||
const sc = sidecarFrom({
|
||||
setupHeader: [0, 1, 2], totalByteLength: 1000, totalDuration: 1.0, preSkip: 0, points: [],
|
||||
});
|
||||
const r = resolveOpusByteOffset(sc, 5.0);
|
||||
assertEqual(r.landingTimeSeconds, 0, 'empty index: landing is stream start (0 s)');
|
||||
});
|
||||
|
||||
// --- Lead-trim frame math (AC9 fine re-sync) ---------------------------------------------------
|
||||
// The trim frame count is purely arithmetic: (target - landing) * 48000, rounded, clamped to ≥0.
|
||||
// This is the exact formula in OpusStreamDecoder.reinitializeForRangeContinuation so we test it
|
||||
// independently of the browser-bound WebCodecs decode.
|
||||
|
||||
function leadTrimFrames(landingTimeSeconds: number, targetTimeSeconds: number): number {
|
||||
return Math.max(0, Math.round((targetTimeSeconds - landingTimeSeconds) * OPUS_SAMPLE_RATE));
|
||||
}
|
||||
|
||||
test('lead-trim frame count is (target - landing) * 48000, rounded', () => {
|
||||
// Page at t=0, seek to 0.3 s: trim 0.3 * 48000 = 14400 frames.
|
||||
assertEqual(leadTrimFrames(0, 0.3), 14400, 'trim for 0.3 s offset');
|
||||
// Page at t=0.5 s, seek to 0.7 s: trim 0.2 * 48000 = 9600 frames.
|
||||
assertEqual(leadTrimFrames(0.5, 0.7), 9600, 'trim for 0.2 s offset');
|
||||
// Exact page boundary: no trim needed.
|
||||
assertEqual(leadTrimFrames(0.5, 0.5), 0, 'no trim when target == landing');
|
||||
// Guard against floating-point rounding producing a tiny negative: clamp to 0.
|
||||
assertEqual(leadTrimFrames(0.5000001, 0.5), 0, 'negative rounds to zero (guard)');
|
||||
});
|
||||
|
||||
// --- OggDemuxer: page -> packet extraction ----------------------------------------------------
|
||||
//
|
||||
// Builds minimal Ogg pages by hand (no codec) so the lacing logic is exercised deterministically.
|
||||
|
||||
interface PageSpec {
|
||||
granule: number; // -1 (0xFFFF...) means "no granule"
|
||||
continued?: boolean; // header-type bit 0x01
|
||||
eos?: boolean; // header-type bit 0x04
|
||||
/** Packet payloads to lace into this page (each split into 255-byte segments per Ogg rules). */
|
||||
packets?: Uint8Array[];
|
||||
/** Raw segment lengths + payload, for hand-crafting page-spanning packets. */
|
||||
rawSegments?: number[];
|
||||
rawPayload?: Uint8Array;
|
||||
}
|
||||
|
||||
function buildPage(spec: PageSpec): Uint8Array {
|
||||
let segTable: number[];
|
||||
let payload: Uint8Array;
|
||||
|
||||
if (spec.rawSegments && spec.rawPayload) {
|
||||
segTable = spec.rawSegments;
|
||||
payload = spec.rawPayload;
|
||||
} else {
|
||||
segTable = [];
|
||||
const chunks: number[] = [];
|
||||
for (const pkt of spec.packets ?? []) {
|
||||
let remaining = pkt.length;
|
||||
let o = 0;
|
||||
// Lace: emit 255-byte segments until the final (< 255) segment terminates the packet.
|
||||
for (;;) {
|
||||
const seg = Math.min(255, remaining);
|
||||
segTable.push(seg);
|
||||
for (let i = 0; i < seg; i++) chunks.push(pkt[o + i]);
|
||||
o += seg;
|
||||
remaining -= seg;
|
||||
if (seg < 255) break; // terminating segment
|
||||
}
|
||||
}
|
||||
payload = new Uint8Array(chunks);
|
||||
}
|
||||
|
||||
const header = new Uint8Array(OGG_HDR + segTable.length + payload.length);
|
||||
header.set([0x4f, 0x67, 0x67, 0x53], 0); // "OggS"
|
||||
header[4] = 0; // version
|
||||
header[5] = (spec.continued ? 0x01 : 0) | (spec.eos ? 0x04 : 0);
|
||||
// granule (LE uint64)
|
||||
if (spec.granule < 0) {
|
||||
for (let i = 0; i < 8; i++) header[6 + i] = 0xff;
|
||||
} else {
|
||||
let g = spec.granule;
|
||||
for (let i = 0; i < 8; i++) { header[6 + i] = g & 0xff; g = Math.floor(g / 256); }
|
||||
}
|
||||
header[26] = segTable.length;
|
||||
header.set(segTable, OGG_HDR);
|
||||
header.set(payload, OGG_HDR + segTable.length);
|
||||
return header;
|
||||
}
|
||||
const OGG_HDR = 27;
|
||||
|
||||
function opusHeadPacket(channels: number, preSkip: number): Uint8Array {
|
||||
// "OpusHead"(8) version(1) channels(1) preSkip(2 LE) inputRate(4) gain(2) mapping(1) = 19 bytes
|
||||
const p = new Uint8Array(19);
|
||||
p.set([0x4f, 0x70, 0x75, 0x73, 0x48, 0x65, 0x61, 0x64], 0);
|
||||
p[8] = 1;
|
||||
p[9] = channels;
|
||||
p[10] = preSkip & 0xff;
|
||||
p[11] = (preSkip >> 8) & 0xff;
|
||||
return p;
|
||||
}
|
||||
function opusTagsPacket(): Uint8Array {
|
||||
const p = new Uint8Array(16);
|
||||
p.set([0x4f, 0x70, 0x75, 0x73, 0x54, 0x61, 0x67, 0x73], 0); // "OpusTags"
|
||||
return p;
|
||||
}
|
||||
|
||||
test('OggDemuxer skips OpusHead/OpusTags and returns audio packets with the page granule', () => {
|
||||
const head = buildPage({ granule: 0, packets: [opusHeadPacket(2, 312)] });
|
||||
const tags = buildPage({ granule: 0, packets: [opusTagsPacket()] });
|
||||
const audio = buildPage({ granule: 24000, packets: [new Uint8Array([0xaa, 0xbb]), new Uint8Array([0xcc])] });
|
||||
|
||||
const d = new OggDemuxer();
|
||||
const packets = d.push(concat([head, tags, audio]));
|
||||
assertEqual(packets.length, 2, 'two audio packets, setup skipped');
|
||||
assertArray(packets[0].data, [0xaa, 0xbb], 'first audio packet bytes');
|
||||
assertEqual(packets[0].pageGranule, null, 'non-final packet carries no granule');
|
||||
assertArray(packets[1].data, [0xcc], 'second audio packet bytes');
|
||||
assertEqual(packets[1].pageGranule, 24000, 'final completing packet carries the page granule');
|
||||
assertEqual(packets[1].isLastPage, false, 'not EOS');
|
||||
});
|
||||
|
||||
test('OggDemuxer flags the EOS page', () => {
|
||||
const head = buildPage({ granule: 0, packets: [opusHeadPacket(1, 100)] });
|
||||
const tags = buildPage({ granule: 0, packets: [opusTagsPacket()] });
|
||||
const audio = buildPage({ granule: 48000, eos: true, packets: [new Uint8Array([0x01])] });
|
||||
const d = new OggDemuxer();
|
||||
const packets = d.push(concat([head, tags, audio]));
|
||||
assertEqual(packets.length, 1, 'one audio packet');
|
||||
assertEqual(packets[0].isLastPage, true, 'EOS flagged');
|
||||
});
|
||||
|
||||
test('OggDemuxer reassembles a packet that spans two pages (255 last segment + continuation)', () => {
|
||||
const head = buildPage({ granule: 0, packets: [opusHeadPacket(2, 0)] });
|
||||
const tags = buildPage({ granule: 0, packets: [opusTagsPacket()] });
|
||||
// First audio page: one 255-byte segment that does NOT terminate (packet continues).
|
||||
const part1 = new Uint8Array(255).fill(0x11);
|
||||
const pageA = buildPage({ granule: -1, rawSegments: [255], rawPayload: part1 });
|
||||
// Second page (continued): a 10-byte terminating segment completes the packet.
|
||||
const part2 = new Uint8Array(10).fill(0x22);
|
||||
const pageB = buildPage({ granule: 24000, continued: true, rawSegments: [10], rawPayload: part2 });
|
||||
|
||||
const d = new OggDemuxer();
|
||||
const packets = d.push(concat([head, tags, pageA, pageB]));
|
||||
assertEqual(packets.length, 1, 'one reassembled packet');
|
||||
assertEqual(packets[0].data.length, 265, 'packet is 255 + 10 bytes');
|
||||
assertEqual(packets[0].data[0], 0x11, 'first byte from page A');
|
||||
assertEqual(packets[0].data[264], 0x22, 'last byte from page B');
|
||||
assertEqual(packets[0].pageGranule, 24000, 'granule from the completing page');
|
||||
});
|
||||
|
||||
test('OggDemuxer handles bytes split across push() calls (page straddles a network chunk)', () => {
|
||||
const head = buildPage({ granule: 0, packets: [opusHeadPacket(2, 0)] });
|
||||
const tags = buildPage({ granule: 0, packets: [opusTagsPacket()] });
|
||||
const audio = buildPage({ granule: 960, packets: [new Uint8Array([0x07, 0x08, 0x09])] });
|
||||
const full = concat([head, tags, audio]);
|
||||
|
||||
const d = new OggDemuxer();
|
||||
const cut = full.length - 2; // split mid-audio-page
|
||||
const first = d.push(full.subarray(0, cut));
|
||||
assertEqual(first.length, 0, 'no whole audio packet yet');
|
||||
const second = d.push(full.subarray(cut));
|
||||
assertEqual(second.length, 1, 'audio packet completes on the second push');
|
||||
assertArray(second[0].data, [0x07, 0x08, 0x09], 'reassembled across pushes');
|
||||
});
|
||||
|
||||
test('OggDemuxer.reset(continuation) treats the first page as audio (no setup expected)', () => {
|
||||
const audio = buildPage({ granule: 96000, packets: [new Uint8Array([0x42])] });
|
||||
const d = new OggDemuxer();
|
||||
d.reset(true);
|
||||
const packets = d.push(audio);
|
||||
assertEqual(packets.length, 1, 'continuation: first page is audio');
|
||||
assertArray(packets[0].data, [0x42], 'audio packet bytes');
|
||||
});
|
||||
|
||||
// --- extractOpusHead / opusHeadChannelCount: WebCodecs description from the sidecar -----------
|
||||
|
||||
test('extractOpusHead returns the OpusHead packet from the setup pages', () => {
|
||||
const head = buildPage({ granule: 0, packets: [opusHeadPacket(2, 312)] });
|
||||
const tags = buildPage({ granule: 0, packets: [opusTagsPacket()] });
|
||||
const setup = concat([head, tags]);
|
||||
const opusHead = assertNotNull(extractOpusHead(setup), 'OpusHead extracted');
|
||||
assertArray(opusHead.subarray(0, 8), [0x4f, 0x70, 0x75, 0x73, 0x48, 0x65, 0x61, 0x64], 'OpusHead magic');
|
||||
assertEqual(opusHeadChannelCount(opusHead), 2, 'channel count');
|
||||
});
|
||||
|
||||
test('extractOpusHead returns null when no OpusHead page is present', () => {
|
||||
const tags = buildPage({ granule: 0, packets: [opusTagsPacket()] });
|
||||
assertNull(extractOpusHead(tags), 'no OpusHead');
|
||||
});
|
||||
|
||||
// --- OpusStreamDecoder.totalDuration: available from the sidecar BEFORE the first push ----------
|
||||
//
|
||||
// Defect 1 (dead Opus seekbar): the C# layer locks the UI Duration on the first chunk whose result
|
||||
// carries a value, and AudioPlayer.processOpusChunk now surfaces `decoder.totalDuration` on that first
|
||||
// chunk rather than gating it on the (async, possibly-empty-on-chunk-1) decoded buffers. The load-bearing
|
||||
// guarantee that makes this correct is that `totalDuration` is known from the sidecar IMMEDIATELY — i.e.
|
||||
// before any push and without WebCodecs. These tests pin that contract; the WebCodecs decode itself stays
|
||||
// browser-verified. The constructor only stashes the context manager (totalDuration never touches it), so a
|
||||
// null-shaped stub is safe and no AudioDecoder is constructed.
|
||||
|
||||
const stubContextManager = {} as unknown as ConstructorParameters<typeof OpusStreamDecoder>[0];
|
||||
|
||||
test('OpusStreamDecoder.totalDuration is the sidecar duration, available before any push', () => {
|
||||
const sidecar = sidecarFrom({
|
||||
setupHeader: [0x4f, 0x70, 0x75, 0x73, 0x48, 0x65, 0x61, 0x64],
|
||||
totalByteLength: 500_000, totalDuration: 212.5, preSkip: 312,
|
||||
points: [{ granule: 312, byteOffset: 4096 }],
|
||||
});
|
||||
const decoder = new OpusStreamDecoder(stubContextManager, sidecar);
|
||||
// No push, no configure — the value the first chunk reports to C# must already be present.
|
||||
assertEqual(decoder.totalDuration, 212.5, 'totalDuration from sidecar, pre-push');
|
||||
});
|
||||
|
||||
test('OpusStreamDecoder.totalDuration is null when the sidecar carries no positive duration', () => {
|
||||
const sidecar = sidecarFrom({
|
||||
setupHeader: [0], totalByteLength: 0, totalDuration: 0, preSkip: 0, points: [],
|
||||
});
|
||||
const decoder = new OpusStreamDecoder(stubContextManager, sidecar);
|
||||
// A zero/absent sidecar duration must report null (not 0) so the chunk result carries no spurious
|
||||
// value — the WAV-header path, not a bogus Opus duration, then drives the UI.
|
||||
assertEqual(decoder.totalDuration, null, 'no positive duration -> null');
|
||||
});
|
||||
|
||||
// --- Phase 21.2b: Opus decode-ahead back-pressure (the stash-while-full half) ------------------
|
||||
//
|
||||
// When the shared scheduler is full, push() must NOT demux/decode ahead — it stashes the raw bytes
|
||||
// and returns nothing, so the WebCodecs decode queue and decodedQueue stay near-empty (OQ7). The
|
||||
// stash-while-full branch returns BEFORE ensureConfigured(), so it is testable without WebCodecs
|
||||
// (no AudioDecoder is constructed). The drain-on-resume path needs the real WebCodecs decoder and
|
||||
// stays browser-verified; here we pin the bound itself and the lifecycle resets.
|
||||
|
||||
// Access the private stash for white-box assertions (same idiom the scheduler tests use).
|
||||
function stashLength(decoder: OpusStreamDecoder): number {
|
||||
return (decoder as unknown as { pendingBytes: Uint8Array[] }).pendingBytes.length;
|
||||
}
|
||||
|
||||
// The stash-while-full branch returns synchronously at the top of push() (before any real await),
|
||||
// so the stash is observable immediately without awaiting the returned promise — keeping these
|
||||
// tests inside the synchronous inline harness (which does not await test bodies).
|
||||
test('push stashes bytes and decodes nothing while the scheduler is full (no decode-ahead)', () => {
|
||||
const sidecar = sidecarFrom({
|
||||
setupHeader: [0x4f, 0x70, 0x75, 0x73, 0x48, 0x65, 0x61, 0x64],
|
||||
totalByteLength: 500_000, totalDuration: 100, preSkip: 312,
|
||||
points: [{ granule: 312, byteOffset: 4096 }],
|
||||
});
|
||||
// Scheduler reports "full" → push must short-circuit before touching WebCodecs.
|
||||
const decoder = new OpusStreamDecoder(stubContextManager, sidecar, () => true);
|
||||
|
||||
void decoder.push(new Uint8Array([1, 2, 3]));
|
||||
void decoder.push(new Uint8Array([4, 5]));
|
||||
|
||||
assertEqual(stashLength(decoder), 2, 'both chunks stashed in arrival order');
|
||||
assertEqual(decoder.ready, false, 'decoder not even configured while throttled');
|
||||
});
|
||||
|
||||
test('reinitializeForRangeContinuation drops the pre-seek stash (C6 — no stale feed across reset)', () => {
|
||||
const sidecar = sidecarFrom({
|
||||
setupHeader: [0x4f, 0x70, 0x75, 0x73, 0x48, 0x65, 0x61, 0x64],
|
||||
totalByteLength: 500_000, totalDuration: 100, preSkip: 312,
|
||||
points: [{ granule: 312, byteOffset: 4096 }],
|
||||
});
|
||||
const decoder = new OpusStreamDecoder(stubContextManager, sidecar, () => true);
|
||||
void decoder.push(new Uint8Array([1, 2, 3])); // stash one chunk while full
|
||||
assertEqual(stashLength(decoder), 1, 'one chunk stashed pre-seek');
|
||||
|
||||
decoder.reinitializeForRangeContinuation(0, 5); // a seek
|
||||
assertEqual(stashLength(decoder), 0, 'pre-seek stash dropped on range-continuation');
|
||||
});
|
||||
|
||||
test('dispose clears the stash', () => {
|
||||
const sidecar = sidecarFrom({
|
||||
setupHeader: [0x4f, 0x70, 0x75, 0x73, 0x48, 0x65, 0x61, 0x64],
|
||||
totalByteLength: 500_000, totalDuration: 100, preSkip: 312,
|
||||
points: [{ granule: 312, byteOffset: 4096 }],
|
||||
});
|
||||
const decoder = new OpusStreamDecoder(stubContextManager, sidecar, () => true);
|
||||
void decoder.push(new Uint8Array([9]));
|
||||
assertEqual(stashLength(decoder), 1, 'stashed');
|
||||
decoder.dispose();
|
||||
assertEqual(stashLength(decoder), 0, 'stash cleared on dispose');
|
||||
});
|
||||
|
||||
function concat(arrs: Uint8Array[]): Uint8Array {
|
||||
let len = 0;
|
||||
for (const a of arrs) len += a.length;
|
||||
const out = new Uint8Array(len);
|
||||
let o = 0;
|
||||
for (const a of arrs) { out.set(a, o); o += a.length; }
|
||||
return out;
|
||||
}
|
||||
|
||||
// --- report ----------------------------------------------------------------------------------
|
||||
if (failures.length > 0) {
|
||||
console.error(failures.join('\n'));
|
||||
throw new Error(`${failures.length} test(s) failed, ${passed} passed`);
|
||||
}
|
||||
console.log(`ALL ${passed} TESTS PASSED`);
|
||||
@@ -0,0 +1,395 @@
|
||||
/**
|
||||
* OpusStreamDecoder - the WebCodecs streaming Opus decode pipeline.
|
||||
*
|
||||
* This replaces the fundamentally-broken per-segment `decodeAudioData` Opus model. Instead of cutting
|
||||
* the Ogg stream into page-runs and decoding each as a standalone file (which re-applies pre-skip and
|
||||
* starts from cold codec state at every boundary), it feeds a single stateful WebCodecs `AudioDecoder`
|
||||
* the demuxed Opus packets in order — correct pre-skip-once handling and full inter-frame continuity.
|
||||
*
|
||||
* Pipeline: OggDemuxer (pages -> Opus packets + granule) -> AudioDecoder (codec 'opus', configured
|
||||
* from the OpusHead in the sidecar) -> AudioData (48 kHz PCM) -> AudioBuffer -> PlaybackScheduler.
|
||||
*
|
||||
* Pre-skip (encoder delay): handled ONCE, by the decoder. WebCodecs decodes Opus with the OpusHead
|
||||
* passed as `AudioDecoderConfig.description`; the OpusHead carries `pre_skip`, and the WebCodecs Opus
|
||||
* decoder discards those leading samples itself. We do NOT re-trim per packet — doing so on top of the
|
||||
* decoder's own trim would double-count. This is the spec-intended path (W3C WebCodecs Opus registration).
|
||||
*
|
||||
* End-trim: the sidecar's `totalDurationSeconds` is the exact pre-skip-corrected stream length. We cap
|
||||
* cumulative emitted audio at that length so the final partial frame's padding does not leak past the
|
||||
* true end. (Granule-position end-trim from the EOS page is the alternative; capping on the known total
|
||||
* is equivalent and simpler, and the sidecar total is authoritative.)
|
||||
*
|
||||
* Sample rate: Opus always decodes at 48 kHz (RFC 7845). We force the AudioContext to 48 kHz at init so
|
||||
* the decoded AudioData needs no resampling before scheduling — the same `recreateWithSampleRate` seam
|
||||
* the WAV path uses for non-44.1 sources.
|
||||
*
|
||||
* BROWSER-VERIFIED. The actual decode/playback/trim correctness is verified in Daniel's browser
|
||||
* (WebCodecs cannot run in Node/headless here). The Ogg demux, packet timing, and end-trim *math* are
|
||||
* unit-tested; the WebCodecs glue (configure/decode/flush/AudioData->AudioBuffer) is browser-verified.
|
||||
*/
|
||||
|
||||
import { AudioContextManager } from './AudioContextManager.js';
|
||||
import { decodePressure } from './decodePressure.js';
|
||||
import { IStreamingDecoder } from './IStreamingDecoder.js';
|
||||
import { OggDemuxer, OpusPacket, extractOpusHead, opusHeadChannelCount } from './OggDemuxer.js';
|
||||
import { OpusSeekData, OPUS_SAMPLE_RATE } from './OpusSidecar.js';
|
||||
|
||||
/** Opus packet duration ceiling is 120 ms; at 48 kHz that is 5760 frames. Used only for chunk timestamps. */
|
||||
const MAX_PACKET_FRAMES = 5760;
|
||||
|
||||
export class OpusStreamDecoder implements IStreamingDecoder {
|
||||
private readonly contextManager: AudioContextManager;
|
||||
private readonly sidecar: OpusSeekData;
|
||||
// Phase 21.2b back-pressure hook: returns true when the shared scheduler is full (forward fill
|
||||
// over high-water). While full, push() stashes raw bytes WITHOUT demuxing/decoding so the
|
||||
// WebCodecs decode queue and decodedQueue stay near-empty behind a throttled socket (OQ7).
|
||||
// Null = no back-pressure (e.g. unit tests), in which case the decoder feeds eagerly as before.
|
||||
private readonly isSchedulerFull: (() => boolean) | null;
|
||||
|
||||
// Raw bytes received while the scheduler was full, held undemuxed until it drains. The C# read
|
||||
// loop also pauses above high-water, so this stash is bounded to at most the in-flight chunks
|
||||
// between the loop reading the productionPaused flag and actually stopping — a handful of KB,
|
||||
// not a decode-ahead. Drained (demuxed + decoded) on the next push once below high-water.
|
||||
private pendingBytes: Uint8Array[] = [];
|
||||
|
||||
private demuxer = new OggDemuxer();
|
||||
private decoder: AudioDecoder | null = null;
|
||||
private channelCount = 2;
|
||||
private configured = false;
|
||||
// OpusHead bytes used as the AudioDecoder `description`, captured once at first configure and reused
|
||||
// verbatim on a range-continuation reconfigure (avoids re-extracting / a non-null assertion).
|
||||
private opusHeadDescription: Uint8Array | null = null;
|
||||
|
||||
// Decoded AudioData awaiting conversion, filled by the AudioDecoder output callback.
|
||||
private decodedQueue: AudioData[] = [];
|
||||
private fatalError = false;
|
||||
|
||||
// Frames to discard from the head of the first post-seek decoded output (AC9 fine re-sync).
|
||||
// Set by reinitializeForRangeContinuation to (targetTimeSeconds - landingTimeSeconds) * 48000,
|
||||
// consumed frame-by-frame in audioDataToBuffer until exhausted (then zero for the rest of the stream).
|
||||
private leadTrimFrames = 0;
|
||||
|
||||
// Monotonic packet timestamp (microseconds) handed to each EncodedAudioChunk. WebCodecs requires
|
||||
// strictly increasing timestamps; the true value is irrelevant to us (we schedule by accumulation),
|
||||
// so a synthetic 48 kHz-derived counter suffices and stays exact.
|
||||
private nextTimestampUs = 0;
|
||||
|
||||
// Cumulative frames already emitted as AudioBuffers, for end-trim against the known total length.
|
||||
private emittedFrames = 0;
|
||||
private readonly totalFrames: number;
|
||||
|
||||
constructor(
|
||||
contextManager: AudioContextManager,
|
||||
sidecar: OpusSeekData,
|
||||
isSchedulerFull: (() => boolean) | null = null) {
|
||||
this.contextManager = contextManager;
|
||||
this.sidecar = sidecar;
|
||||
this.isSchedulerFull = isSchedulerFull;
|
||||
this.totalFrames = sidecar.totalDurationSeconds > 0
|
||||
? Math.round(sidecar.totalDurationSeconds * OPUS_SAMPLE_RATE)
|
||||
: Number.POSITIVE_INFINITY;
|
||||
}
|
||||
|
||||
get hasFatalError(): boolean {
|
||||
return this.fatalError;
|
||||
}
|
||||
|
||||
get ready(): boolean {
|
||||
return this.configured;
|
||||
}
|
||||
|
||||
get totalDuration(): number | null {
|
||||
return this.sidecar.totalDurationSeconds > 0 ? this.sidecar.totalDurationSeconds : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazily build + configure the WebCodecs decoder from the sidecar's OpusHead. Idempotent. Forces the
|
||||
* AudioContext to 48 kHz so decoded AudioData schedules without resampling. Returns false on a config
|
||||
* the browser cannot support (caller should never reach here — the capability gate runs first — but
|
||||
* we fail safe rather than throw into the stream loop).
|
||||
*/
|
||||
private async ensureConfigured(): Promise<boolean> {
|
||||
if (this.configured) return true;
|
||||
if (typeof AudioDecoder === 'undefined') {
|
||||
this.fatalError = true;
|
||||
return false;
|
||||
}
|
||||
|
||||
const opusHead = extractOpusHead(this.sidecar.setupHeaderBytes);
|
||||
if (!opusHead) {
|
||||
this.fatalError = true;
|
||||
return false;
|
||||
}
|
||||
this.channelCount = opusHeadChannelCount(opusHead);
|
||||
// Copy the OpusHead into a standalone buffer — the sidecar subarray is a view we keep.
|
||||
this.opusHeadDescription = opusHead.slice();
|
||||
|
||||
// Opus decodes at 48 kHz; align the context so no resample is needed. AudioPlayer.initializeStreaming
|
||||
// already aligned it to 48 kHz up front (the format is resolved before any bytes flow), so in the
|
||||
// common path this is an early-return no-op — the live graph is NOT close()'d and rebuilt mid-decode.
|
||||
// Kept as the defensive backstop for any path that reaches a configured decoder on a non-48 kHz
|
||||
// context (the same recreate seam the WAV path uses for non-44.1 sources).
|
||||
if (this.contextManager.sampleRate !== OPUS_SAMPLE_RATE) {
|
||||
await this.contextManager.recreateWithSampleRate(OPUS_SAMPLE_RATE);
|
||||
}
|
||||
|
||||
this.decoder = new AudioDecoder({
|
||||
output: (data) => this.decodedQueue.push(data),
|
||||
error: (err) => {
|
||||
console.error('Opus AudioDecoder error:', err.message);
|
||||
this.fatalError = true;
|
||||
}
|
||||
});
|
||||
this.decoder.configure(this.buildConfig());
|
||||
this.configured = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
private buildConfig(): AudioDecoderConfig {
|
||||
return {
|
||||
codec: 'opus',
|
||||
sampleRate: OPUS_SAMPLE_RATE,
|
||||
numberOfChannels: this.channelCount,
|
||||
description: this.opusHeadDescription ?? undefined
|
||||
};
|
||||
}
|
||||
|
||||
async push(chunk: Uint8Array): Promise<AudioBuffer[]> {
|
||||
if (this.fatalError) return [];
|
||||
|
||||
// 21.2b back-pressure: while the scheduler is full, do NOT demux/decode ahead. Stash the
|
||||
// raw bytes in arrival order and return nothing — the WebCodecs decode queue and
|
||||
// decodedQueue stay near-empty (OQ7). The bytes are demuxed/decoded on a later push once
|
||||
// the scheduler has drained below low-water, in exactly the order received (Ogg demux is
|
||||
// order-sensitive). configure() is deferred too — no need to spin up the decoder while
|
||||
// throttled. The C# loop also stops reading above high-water, so the stash stays small.
|
||||
if (this.isSchedulerFull?.()) {
|
||||
this.pendingBytes.push(chunk);
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!(await this.ensureConfigured())) return [];
|
||||
|
||||
// Drained below high-water: replay any stashed bytes first (preserving stream order), then
|
||||
// the new chunk, through the demuxer as one contiguous feed.
|
||||
const out: AudioBuffer[] = [];
|
||||
if (this.pendingBytes.length > 0) {
|
||||
const stashed = this.pendingBytes;
|
||||
this.pendingBytes = [];
|
||||
for (const bytes of stashed) {
|
||||
this.decodePackets(this.demuxer.push(bytes));
|
||||
}
|
||||
}
|
||||
|
||||
this.decodePackets(this.demuxer.push(chunk));
|
||||
// Wait until the WebCodecs decoder has processed the queued packets before draining.
|
||||
await this.yieldToDecoder();
|
||||
out.push(...this.drainDecoded());
|
||||
return out;
|
||||
}
|
||||
|
||||
async complete(): Promise<AudioBuffer[]> {
|
||||
if (this.fatalError) {
|
||||
return this.drainDecoded();
|
||||
}
|
||||
|
||||
// End-of-stream may arrive while still throttled with bytes stashed (e.g. a short track
|
||||
// that finished sending before the scheduler drained). Configure if needed and replay the
|
||||
// stash so the tail is decoded before flush — otherwise the final seconds would be lost.
|
||||
//
|
||||
// OQ7/AC1-Opus precision note: the stash here is drained in full without a water-mark
|
||||
// check. This is intentionally correct: the stream has ended — you cannot back-pressure a
|
||||
// finished stream — and the remainder is tail-only (bounded by whatever the throttled C#
|
||||
// loop left in flight, which is at most one push() worth of bytes). Adding a water-mark
|
||||
// gate to complete() would silently drop the track's tail and is therefore wrong.
|
||||
if (this.pendingBytes.length > 0) {
|
||||
if (await this.ensureConfigured()) {
|
||||
const stashed = this.pendingBytes;
|
||||
this.pendingBytes = [];
|
||||
for (const bytes of stashed) {
|
||||
this.decodePackets(this.demuxer.push(bytes));
|
||||
}
|
||||
} else {
|
||||
this.pendingBytes = [];
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.decoder || this.decoder.state !== 'configured') {
|
||||
return this.drainDecoded();
|
||||
}
|
||||
try {
|
||||
await this.decoder.flush();
|
||||
} catch (err) {
|
||||
// A flush can reject if the decoder was reset/closed concurrently (track switch); the loop's
|
||||
// own cancellation handles that — surface nothing, just drain what we have.
|
||||
console.warn('Opus decoder flush interrupted:', (err as Error).message);
|
||||
}
|
||||
return this.drainDecoded();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reinitialize for a Range-continuation stream after seek-beyond-buffer.
|
||||
*
|
||||
* @param landingTimeSeconds The actual page-start presentation time resolved from the seek index
|
||||
* (t_page ≤ targetTimeSeconds). This is the time at which the decoder
|
||||
* will begin emitting audio after reconfigure.
|
||||
* @param targetTimeSeconds The user-requested seek position. The difference
|
||||
* `(target - landing) * OPUS_SAMPLE_RATE` frames are trimmed from the
|
||||
* head of the decoded output so playback lands precisely at the target
|
||||
* (AC9 fine re-sync, §3.4a step 4).
|
||||
*
|
||||
* Pre-skip note: the reconfigure re-applies the WebCodecs Opus decoder's own pre-skip trim. The
|
||||
* W3C spec is non-normative on the exact sample count and browsers vary (~312 samples at 48 kHz in
|
||||
* practice). `leadTrimFrames` is computed from the sidecar's pre-skip-corrected presentation times
|
||||
* (via `presentationTimeSeconds`), so it does NOT double-count the per-reconfigure pre-skip; the
|
||||
* decoder handles that internally. If browser testing reveals a residual offset, adjust the
|
||||
* `leadTrimFrames` calculation here — this is the single point of control.
|
||||
*/
|
||||
reinitializeForRangeContinuation(landingTimeSeconds: number, targetTimeSeconds: number): void {
|
||||
// New 206 body starts on a page boundary with no setup pages; the codec config is unchanged but
|
||||
// inter-frame state must restart cleanly. AudioDecoder.reset() drops queued work and returns the
|
||||
// decoder to 'unconfigured', so we reconfigure with the cached config. The demuxer goes into
|
||||
// continuation mode (treat the first page's packets as audio — no setup pages in a 206 body).
|
||||
this.demuxer.reset(true);
|
||||
this.decodedQueue = [];
|
||||
// Drop any bytes stashed by back-pressure: they belong to the PRE-seek stream position and
|
||||
// must never be replayed against the post-seek (range-continuation) demux state (C6 — no
|
||||
// stale feed racing the reset).
|
||||
this.pendingBytes = [];
|
||||
this.emittedFrames = 0; // post-seek buffers are positioned by the scheduler's playbackOffset
|
||||
// Arm the lead trim: skip enough decoded frames to land at targetTimeSeconds, not at
|
||||
// landingTimeSeconds (the page start). Clamp to ≥0 to guard against floating-point rounding.
|
||||
this.leadTrimFrames = Math.max(0, Math.round((targetTimeSeconds - landingTimeSeconds) * OPUS_SAMPLE_RATE));
|
||||
if (this.decoder && this.decoder.state === 'configured') {
|
||||
this.decoder.reset();
|
||||
this.decoder.configure(this.buildConfig());
|
||||
}
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
for (const d of this.decodedQueue) {
|
||||
try { d.close(); } catch { /* already closed */ }
|
||||
}
|
||||
this.decodedQueue = [];
|
||||
this.pendingBytes = [];
|
||||
if (this.decoder && this.decoder.state !== 'closed') {
|
||||
try { this.decoder.close(); } catch { /* already closed */ }
|
||||
}
|
||||
this.decoder = null;
|
||||
this.configured = false;
|
||||
}
|
||||
|
||||
private decodePackets(packets: OpusPacket[]): void {
|
||||
if (!this.decoder || this.decoder.state !== 'configured') return;
|
||||
for (const pkt of packets) {
|
||||
if (pkt.data.length === 0) continue;
|
||||
// Every Opus packet is independently a "key" frame at the container level for WebCodecs's
|
||||
// purposes — Opus has no key/delta distinction; 'key' is the correct type for all packets.
|
||||
const chunk = new EncodedAudioChunk({
|
||||
type: 'key',
|
||||
timestamp: this.nextTimestampUs,
|
||||
data: pkt.data
|
||||
});
|
||||
// Advance the synthetic clock by a packet's max duration; exact value is immaterial to us.
|
||||
this.nextTimestampUs += Math.round((MAX_PACKET_FRAMES / OPUS_SAMPLE_RATE) * 1_000_000);
|
||||
try {
|
||||
this.decoder.decode(chunk);
|
||||
} catch (err) {
|
||||
console.error('Opus decode() threw:', (err as Error).message);
|
||||
this.fatalError = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert every queued AudioData into an AudioBuffer at the context sample rate, applying
|
||||
* end-trim against the known total frame count and lead-trim for post-seek fine re-sync.
|
||||
*/
|
||||
private drainDecoded(): AudioBuffer[] {
|
||||
const out: AudioBuffer[] = [];
|
||||
const ctx = this.contextManager.getContext();
|
||||
|
||||
while (this.decodedQueue.length > 0) {
|
||||
const data = this.decodedQueue.shift()!;
|
||||
try {
|
||||
const buffer = this.audioDataToBuffer(ctx, data);
|
||||
if (buffer) out.push(buffer);
|
||||
} finally {
|
||||
try { data.close(); } catch { /* already closed */ }
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy an AudioData's PCM into a new AudioBuffer, applying:
|
||||
* 1. Lead-trim (post-seek fine re-sync): skip `leadTrimFrames` from the front so the audible
|
||||
* start lands at the requested seek position, not at the preceding page boundary (AC9).
|
||||
* 2. End-trim: cap cumulative output at `totalFrames` so the final partial frame's padding
|
||||
* does not leak past the true stream end.
|
||||
* Returns null when either trim leaves zero usable frames.
|
||||
*/
|
||||
private audioDataToBuffer(ctx: BaseAudioContext, data: AudioData): AudioBuffer | null {
|
||||
const frames = data.numberOfFrames;
|
||||
const channels = data.numberOfChannels;
|
||||
|
||||
// Lead-trim: consume frames from the front for post-seek fine re-sync (AC9).
|
||||
let skip = 0;
|
||||
if (this.leadTrimFrames > 0) {
|
||||
skip = Math.min(this.leadTrimFrames, frames);
|
||||
this.leadTrimFrames -= skip;
|
||||
}
|
||||
const available = frames - skip;
|
||||
if (available <= 0) return null;
|
||||
|
||||
// End-trim: cap cumulative output at totalFrames.
|
||||
let keep = available;
|
||||
if (Number.isFinite(this.totalFrames)) {
|
||||
const room = this.totalFrames - this.emittedFrames;
|
||||
if (room <= 0) return null;
|
||||
if (room < available) keep = room;
|
||||
}
|
||||
if (keep <= 0) return null;
|
||||
|
||||
const buffer = ctx.createBuffer(channels, keep, data.sampleRate);
|
||||
// Allocate only for the frames we actually copy; frameOffset skips the lead-trim region.
|
||||
const plane = new Float32Array(keep);
|
||||
for (let ch = 0; ch < channels; ch++) {
|
||||
data.copyTo(plane, { planeIndex: ch, frameOffset: skip, frameCount: keep, format: 'f32-planar' });
|
||||
buffer.copyToChannel(plane, ch);
|
||||
}
|
||||
this.emittedFrames += keep;
|
||||
return buffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait until the AudioDecoder's internal work queue drains (decodeQueueSize → 0), so output
|
||||
* callbacks have fired before we drain decodedQueue. Bounded to MAX_YIELD_ITERS × 4 ms to guard
|
||||
* against a stuck decoder; any outputs collected before the cap are still returned. `complete()`
|
||||
* uses decoder.flush() as its final barrier instead (flush() is the authoritative end-of-stream
|
||||
* drain).
|
||||
*/
|
||||
private yieldToDecoder(): Promise<void> {
|
||||
const MAX_YIELD_ITERS = 50; // 50 × 4 ms = 200 ms ceiling
|
||||
return new Promise<void>((resolve) => {
|
||||
let iters = 0;
|
||||
const poll = () => {
|
||||
if (!this.decoder || this.decoder.decodeQueueSize === 0 || iters >= MAX_YIELD_ITERS) {
|
||||
// Hitting the 200 ms ceiling with the decode queue still non-empty means the WebCodecs
|
||||
// decoder is falling behind realtime — the decode-starvation symptom that worsens with
|
||||
// HW accel off (software WebGL render contending for the main thread). Report it as
|
||||
// decode pressure so the visualizer throttles and yields the main thread back to decode.
|
||||
if (this.decoder && iters >= MAX_YIELD_ITERS && this.decoder.decodeQueueSize > 0) {
|
||||
decodePressure.report();
|
||||
}
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
iters++;
|
||||
setTimeout(poll, 4);
|
||||
};
|
||||
poll();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,834 @@
|
||||
/**
|
||||
* PlaybackScheduler partial-eviction tests (Phase 21.1) — the anchor/index bookkeeping.
|
||||
*
|
||||
* The crux of 21.1 is that getCurrentPosition / playFromPosition / the schedule loop stay
|
||||
* exact against a buffer array that no longer begins at absolute time 0 after front eviction.
|
||||
* That math is pure given a clock and buffer durations, so it is testable in Node without a
|
||||
* browser by injecting fakes for AudioContextManager and AudioBuffer (the scheduler only ever
|
||||
* reads contextManager.currentTime, getGainNode(), getContext().createBufferSource(), and
|
||||
* buffer.duration).
|
||||
*
|
||||
* Same harness convention as OpusStreamDecoder.test.ts: no test runner in this repo, run a
|
||||
* copy from the COMPILED output so the `.js` import specifier resolves:
|
||||
*
|
||||
* dotnet build DeepDrftPublic/DeepDrftPublic.csproj
|
||||
* cp DeepDrftPublic/Interop/audio/PlaybackScheduler.test.ts DeepDrftPublic/wwwroot/js/audio/
|
||||
* node DeepDrftPublic/wwwroot/js/audio/PlaybackScheduler.test.ts
|
||||
*
|
||||
* A thrown error / non-zero exit signals failure; "ALL <n> TESTS PASSED" signals success.
|
||||
* Excluded from the production tsc build via tsconfig `exclude: Interop/ ** /*.test.ts`.
|
||||
*/
|
||||
|
||||
import { PlaybackScheduler } from './PlaybackScheduler.js';
|
||||
import type { AudioContextManager } from './AudioContextManager.js';
|
||||
|
||||
// --- tiny inline harness (no dependencies) ---------------------------------------------------
|
||||
let passed = 0;
|
||||
const failures: string[] = [];
|
||||
function test(name: string, fn: () => void): void {
|
||||
try {
|
||||
fn();
|
||||
passed++;
|
||||
} catch (e) {
|
||||
failures.push(`FAIL: ${name}\n ${(e as Error).message}`);
|
||||
}
|
||||
}
|
||||
function assertClose(actual: number, expected: number, msg?: string, eps = 1e-9): void {
|
||||
if (Math.abs(actual - expected) > eps) {
|
||||
throw new Error(`${msg ?? 'assertClose'}: expected ${expected}, got ${actual}`);
|
||||
}
|
||||
}
|
||||
function assertEqual(actual: unknown, expected: unknown, msg?: string): void {
|
||||
if (actual !== expected) {
|
||||
throw new Error(`${msg ?? 'assertEqual'}: expected ${String(expected)}, got ${String(actual)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// --- fakes -----------------------------------------------------------------------------------
|
||||
|
||||
/** A buffer source that records start/stop and fires onended on demand. */
|
||||
class FakeSource {
|
||||
public buffer: unknown = null;
|
||||
public onended: (() => void) | null = null;
|
||||
public started = false;
|
||||
public stopped = false;
|
||||
connect(): void { /* no-op */ }
|
||||
start(): void { this.started = true; }
|
||||
stop(): void {
|
||||
this.stopped = true;
|
||||
// The real Web Audio fires onended when a source is stopped; the scheduler relies on
|
||||
// that for cleanup. Mirror it so handleSourceEnded paths are exercised.
|
||||
this.onended?.();
|
||||
}
|
||||
}
|
||||
|
||||
/** Controllable clock + the minimal AudioContext surface the scheduler touches. */
|
||||
class FakeContextManager {
|
||||
public now = 0;
|
||||
public sources: FakeSource[] = [];
|
||||
get currentTime(): number { return this.now; }
|
||||
getGainNode(): unknown { return {}; }
|
||||
getContext(): unknown {
|
||||
const self = this;
|
||||
return {
|
||||
createBufferSource(): FakeSource {
|
||||
const s = new FakeSource();
|
||||
self.sources.push(s);
|
||||
return s;
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** A decoded buffer is, for the scheduler's purposes, just a duration. */
|
||||
function buf(duration: number): AudioBuffer {
|
||||
return { duration } as AudioBuffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* A decoded buffer carrying realistic byte-footprint fields (length + numberOfChannels) for the
|
||||
* OQ3 byte-ceiling test. Models 48 kHz stereo float PCM: length = duration × 48000 frames, 2 ch.
|
||||
*/
|
||||
function bufBytes(duration: number): AudioBuffer {
|
||||
return { duration, length: Math.round(duration * 48000), numberOfChannels: 2 } as AudioBuffer;
|
||||
}
|
||||
|
||||
function makeScheduler(cm: FakeContextManager): PlaybackScheduler {
|
||||
// The scheduler only uses the subset FakeContextManager implements.
|
||||
return new PlaybackScheduler(cm as unknown as AudioContextManager);
|
||||
}
|
||||
|
||||
/**
|
||||
* Drive the schedule cursor to the end of the buffer array WITHOUT running playback to
|
||||
* completion, then clear the live-source set so neither nextBufferIndex nor a live source
|
||||
* pins eviction. This isolates the back-retain threshold math from the live-frontier guards
|
||||
* (which are exercised by their own tests).
|
||||
*
|
||||
* The lookahead in scheduleBuffersFrom only schedules ~500ms ahead per call; pushing the clock
|
||||
* far back makes "lookahead" small so a single scheduleNewBuffers() call schedules everything
|
||||
* remaining. We then drop the (white-box) live-source list and reset the schedule cursor to the
|
||||
* end, leaving the array intact for a direct evictPlayedBuffers() call at a chosen position.
|
||||
*/
|
||||
function advanceCursorToEnd(s: PlaybackScheduler, cm: FakeContextManager): void {
|
||||
const priv = s as unknown as { nextScheduleTime: number; nextBufferIndex: number; scheduledSources: unknown[] };
|
||||
// Make the existing schedule anchor look "now" so the lookahead window is tiny, then let
|
||||
// the scheduler lay down every remaining buffer in one pass.
|
||||
priv.nextScheduleTime = cm.now;
|
||||
s.scheduleNewBuffers();
|
||||
// Repeat until the cursor reaches the end (lookahead may break early on long arrays).
|
||||
let guard = 0;
|
||||
while ((priv.nextBufferIndex as number) < s.getBufferCount() && guard++ < 1000) {
|
||||
priv.nextScheduleTime = cm.now;
|
||||
s.scheduleNewBuffers();
|
||||
}
|
||||
// Unpin the front: discard live sources without firing the onended cascade.
|
||||
cm.sources.forEach(x => { x.onended = null; x.stopped = true; });
|
||||
priv.scheduledSources.length = 0;
|
||||
}
|
||||
|
||||
// --- tests -----------------------------------------------------------------------------------
|
||||
|
||||
// Position correctness after eviction: query current position after the front of the buffer
|
||||
// array has been evicted; it must still equal wall-clock track time.
|
||||
test('position stays exact after a front eviction', () => {
|
||||
const cm = new FakeContextManager();
|
||||
const s = makeScheduler(cm);
|
||||
s.setBackRetainSeconds(0); // retain nothing behind the playhead — evict aggressively
|
||||
// Ten 1s buffers, track [0,10).
|
||||
for (let i = 0; i < 10; i++) s.addBuffer(buf(1));
|
||||
|
||||
cm.now = 0;
|
||||
s.playFromPosition(0); // schedules a 500ms lookahead worth of sources from index 0
|
||||
advanceCursorToEnd(s, cm);
|
||||
|
||||
cm.now = 3.0;
|
||||
const dropped = s.evictPlayedBuffers();
|
||||
if (dropped <= 0) throw new Error('expected front buffers to be evicted at t=3 with 0s retain');
|
||||
|
||||
// Absolute position must read 3.0 regardless of how many front buffers were dropped.
|
||||
assertClose(s.getCurrentPosition(), 3.0, 'position after eviction');
|
||||
// And buffers[0] no longer being the track start is reflected in the advanced offset.
|
||||
if (s.getPlaybackOffset() <= 0) {
|
||||
throw new Error('expected playbackOffset to advance past 0 after eviction');
|
||||
}
|
||||
});
|
||||
|
||||
// Eviction threshold respected: buffers older than back-retain are released; those within are
|
||||
// kept. With back-retain = 2s at position 5, end<=3 is droppable, end in (3,..] is retained.
|
||||
// Driven deterministically: advance the schedule cursor to the end (so nextBufferIndex does
|
||||
// not pin eviction), clear live sources, then call eviction directly at a known position.
|
||||
test('back-retain bound governs what is evicted', () => {
|
||||
const cm = new FakeContextManager();
|
||||
const s = makeScheduler(cm);
|
||||
s.setBackRetainSeconds(2);
|
||||
for (let i = 0; i < 10; i++) s.addBuffer(buf(1)); // track [0,10)
|
||||
|
||||
cm.now = 0;
|
||||
s.playFromPosition(0);
|
||||
advanceCursorToEnd(s, cm); // nextBufferIndex == 10, no live sources
|
||||
|
||||
cm.now = 5.0; // playhead at absolute t=5
|
||||
const evicted = s.evictPlayedBuffers();
|
||||
|
||||
// currentPosition is 5.0; backRetain 2 => evictBefore = 3. Buffers ending at 1,2,3 are
|
||||
// droppable (3 buffers); the buffer ending at 4 must be retained.
|
||||
assertEqual(evicted, 3, 'evicted count under 2s back-retain at t=5');
|
||||
assertEqual(s.getBufferCount(), 7, 'seven buffers retained');
|
||||
assertClose(s.getPlaybackOffset(), 3.0, 'offset == dropped duration');
|
||||
assertClose(s.getCurrentPosition(), 5.0, 'position unchanged by eviction');
|
||||
});
|
||||
|
||||
// Resume-after-pause with an evicted front: playFromPosition resumes at the correct absolute
|
||||
// time against the shortened array.
|
||||
test('resume after pause lands at correct absolute time post-eviction', () => {
|
||||
const cm = new FakeContextManager();
|
||||
const s = makeScheduler(cm);
|
||||
s.setBackRetainSeconds(1);
|
||||
for (let i = 0; i < 10; i++) s.addBuffer(buf(1)); // [0,10)
|
||||
|
||||
cm.now = 0;
|
||||
s.playFromPosition(0);
|
||||
advanceCursorToEnd(s, cm);
|
||||
|
||||
cm.now = 4.0;
|
||||
s.evictPlayedBuffers(); // back-retain 1 at t=4 => drops buffers ending <=3 (3 buffers)
|
||||
|
||||
// Pause at t=4: returns absolute position 4.0.
|
||||
const paused = s.pause();
|
||||
assertClose(paused, 4.0, 'pause returns absolute position');
|
||||
// Front was evicted, so offset advanced. The buffer-relative anchor must net to absolute 4.
|
||||
assertClose(s.getCurrentPosition(), 4.0, 'position holds at 4 while paused');
|
||||
|
||||
// Resume the way AudioPlayer.play does: buffer-relative = absolute - offset.
|
||||
cm.now = 4.0;
|
||||
const bufferRelative = paused - s.getPlaybackOffset();
|
||||
if (bufferRelative < 0) throw new Error('buffer-relative resume position went negative');
|
||||
s.playFromPosition(bufferRelative);
|
||||
cm.now = 4.0;
|
||||
assertClose(s.getCurrentPosition(), 4.0, 'resume restored absolute position');
|
||||
});
|
||||
|
||||
// Seek-back into still-retained buffers works: with back-retain holding recent audio, a short
|
||||
// backward seek stays in-buffer (queryable/playable), no clamp to the new front.
|
||||
test('short seek-back into retained region resolves in-buffer', () => {
|
||||
const cm = new FakeContextManager();
|
||||
const s = makeScheduler(cm);
|
||||
s.setBackRetainSeconds(3);
|
||||
for (let i = 0; i < 10; i++) s.addBuffer(buf(1)); // [0,10)
|
||||
|
||||
cm.now = 0;
|
||||
s.playFromPosition(0);
|
||||
advanceCursorToEnd(s, cm);
|
||||
|
||||
cm.now = 6.0;
|
||||
s.evictPlayedBuffers(); // back-retain 3 at t=6 => evictBefore=3, drops buffers ending <=3
|
||||
|
||||
const offset = s.getPlaybackOffset();
|
||||
// back-retain 3 at t=6 => evictBefore=3, so buffers ending <=3 dropped, offset==3.
|
||||
assertClose(offset, 3.0, 'offset after eviction with 3s retain');
|
||||
|
||||
// The retained region is [offset, totalEnd) == [3, 10). A seek back to t=4 is inside it.
|
||||
const seekTarget = 4.0;
|
||||
const bufferRelative = seekTarget - offset; // 1.0 into the retained array
|
||||
if (bufferRelative < 0) throw new Error('seek-back target fell below retained front (should be in-buffer)');
|
||||
cm.now = 6.0;
|
||||
s.playFromPosition(bufferRelative);
|
||||
cm.now = 6.0;
|
||||
assertClose(s.getCurrentPosition(), seekTarget, 'seek-back resolved to absolute target');
|
||||
});
|
||||
|
||||
// Eviction never crosses the live frontier: a buffer still referenced by an unstopped source
|
||||
// must not be dropped even if the clock says it is "behind".
|
||||
test('eviction does not drop buffers under live sources or past the schedule cursor', () => {
|
||||
const cm = new FakeContextManager();
|
||||
const s = makeScheduler(cm);
|
||||
s.setBackRetainSeconds(0);
|
||||
for (let i = 0; i < 10; i++) s.addBuffer(buf(1));
|
||||
|
||||
cm.now = 0;
|
||||
s.playFromPosition(0); // schedules ~first 500ms+ of sources; they remain live (not ended)
|
||||
|
||||
// Jump the clock far ahead WITHOUT ending the live sources.
|
||||
cm.now = 9.0;
|
||||
const before = s.getBufferCount();
|
||||
const dropped = s.evictPlayedBuffers();
|
||||
|
||||
// Nothing past the schedule cursor or under a live source may be dropped. The scheduled
|
||||
// (live) sources pin the front, so eviction is bounded — it must not strip the whole array.
|
||||
if (s.getBufferCount() < 0) throw new Error('buffer count went negative');
|
||||
assertEqual(s.getBufferCount(), before - dropped, 'count matches dropped');
|
||||
// The live sources start at index 0, so firstLiveIndex pins eviction at 0 — nothing drops.
|
||||
assertEqual(dropped, 0, 'no eviction while front sources are live');
|
||||
});
|
||||
|
||||
// handleSourceEnded cascade: eviction fires from the real production trigger (onended), not
|
||||
// via a direct evictPlayedBuffers() call. Confirms the anchor/index invariants hold end-to-end
|
||||
// through the scheduler's own event handling while playback is still active with a live source.
|
||||
//
|
||||
// Setup: 0.3s buffers so the 500ms lookahead window fits exactly two sources after
|
||||
// playFromPosition(0). Buffer 0 ends at ~0.31s, buffer 1 ends at ~0.61s — both are scheduled.
|
||||
// Clock is then advanced to t=0.6 so buffer 0's end (0.31) < evictBefore (0.6) while the live
|
||||
// source on buffer 1 pins firstLiveIndex=1, blocking further eviction. This is the mid-array
|
||||
// pinning scenario that later waves (21.2/21.3) build on.
|
||||
test('eviction via handleSourceEnded: position exact, live bufferIndex decremented, frontier respected', () => {
|
||||
const cm = new FakeContextManager();
|
||||
const s = makeScheduler(cm);
|
||||
// Retain nothing behind the playhead — evict aggressively so the cascade fires.
|
||||
s.setBackRetainSeconds(0);
|
||||
|
||||
// Eight 0.3s buffers. scheduleBuffersFrom with lookaheadTarget=0.5s at t=0:
|
||||
// after buf 0: nextScheduleTime≈0.31, lookahead=0.31 < 0.5 → continues
|
||||
// after buf 1: nextScheduleTime≈0.61, lookahead=0.61 > 0.5 → breaks
|
||||
// → exactly two sources are live after playFromPosition.
|
||||
for (let i = 0; i < 8; i++) s.addBuffer(buf(0.3));
|
||||
|
||||
cm.now = 0;
|
||||
s.playFromPosition(0);
|
||||
|
||||
// Reach inside to see which sources were scheduled and what bufferIndex they hold.
|
||||
const priv = s as unknown as {
|
||||
scheduledSources: Array<{ source: FakeSource; bufferIndex: number; startTime: number; endTime: number }>;
|
||||
nextBufferIndex: number;
|
||||
};
|
||||
|
||||
// Confirm two sources are live — the setup guarantee.
|
||||
if (priv.scheduledSources.length < 2) {
|
||||
throw new Error(`Expected ≥2 scheduled sources after playFromPosition, got ${priv.scheduledSources.length}`);
|
||||
}
|
||||
|
||||
// Identify the first and second scheduled sources by bufferIndex order.
|
||||
const sorted = [...priv.scheduledSources].sort((a, b) => a.bufferIndex - b.bufferIndex);
|
||||
const firstScheduled = sorted[0]; // bufferIndex 0
|
||||
const secondScheduled = sorted[1]; // bufferIndex 1
|
||||
const secondBufferIndexBefore = secondScheduled.bufferIndex; // must be 1
|
||||
|
||||
// Record the second FakeSource so we can assert it was not stopped by eviction.
|
||||
const secondFakeSource = secondScheduled.source as unknown as FakeSource;
|
||||
|
||||
// Advance clock to 0.6s. Buffer 0 ends at ~0.31s → evictBefore=0.6, end=0.31 ≤ 0.6 →
|
||||
// droppable. Buffer 1 ends at ~0.61s → its live source pins firstLiveIndex=1 → NOT dropped.
|
||||
cm.now = 0.6;
|
||||
|
||||
// Confirm playback is still active before firing the cascade.
|
||||
assertEqual(s.isActive(), true, 'isActive must be true before cascade');
|
||||
|
||||
// Fire the cascade via the production trigger: stop the first source, which calls onended,
|
||||
// which calls handleSourceEnded, which calls evictPlayedBuffers internally.
|
||||
(firstScheduled.source as unknown as FakeSource).stop();
|
||||
|
||||
// (a) Absolute position must remain exactly 0.6.
|
||||
assertClose(s.getCurrentPosition(), 0.6, 'position after handleSourceEnded cascade');
|
||||
|
||||
// (b) The second live source's bufferIndex must have been decremented by 1 (the one evicted
|
||||
// front buffer), shifting it from absolute index 1 to absolute index 0.
|
||||
const expectedSecondIndex = secondBufferIndexBefore - 1;
|
||||
assertEqual(secondScheduled.bufferIndex, expectedSecondIndex, 'live source bufferIndex decremented');
|
||||
|
||||
// (c) Eviction stopped at firstLiveIndex=1, not nextBufferIndex — the second buffer was
|
||||
// NOT dropped. Verify the second source was not stopped (it remained live throughout).
|
||||
assertEqual(secondFakeSource.stopped, false, 'live second source not stopped by eviction');
|
||||
// And the scheduler still has buffers (the array was not wiped past the frontier).
|
||||
if (s.getBufferCount() === 0) {
|
||||
throw new Error('eviction wiped all buffers — should have stopped at firstLiveIndex');
|
||||
}
|
||||
});
|
||||
|
||||
// === Phase 21.2 back-pressure: the forward water-mark signal =================================
|
||||
//
|
||||
// The signal is pure given the clock + buffer durations + the playhead position, so it is
|
||||
// testable in Node with the same fakes. We drive forward lookahead by adding buffers (fill) and
|
||||
// advancing the clock (drain), and assert the hysteresis latch and the OQ3 byte ceiling.
|
||||
|
||||
/**
|
||||
* Fill the scheduler with `count` 1 s buffers, start playback at t=0, and advance the schedule
|
||||
* cursor to the end so nextBufferIndex does not pin anything. Leaves all `count` buffers decoded
|
||||
* and the playhead at the clock position the caller sets afterwards.
|
||||
*/
|
||||
function fillAndStart(s: PlaybackScheduler, cm: FakeContextManager, count: number): void {
|
||||
for (let i = 0; i < count; i++) s.addBuffer(buf(1));
|
||||
cm.now = 0;
|
||||
s.playFromPosition(0);
|
||||
advanceCursorToEnd(s, cm);
|
||||
}
|
||||
|
||||
// High-water reached → production pauses; the signal reflects the forward lookahead.
|
||||
test('evaluateProductionPause latches true when forward lookahead reaches high-water', () => {
|
||||
const cm = new FakeContextManager();
|
||||
const s = makeScheduler(cm);
|
||||
s.setForwardWindow(10, 5, 0); // high 10s, low 5s, byte cap disabled
|
||||
fillAndStart(s, cm, 40); // 40s decoded, track [0,40)
|
||||
|
||||
cm.now = 0; // playhead at 0 → forward lookahead = 40s ≥ 10s high-water
|
||||
assertEqual(s.getForwardLookaheadSeconds(), 40, 'lookahead is full decoded tail at t=0');
|
||||
assertEqual(s.evaluateProductionPause(), true, 'pauses at/above high-water');
|
||||
});
|
||||
|
||||
// Below high-water but above low-water while NOT yet paused → stays unpaused (no premature pause).
|
||||
test('evaluateProductionPause stays false in the hysteresis band before the high-water crossing', () => {
|
||||
const cm = new FakeContextManager();
|
||||
const s = makeScheduler(cm);
|
||||
s.setForwardWindow(10, 5, 0);
|
||||
fillAndStart(s, cm, 8); // 8s decoded
|
||||
|
||||
cm.now = 0; // lookahead 8s: between low(5) and high(10), never latched → unpaused
|
||||
assertEqual(s.evaluateProductionPause(), false, 'no pause until high-water is actually reached');
|
||||
});
|
||||
|
||||
// Hysteresis: once paused at high-water, stays paused through the band until lookahead drains
|
||||
// below low-water, then resumes. Drain is modeled by advancing the clock (playhead moves forward,
|
||||
// shrinking forward lookahead).
|
||||
test('evaluateProductionPause holds through the band and resumes only below low-water', () => {
|
||||
const cm = new FakeContextManager();
|
||||
const s = makeScheduler(cm);
|
||||
s.setForwardWindow(10, 5, 0);
|
||||
fillAndStart(s, cm, 40); // track [0,40)
|
||||
|
||||
cm.now = 0;
|
||||
assertEqual(s.evaluateProductionPause(), true, 'latched at high-water (40s ahead)');
|
||||
|
||||
// Playhead at 32 → lookahead 8s: in the band (5..10) → must STAY paused (hysteresis).
|
||||
cm.now = 32;
|
||||
assertEqual(s.getForwardLookaheadSeconds(), 8, 'lookahead drained to 8s');
|
||||
assertEqual(s.evaluateProductionPause(), true, 'still paused inside the band');
|
||||
|
||||
// Playhead at 36 → lookahead 4s ≤ low-water 5 → resume.
|
||||
cm.now = 36;
|
||||
assertEqual(s.getForwardLookaheadSeconds(), 4, 'lookahead below low-water');
|
||||
assertEqual(s.evaluateProductionPause(), false, 'resumes below low-water');
|
||||
|
||||
// Refill back over high-water re-latches (the next chunk would re-pause).
|
||||
for (let i = 0; i < 20; i++) s.addBuffer(buf(1)); // +20s decoded ahead
|
||||
assertEqual(s.evaluateProductionPause(), true, 're-latches when fill exceeds high-water again');
|
||||
});
|
||||
|
||||
// OQ3 hard byte ceiling pauses production independent of the time window, and releases as soon as
|
||||
// the footprint is back under the cap (no separate low-water band on the byte guard).
|
||||
test('OQ3 byte ceiling pauses regardless of the time window', () => {
|
||||
const cm = new FakeContextManager();
|
||||
const s = makeScheduler(cm);
|
||||
// Each 1s buffer here is 48000 frames × 2 ch × 4 bytes = 384000 bytes. Cap at ~1.5 MB ≈ 4 buffers.
|
||||
const perBuffer = 48000 * 2 * 4;
|
||||
s.setForwardWindow(1000, 500, perBuffer * 4); // time window huge so only the byte cap can fire
|
||||
for (let i = 0; i < 6; i++) s.addBuffer(bufBytes(1)); // 6 buffers > 4-buffer cap
|
||||
cm.now = 0;
|
||||
s.playFromPosition(0);
|
||||
advanceCursorToEnd(s, cm);
|
||||
|
||||
cm.now = 0;
|
||||
if (s.getDecodedByteEstimate() <= perBuffer * 4) {
|
||||
throw new Error('test setup: byte estimate should exceed the cap');
|
||||
}
|
||||
assertEqual(s.evaluateProductionPause(), true, 'byte ceiling pauses even with a huge time window');
|
||||
});
|
||||
|
||||
// clear() / clearForSeek() release the latch so a fresh stream/seek starts unthrottled (C2).
|
||||
test('clear and clearForSeek release the back-pressure latch (C2 latency parity)', () => {
|
||||
const cm = new FakeContextManager();
|
||||
const s = makeScheduler(cm);
|
||||
s.setForwardWindow(10, 5, 0);
|
||||
fillAndStart(s, cm, 40);
|
||||
cm.now = 0;
|
||||
assertEqual(s.evaluateProductionPause(), true, 'latched');
|
||||
|
||||
s.clear();
|
||||
// After clear there are no buffers, lookahead is 0, and the latch is reset → unpaused.
|
||||
assertEqual(s.evaluateProductionPause(), false, 'clear resets the latch and empties fill');
|
||||
|
||||
fillAndStart(s, cm, 40);
|
||||
cm.now = 0;
|
||||
assertEqual(s.evaluateProductionPause(), true, 'latched again after refill');
|
||||
s.clearForSeek();
|
||||
assertEqual(s.evaluateProductionPause(), false, 'clearForSeek resets the latch');
|
||||
});
|
||||
|
||||
// Production defaults (no setForwardWindow): the widened 60s/30s cushion. The byte cap is the
|
||||
// UNCHANGED hard OOM bound; these defaults only govern the time window. buf(1) carries no byte
|
||||
// fields, so getDecodedByteEstimate is NaN and the byte guard never fires — the time window alone
|
||||
// governs, which is exactly what we want to pin here.
|
||||
test('default forward window throttles at 60s and resumes at 30s (no setForwardWindow)', () => {
|
||||
const cm = new FakeContextManager();
|
||||
const s = makeScheduler(cm);
|
||||
// Deliberately no setForwardWindow() — exercise the PRODUCTION defaults (high 60s / low 30s).
|
||||
for (let i = 0; i < 70; i++) s.addBuffer(buf(1)); // 70s decoded, track [0,70)
|
||||
cm.now = 0;
|
||||
s.playFromPosition(0);
|
||||
advanceCursorToEnd(s, cm);
|
||||
|
||||
cm.now = 0; // forward lookahead = 70s ≥ 60s high-water
|
||||
assertEqual(s.evaluateProductionPause(), true, 'pauses at the 60s default high-water');
|
||||
cm.now = 35; // lookahead 35s: inside the 30..60 band → stays paused (hysteresis)
|
||||
assertEqual(s.evaluateProductionPause(), true, 'holds through the widened band');
|
||||
cm.now = 45; // lookahead 25s ≤ 30s low-water → resume
|
||||
assertEqual(s.evaluateProductionPause(), false, 'resumes at the 30s default low-water');
|
||||
});
|
||||
|
||||
// Lookahead correctness in the underrun state + the prime block hypothesis directly refuted: when the
|
||||
// playhead has drained the queue mid-stream, forward lookahead must read ~0 (not a stale-high value)
|
||||
// so production is NOT throttled while decoded audio is genuinely low.
|
||||
test('forward lookahead is exact during an underrun park and never trips a false pause', () => {
|
||||
const cm = new FakeContextManager();
|
||||
const s = makeScheduler(cm);
|
||||
// 5s decoded, playback started, stream NOT complete.
|
||||
for (let i = 0; i < 5; i++) s.addBuffer(buf(1));
|
||||
cm.now = 0;
|
||||
s.playFromPosition(0);
|
||||
|
||||
// Playhead advances past the decoded tail and the queue drains → mid-stream underrun park.
|
||||
cm.now = 6;
|
||||
drainAllSources(s, cm);
|
||||
assertEqual(s.isActive(), false, 'parked in underrun');
|
||||
|
||||
// At the park the playhead sits at the decoded tail: forward lookahead is 0, so production must
|
||||
// NOT be throttled (the "paused while decoded audio is low" hypothesis must not hold here).
|
||||
assertClose(s.getForwardLookaheadSeconds(), 0, 'lookahead is 0 at the underrun tail');
|
||||
assertEqual(s.evaluateProductionPause(), false, 'low decoded audio does not pause production');
|
||||
|
||||
// Refill arriving during the park grows the lead monotonically; lookahead reflects exactly it,
|
||||
// measured against the FROZEN playhead — not a stale pre-underrun position.
|
||||
s.addBuffer(buf(1));
|
||||
s.addBuffer(buf(1));
|
||||
assertClose(s.getForwardLookaheadSeconds(), 2, 'lookahead equals the freshly-accumulated lead');
|
||||
assertEqual(s.evaluateProductionPause(), false, 'still unthrottled well below the 60s high-water');
|
||||
});
|
||||
|
||||
// === False end-of-playback guard (Opus-startup misfire) ======================================
|
||||
//
|
||||
// The scheduler must distinguish a GENUINE end-of-track (stream complete AND queue drained) from a
|
||||
// transient startup/underrun gap (queue drained while bytes are still streaming — Opus decodes via
|
||||
// WebCodecs asynchronously, so the first buffers can lag the playback-start minimum). The end
|
||||
// callback fires only in the first case. These tests drive the real handleSourceEnded cascade via
|
||||
// FakeSource.stop() and assert onPlaybackEnded fires exactly when it should.
|
||||
|
||||
/** Drive the schedule cursor + live sources to a fully-drained queue at the buffer tail. */
|
||||
function drainAllSources(s: PlaybackScheduler, cm: FakeContextManager): void {
|
||||
const priv = s as unknown as { scheduledSources: Array<{ source: FakeSource }> };
|
||||
let guard = 0;
|
||||
while (priv.scheduledSources.length > 0 && guard++ < 10000) {
|
||||
// Stop the head source; its onended → handleSourceEnded removes it and schedules the next.
|
||||
priv.scheduledSources[0].source.stop();
|
||||
}
|
||||
}
|
||||
|
||||
// A drained queue MID-STREAM (streamComplete false) must NOT fire onPlaybackEnded — it parks in
|
||||
// underrun instead. This is the exact Opus-startup false-end.
|
||||
test('drained queue while still streaming does not fire onPlaybackEnded (no false end)', () => {
|
||||
const cm = new FakeContextManager();
|
||||
const s = makeScheduler(cm);
|
||||
let ended = 0;
|
||||
s.onPlaybackEnded = () => { ended++; };
|
||||
|
||||
// A short run of buffers, playback started, but the stream is NOT marked complete.
|
||||
for (let i = 0; i < 3; i++) s.addBuffer(buf(0.3));
|
||||
cm.now = 0;
|
||||
s.playFromPosition(0);
|
||||
|
||||
// Advance the clock past the buffered tail and drain every scheduled source.
|
||||
cm.now = 1.0;
|
||||
drainAllSources(s, cm);
|
||||
|
||||
assertEqual(ended, 0, 'no end callback fired mid-stream');
|
||||
assertEqual(s.isActive(), false, 'scheduler parked (inactive) on underrun');
|
||||
});
|
||||
|
||||
// After a mid-stream underrun, newly decoded buffers must RESUME playback (scheduleNewBuffers
|
||||
// re-anchors and re-activates) — not stay stuck, and still not fire a false end.
|
||||
test('underrun resumes when new buffers arrive', () => {
|
||||
const cm = new FakeContextManager();
|
||||
const s = makeScheduler(cm);
|
||||
let ended = 0;
|
||||
s.onPlaybackEnded = () => { ended++; };
|
||||
|
||||
for (let i = 0; i < 3; i++) s.addBuffer(buf(0.3));
|
||||
cm.now = 0;
|
||||
s.playFromPosition(0);
|
||||
cm.now = 1.0;
|
||||
drainAllSources(s, cm); // underrun
|
||||
assertEqual(s.isActive(), false, 'inactive after underrun');
|
||||
|
||||
// Decode catches up: enough buffers arrive to clear the 1s rebuffer lead (4 × 0.3 = 1.2s).
|
||||
for (let i = 0; i < 4; i++) s.addBuffer(buf(0.3));
|
||||
s.scheduleNewBuffers();
|
||||
|
||||
assertEqual(s.isActive(), true, 'resumed active after refill');
|
||||
assertEqual(ended, 0, 'still no false end after resume');
|
||||
const priv = s as unknown as { scheduledSources: unknown[] };
|
||||
if (priv.scheduledSources.length === 0) {
|
||||
throw new Error('expected new sources scheduled on resume');
|
||||
}
|
||||
});
|
||||
|
||||
// === Rebuffer hysteresis (Opus-startup thrash fix) ===========================================
|
||||
//
|
||||
// After a mid-stream underrun the scheduler must NOT resume on the first arriving buffer (which,
|
||||
// for ~20 ms Opus packets, plays one buffer, drains, and re-parks — the audible start/stop thrash).
|
||||
// It re-accumulates a healthy decoded LEAD (DEFAULT_MIN_PLAYBACK_LEAD_SECONDS = 1s) first. The
|
||||
// streamComplete override is the escape hatch so a genuine short tail still plays out, never parking
|
||||
// forever. These drive the real handleSourceEnded/scheduleNewBuffers/setStreamComplete paths.
|
||||
|
||||
// Below the rebuffer lead: a thin refill must keep the scheduler parked (no resume, no false end);
|
||||
// once the accumulated lead crosses the threshold, it resumes.
|
||||
test('underrun does not resume below the rebuffer lead, resumes once it is met', () => {
|
||||
const cm = new FakeContextManager();
|
||||
const s = makeScheduler(cm);
|
||||
let ended = 0;
|
||||
s.onPlaybackEnded = () => { ended++; };
|
||||
|
||||
for (let i = 0; i < 3; i++) s.addBuffer(buf(0.3));
|
||||
cm.now = 0;
|
||||
s.playFromPosition(0);
|
||||
cm.now = 1.0;
|
||||
drainAllSources(s, cm); // underrun
|
||||
assertEqual(s.isActive(), false, 'parked in underrun');
|
||||
|
||||
// Only 0.6s of fresh lead arrives — below the 1s rebuffer threshold. Must stay parked.
|
||||
for (let i = 0; i < 2; i++) s.addBuffer(buf(0.3));
|
||||
s.scheduleNewBuffers();
|
||||
assertEqual(s.isActive(), false, 'still parked — lead below the rebuffer threshold');
|
||||
assertEqual(ended, 0, 'no false end while re-accumulating lead');
|
||||
const priv = s as unknown as { scheduledSources: unknown[] };
|
||||
assertEqual(priv.scheduledSources.length, 0, 'nothing scheduled below the threshold');
|
||||
|
||||
// More lead arrives, crossing the threshold (0.6 + 0.6 = 1.2s ≥ 1s) → now resume.
|
||||
for (let i = 0; i < 2; i++) s.addBuffer(buf(0.3));
|
||||
s.scheduleNewBuffers();
|
||||
assertEqual(s.isActive(), true, 'resumes once the lead crosses the threshold');
|
||||
assertEqual(ended, 0, 'still no false end after resume');
|
||||
});
|
||||
|
||||
// Genuine-end tail SHORTER than the rebuffer lead: while parked, a small tail arrives AND the stream
|
||||
// completes. The threshold is overridden so the tail plays out and the genuine end fires exactly
|
||||
// once — the scheduler must never park forever waiting for a lead that will never come.
|
||||
test('streamComplete tail below the rebuffer lead still plays out and fires end once', () => {
|
||||
const cm = new FakeContextManager();
|
||||
const s = makeScheduler(cm);
|
||||
let ended = 0;
|
||||
s.onPlaybackEnded = () => { ended++; };
|
||||
|
||||
for (let i = 0; i < 3; i++) s.addBuffer(buf(0.3));
|
||||
cm.now = 0;
|
||||
s.playFromPosition(0);
|
||||
cm.now = 1.0;
|
||||
drainAllSources(s, cm); // underrun
|
||||
assertEqual(s.isActive(), false, 'parked in underrun');
|
||||
|
||||
// A short final tail (0.6s, below the 1s threshold) arrives; the hysteresis keeps it parked.
|
||||
for (let i = 0; i < 2; i++) s.addBuffer(buf(0.3));
|
||||
s.scheduleNewBuffers();
|
||||
assertEqual(s.isActive(), false, 'parked — tail below threshold, stream not yet complete');
|
||||
assertEqual(ended, 0, 'no end before completion');
|
||||
|
||||
// The stream completes: the threshold no longer applies → the tail schedules and plays out.
|
||||
s.setStreamComplete(true);
|
||||
assertEqual(s.isActive(), true, 'resumed to play out the final tail on completion');
|
||||
assertEqual(ended, 0, 'end not fired until the tail drains');
|
||||
|
||||
// Drain the tail → genuine end fires exactly once.
|
||||
cm.now = 2.0;
|
||||
drainAllSources(s, cm);
|
||||
assertEqual(ended, 1, 'genuine end fires exactly once after the tail drains');
|
||||
assertEqual(s.isActive(), false, 'inactive after genuine end');
|
||||
});
|
||||
|
||||
// GENUINE end: stream complete AND queue drains → onPlaybackEnded fires exactly once.
|
||||
test('genuine end (streamComplete + drained) fires onPlaybackEnded exactly once', () => {
|
||||
const cm = new FakeContextManager();
|
||||
const s = makeScheduler(cm);
|
||||
let ended = 0;
|
||||
s.onPlaybackEnded = () => { ended++; };
|
||||
|
||||
for (let i = 0; i < 3; i++) s.addBuffer(buf(0.3));
|
||||
cm.now = 0;
|
||||
s.playFromPosition(0);
|
||||
s.setStreamComplete(true); // all bytes in, no more buffers coming
|
||||
|
||||
cm.now = 1.0;
|
||||
drainAllSources(s, cm);
|
||||
|
||||
assertEqual(ended, 1, 'end fired once on genuine completion');
|
||||
assertEqual(s.isActive(), false, 'inactive after genuine end');
|
||||
});
|
||||
|
||||
// setStreamComplete arriving AFTER the queue has already drained mid-stream (the tail produced no
|
||||
// new buffers) must finalise immediately — the genuine-end signal that landed late.
|
||||
test('setStreamComplete after an already-drained queue finalises immediately', () => {
|
||||
const cm = new FakeContextManager();
|
||||
const s = makeScheduler(cm);
|
||||
let ended = 0;
|
||||
s.onPlaybackEnded = () => { ended++; };
|
||||
|
||||
for (let i = 0; i < 3; i++) s.addBuffer(buf(0.3));
|
||||
cm.now = 0;
|
||||
s.playFromPosition(0);
|
||||
cm.now = 1.0;
|
||||
drainAllSources(s, cm); // underrun, no end yet
|
||||
assertEqual(ended, 0, 'no end before completion signal');
|
||||
|
||||
s.setStreamComplete(true); // signal arrives now → finalise
|
||||
assertEqual(ended, 1, 'end fired when completion signalled post-drain');
|
||||
});
|
||||
|
||||
// clearForSeek must reset streamComplete so a post-seek refill cannot inherit a stale "complete"
|
||||
// and fire a premature end before its own bytes arrive.
|
||||
test('clearForSeek resets streamComplete (no inherited end on refill)', () => {
|
||||
const cm = new FakeContextManager();
|
||||
const s = makeScheduler(cm);
|
||||
let ended = 0;
|
||||
s.onPlaybackEnded = () => { ended++; };
|
||||
|
||||
for (let i = 0; i < 3; i++) s.addBuffer(buf(0.3));
|
||||
cm.now = 0;
|
||||
s.playFromPosition(0);
|
||||
s.setStreamComplete(true);
|
||||
|
||||
s.clearForSeek();
|
||||
s.setPlaybackOffset(5);
|
||||
// Post-seek continuation: fresh buffers, playback resumes, stream NOT yet complete.
|
||||
for (let i = 0; i < 3; i++) s.addBuffer(buf(0.3));
|
||||
cm.now = 5;
|
||||
s.playFromPosition(0);
|
||||
cm.now = 6.0;
|
||||
drainAllSources(s, cm);
|
||||
|
||||
assertEqual(ended, 0, 'no end fired — stale streamComplete was cleared by clearForSeek');
|
||||
});
|
||||
|
||||
// pause() during underrun: setStreamComplete must NOT fire end while the user is paused.
|
||||
// This is the narrow window the fix to pause() closes: without the underrun_ clear, a paused
|
||||
// scheduler that was mid-underrun satisfies the setStreamComplete immediate-finalise guard
|
||||
// (complete && underrun_ && drained) and fires TrackEnded / queue-advance while paused.
|
||||
test('pause during underrun: setStreamComplete does not fire end while paused', () => {
|
||||
const cm = new FakeContextManager();
|
||||
const s = makeScheduler(cm);
|
||||
let ended = 0;
|
||||
s.onPlaybackEnded = () => { ended++; };
|
||||
|
||||
// A short run of buffers, drain them mid-stream → scheduler parks in underrun.
|
||||
for (let i = 0; i < 3; i++) s.addBuffer(buf(0.3));
|
||||
cm.now = 0;
|
||||
s.playFromPosition(0);
|
||||
cm.now = 1.0;
|
||||
drainAllSources(s, cm); // queue drained, streamComplete still false → underrun
|
||||
assertEqual(s.isActive(), false, 'parked in underrun after drain');
|
||||
assertEqual(ended, 0, 'no end before pause');
|
||||
|
||||
// User pauses while the scheduler is parked in underrun.
|
||||
s.pause();
|
||||
|
||||
// Stream completes with no further buffers (the tail produced nothing new).
|
||||
// With the fix, pause() cleared underrun_ so this must NOT finalise immediately.
|
||||
s.setStreamComplete(true);
|
||||
|
||||
assertEqual(ended, 0, 'no end fired while paused — setStreamComplete must not fire during pause');
|
||||
assertEqual(s.isActive(), false, 'scheduler stays inactive after setStreamComplete during pause');
|
||||
});
|
||||
|
||||
// underrun → resume → genuine end fires exactly once: the full composition from a mid-stream gap
|
||||
// through resumed playback to completion. Confirms no double-fire and no stuck scheduler.
|
||||
test('underrun → resume → genuine end fires exactly once', () => {
|
||||
const cm = new FakeContextManager();
|
||||
const s = makeScheduler(cm);
|
||||
let ended = 0;
|
||||
s.onPlaybackEnded = () => { ended++; };
|
||||
|
||||
// Drain initial buffers into underrun.
|
||||
for (let i = 0; i < 3; i++) s.addBuffer(buf(0.3));
|
||||
cm.now = 0;
|
||||
s.playFromPosition(0);
|
||||
cm.now = 1.0;
|
||||
drainAllSources(s, cm);
|
||||
assertEqual(s.isActive(), false, 'underrun after initial drain');
|
||||
assertEqual(ended, 0, 'no end count during underrun');
|
||||
|
||||
// Decode catches up: enough buffers arrive to clear the 1s rebuffer lead (4 × 0.3 = 1.2s).
|
||||
for (let i = 0; i < 4; i++) s.addBuffer(buf(0.3));
|
||||
s.scheduleNewBuffers();
|
||||
assertEqual(s.isActive(), true, 'resumed active after refill');
|
||||
assertEqual(ended, 0, 'still no end after resume');
|
||||
|
||||
// Mark the stream complete, then drain the resumed sources to genuine end.
|
||||
s.setStreamComplete(true);
|
||||
cm.now = 2.0;
|
||||
drainAllSources(s, cm);
|
||||
|
||||
assertEqual(ended, 1, 'end fires exactly once after genuine completion');
|
||||
assertEqual(s.isActive(), false, 'inactive after genuine end');
|
||||
});
|
||||
|
||||
// === Complete-without-start (force-start fallback) ==========================================
|
||||
//
|
||||
// The C# producer calls StartStreamingPlayback after MarkStreamCompleteAsync when
|
||||
// _streamingPlaybackStarted is still false (total audio below the start threshold). The JS-side
|
||||
// effect is playFromPosition(0) called with streamComplete already true. This section covers the
|
||||
// scheduler-side guarantee: sub-threshold buffers + streamComplete already set + forced
|
||||
// playFromPosition drains and fires end exactly once, never zero, never twice.
|
||||
//
|
||||
// The C# transition itself is not exercisable here (requires StreamingAudioPlayerService +
|
||||
// AudioInteropService), so the test covers the scheduler drain-and-end-once contract directly.
|
||||
|
||||
// Forced start after completion: sub-threshold total audio, streamComplete set BEFORE
|
||||
// playFromPosition(0), sources drain and onPlaybackEnded fires exactly once.
|
||||
test('forced start on complete stream: sub-threshold buffers drain and fire end exactly once', () => {
|
||||
const cm = new FakeContextManager();
|
||||
const s = makeScheduler(cm);
|
||||
let ended = 0;
|
||||
s.onPlaybackEnded = () => { ended++; };
|
||||
|
||||
// Sub-threshold buffers (0.4s total, below the 1s rebuffer lead). Never started.
|
||||
for (let i = 0; i < 2; i++) s.addBuffer(buf(0.2));
|
||||
|
||||
// Stream marks complete BEFORE playback starts — the C# completion-path ordering:
|
||||
// MarkStreamCompleteAsync fires first, then StartStreamingPlayback is called because
|
||||
// _streamingPlaybackStarted is false. setStreamComplete with underrun_=false returns
|
||||
// early (sets the flag but does not schedule/finalize — that is correct, nothing to drain yet).
|
||||
s.setStreamComplete(true);
|
||||
assertEqual(ended, 0, 'no end fired at setStreamComplete — playback not yet started');
|
||||
assertEqual(s.isActive(), false, 'scheduler inactive before forced start');
|
||||
|
||||
// Forced start: C# calls startStreamingPlayback() → playFromPosition(0).
|
||||
// With streamComplete already true and buffers present, this schedules all buffers.
|
||||
cm.now = 0;
|
||||
s.playFromPosition(0);
|
||||
|
||||
const priv = s as unknown as { scheduledSources: unknown[] };
|
||||
if (priv.scheduledSources.length === 0) {
|
||||
throw new Error('expected sources scheduled after forced playFromPosition');
|
||||
}
|
||||
assertEqual(ended, 0, 'end not fired yet — sources must drain first');
|
||||
assertEqual(s.isActive(), true, 'scheduler active while sources are scheduled');
|
||||
|
||||
// Drain sources → streamComplete is true → genuine end fires exactly once.
|
||||
cm.now = 0.5;
|
||||
drainAllSources(s, cm);
|
||||
|
||||
assertEqual(ended, 1, 'end fires exactly once after forced-start drain');
|
||||
assertEqual(s.isActive(), false, 'scheduler inactive after genuine end');
|
||||
});
|
||||
|
||||
// No double-fire: calling setStreamComplete again after end has already fired is a no-op.
|
||||
test('setStreamComplete after forced-start drain is a no-op (no double end)', () => {
|
||||
const cm = new FakeContextManager();
|
||||
const s = makeScheduler(cm);
|
||||
let ended = 0;
|
||||
s.onPlaybackEnded = () => { ended++; };
|
||||
|
||||
for (let i = 0; i < 2; i++) s.addBuffer(buf(0.2));
|
||||
s.setStreamComplete(true);
|
||||
cm.now = 0;
|
||||
s.playFromPosition(0);
|
||||
cm.now = 0.5;
|
||||
drainAllSources(s, cm);
|
||||
assertEqual(ended, 1, 'end fired once after forced-start drain');
|
||||
|
||||
// A redundant setStreamComplete (e.g. called again from a stale C# path) must not re-fire.
|
||||
s.setStreamComplete(true);
|
||||
assertEqual(ended, 1, 'still exactly one end after redundant setStreamComplete');
|
||||
});
|
||||
|
||||
// --- run -------------------------------------------------------------------------------------
|
||||
if (failures.length > 0) {
|
||||
console.error(failures.join('\n'));
|
||||
console.error(`\n${failures.length} FAILED, ${passed} passed`);
|
||||
process.exit(1);
|
||||
} else {
|
||||
console.log(`ALL ${passed} TESTS PASSED`);
|
||||
}
|
||||
@@ -2,10 +2,94 @@
|
||||
* PlaybackScheduler - Manages AudioBuffer storage and playback scheduling.
|
||||
*
|
||||
* Single Responsibility: Store decoded buffers and schedule them for playback.
|
||||
* Supports pause/resume/seek by retaining all buffers.
|
||||
*
|
||||
* Memory model (Phase 21.1 — partial eviction)
|
||||
* --------------------------------------------
|
||||
* The scheduler is the single shared sink both decode paths feed (WAV/MP3/FLAC via
|
||||
* `IFormatDecoder`, Opus via the WebCodecs `IStreamingDecoder`); eviction lives here once
|
||||
* and serves both with zero format branches.
|
||||
*
|
||||
* THE INDEX/TIME-ANCHOR INVARIANT (the crux of 21.1):
|
||||
* `playbackOffset` is the absolute track time at which `buffers[0]` begins. Every
|
||||
* position query and scheduling decision is expressed as `playbackOffset` + a sum of
|
||||
* `buffers[i].duration` from index 0. Originally `buffers[0]` was always the track start,
|
||||
* so `playbackOffset` was 0 except after a seek-beyond-buffer. After partial eviction
|
||||
* `buffers[0]` is no longer the track start — so eviction MUST add the dropped buffers'
|
||||
* total duration to `playbackOffset`. That one move keeps `getCurrentPosition`,
|
||||
* `playFromPosition`, the `getTotalDuration`-based clamp/bounds, and the schedule loop all
|
||||
* exact against a buffer array that no longer starts at absolute time 0.
|
||||
*
|
||||
* The second half of the invariant is the array indices. `nextBufferIndex` and every live
|
||||
* `scheduledSources[].bufferIndex` are absolute positions into `buffers`; splicing `k`
|
||||
* buffers off the front shifts every surviving index down by `k`, so both must be
|
||||
* decremented by `k`. Eviction therefore never crosses the live frontier: it will not drop
|
||||
* a buffer at/after `nextBufferIndex`, nor one still referenced by a scheduled source.
|
||||
*/
|
||||
|
||||
import { AudioContextManager } from './AudioContextManager.js';
|
||||
import { decodePressure } from './decodePressure.js';
|
||||
|
||||
/**
|
||||
* Provisional back-retain default. The window-size POLICY (OQ1/OQ3) is not decided yet, so
|
||||
* this is intentionally a tunable seam (see setBackRetainSeconds), not a baked-in number —
|
||||
* 21.2 feeds real water-marks in later. The default keeps a few seconds of already-played
|
||||
* audio so a short seek-back stays in-buffer (UC3) without a network refetch.
|
||||
*/
|
||||
const DEFAULT_BACK_RETAIN_SECONDS = 10;
|
||||
|
||||
/**
|
||||
* Forward back-pressure water-marks (Phase 21.2 — the bound on the *unplayed* region).
|
||||
*
|
||||
* The single back-pressure signal is the scheduler's decoded forward lookahead: how many
|
||||
* seconds of decoded audio sit AHEAD of the playhead (OQ7). Production (the C# read loop and,
|
||||
* for Opus, the demux/decode feed) pauses above the high-water mark and resumes below the
|
||||
* low-water mark — classic hysteresis so the two producers do not chatter on/off per chunk.
|
||||
*
|
||||
* Time-based defaults — the cushion, NOT the memory bound:
|
||||
* - HIGH (60 s): the most decoded lookahead we hold ahead of the playhead before throttling.
|
||||
* Comfortably above the playback-start minimum (`AudioPlayer.minBuffersForPlayback = 6`
|
||||
* buffers, each typically 0.06 – 1 s depending on format/chunk size), so C2 holds — first
|
||||
* audio never waits on a throttle (the high-water is reached only well after playback runs).
|
||||
* - LOW (30 s): resume producing here. Kept generous so the forward fill never drains to the
|
||||
* ~500 ms scheduler lookahead under network/decode jitter (AC3 — no starvation).
|
||||
*
|
||||
* Why 60/30 and not the old 30/15: the time window is a CUSHION knob, not the memory guarantee —
|
||||
* the OQ3 byte ceiling below is the hard OOM bound. The old 30 s was sized for WAV's byte density
|
||||
* and needlessly starved the cushion for the async WebCodecs Opus path, whose decoded float
|
||||
* footprint is tiny (48 kHz stereo ≈ 0.37 MB/s, so 60 s ≈ 23 MB — a fraction of the 96 MB cap)
|
||||
* yet whose per-packet decode jitter (HW-accel-off software decode, main-thread AudioData copies)
|
||||
* needs a deeper buffer to stay ahead of the playhead. Doubling the window lets Opus use the memory
|
||||
* headroom the byte cap already permits. The byte cap is UNCHANGED, so a high-footprint stream
|
||||
* still pauses at exactly the same footprint as before — the OOM fix does not regress.
|
||||
*
|
||||
* OQ3 hard memory ceiling: an absolute byte cap on total decoded float held, independent of the
|
||||
* time window. This is the guard-rail that makes "1 GB never OOMs" a guarantee rather than a
|
||||
* tuning hope — production pauses on `lookahead >= high OR bytes > cap`, whichever fires first, so
|
||||
* the footprint can never exceed the cap regardless of the time window. The decoded f32 footprint
|
||||
* scales with sample rate × channels (not source codec), so for high-sample-rate / multichannel
|
||||
* audio the byte cap fires before 60 s (bounding memory exactly as the old 30 s window's byte
|
||||
* estimate did); for sparse 48 kHz stereo Opus the time window fires first, at ~23 MB. Estimated
|
||||
* as channels × frames × 4 (f32).
|
||||
*/
|
||||
const DEFAULT_FORWARD_HIGH_WATER_SECONDS = 60;
|
||||
const DEFAULT_FORWARD_LOW_WATER_SECONDS = 30;
|
||||
const DEFAULT_MAX_DECODED_BYTES = 96 * 1024 * 1024; // ~96 MB of decoded float PCM — the HARD OOM bound
|
||||
const BYTES_PER_FLOAT_SAMPLE = 4;
|
||||
|
||||
/**
|
||||
* Rebuffer hysteresis lead — the minimum SECONDS of decoded-but-unscheduled audio that must
|
||||
* accumulate ahead of the schedule cursor before playback may (re)start after a mid-stream underrun.
|
||||
*
|
||||
* Why seconds, not a buffer count: the per-buffer duration differs wildly by format. A WAV/lossless
|
||||
* segment is a sizeable slab (~0.1–0.4 s); a single Opus WebCodecs packet is ~20 ms. The old resume
|
||||
* path re-anchored on the FIRST arriving buffer, so for Opus it scheduled ~20 ms, drained it, parked,
|
||||
* resumed on the next ~20 ms, and so on — the audible start/stop thrash during the WebCodecs decode
|
||||
* ramp. Gating on a fixed LEAD in seconds gives a resume the same cushion a fresh start has,
|
||||
* independent of format. 1 s is the same order as the lossless playback-start lead (~6 segments) and
|
||||
* sits far below the 60 s forward high-water, so back-pressure never throttles production while the
|
||||
* scheduler is still re-accumulating this lead. Tunable; not magic.
|
||||
*/
|
||||
const DEFAULT_MIN_PLAYBACK_LEAD_SECONDS = 1.0;
|
||||
|
||||
interface ScheduledSource {
|
||||
source: AudioBufferSourceNode;
|
||||
@@ -26,11 +110,53 @@ export class PlaybackScheduler {
|
||||
private nextScheduleTime: number = 0; // AudioContext time for next buffer
|
||||
private isActive_: boolean = false; // Prevents scheduling during pause/stop
|
||||
|
||||
// Offset for seek-beyond-buffer scenarios
|
||||
// When seeking to position T beyond buffers, we clear buffers and set playbackOffset = T
|
||||
// The new stream starts at T, so buffer positions are relative to T
|
||||
// Offset for seek-beyond-buffer scenarios AND partial eviction.
|
||||
// This is the absolute track time at which buffers[0] begins. It is set on
|
||||
// seek-beyond-buffer (the new stream starts at T) and ADVANCED by eviction (when the
|
||||
// front k buffers are dropped, their total duration is added here so buffers[0] still
|
||||
// names the correct absolute time). See the index/time-anchor invariant in the header.
|
||||
private playbackOffset: number = 0;
|
||||
|
||||
// Back-retain bound (seconds of already-played audio kept un-evicted). Provisional seam;
|
||||
// 21.2 will drive this from the window policy. Not a hardcoded eviction decision.
|
||||
private backRetainSeconds: number = DEFAULT_BACK_RETAIN_SECONDS;
|
||||
|
||||
// Forward back-pressure water-marks + the OQ3 hard byte ceiling (Phase 21.2). This is the
|
||||
// single shared window policy (OQ6): both producers call evaluateProductionPause() and honor it
|
||||
// in their own way — the C# read loop stops ReadAsync, the Opus feed stops demux/decode.
|
||||
private forwardHighWaterSeconds: number = DEFAULT_FORWARD_HIGH_WATER_SECONDS;
|
||||
private forwardLowWaterSeconds: number = DEFAULT_FORWARD_LOW_WATER_SECONDS;
|
||||
private maxDecodedBytes: number = DEFAULT_MAX_DECODED_BYTES;
|
||||
|
||||
// Rebuffer hysteresis lead (seconds). The minimum decoded-but-unscheduled audio that must sit
|
||||
// ahead of the schedule cursor before playback may (re)start — at a fresh start AND after a
|
||||
// mid-stream underrun. Without it the underrun resume re-anchored on the first arriving buffer
|
||||
// and thrashed on the Opus decode ramp. See DEFAULT_MIN_PLAYBACK_LEAD_SECONDS.
|
||||
private minPlaybackLeadSeconds: number = DEFAULT_MIN_PLAYBACK_LEAD_SECONDS;
|
||||
|
||||
// Hysteresis latch for the production pause. Once forward fill crosses the high-water mark we
|
||||
// stay paused until it drains below the low-water mark, so the two producers do not flap
|
||||
// on/off around a single threshold (and a paused producer does not resume for one chunk only
|
||||
// to re-pause immediately). False until first crossing; flips on the band edges.
|
||||
// Mutated by evaluateProductionPause() — named to signal the state-advance on each call.
|
||||
private productionPaused_: boolean = false;
|
||||
|
||||
// True once the producer (C# read loop / Opus feed) has signalled that ALL bytes are in and
|
||||
// every decodable buffer has been added. This is the discriminator between a genuine
|
||||
// end-of-track and a transient gap. End-of-playback fires ONLY when this is true AND the
|
||||
// scheduled queue has drained — a drained queue while this is false is a startup/underrun gap,
|
||||
// not the end (Opus decodes via WebCodecs asynchronously, so the first AudioBuffer can lag the
|
||||
// playback-start minimum, briefly leaving zero scheduled sources before real playback). Reset
|
||||
// by clear/clearForSeek/resetToStart; set by setStreamComplete.
|
||||
private streamComplete: boolean = false;
|
||||
|
||||
// True while playback is logically running but the decoded queue ran dry mid-stream (underrun).
|
||||
// We stop the scheduler (isActive_ = false) so no source schedules against a stale anchor, but
|
||||
// remember we must re-anchor and resume the moment new buffers arrive — distinct from a paused/
|
||||
// stopped player, which clears this. Without it, scheduleNewBuffers would silently no-op on the
|
||||
// !isActive_ guard and playback would never recover from a starvation gap.
|
||||
private underrun_: boolean = false;
|
||||
|
||||
// Callbacks
|
||||
public onPlaybackEnded: (() => void) | null = null;
|
||||
|
||||
@@ -45,6 +171,38 @@ export class PlaybackScheduler {
|
||||
this.buffers.push(buffer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark whether the byte stream is complete (all bytes received and all decodable buffers added).
|
||||
* The end-of-playback callback fires only when this is true AND the scheduled queue has drained —
|
||||
* so a drained queue while the stream is still in flight (startup/underrun) is never mistaken for
|
||||
* end-of-track. Set true by AudioPlayer on markStreamComplete / decoder isComplete; set false on a
|
||||
* fresh stream or a range-continuation reinit. Setting it true while playback has already drained
|
||||
* mid-stream finalises the track immediately (the genuine-end signal arrived after the queue
|
||||
* emptied — e.g. the very last buffers were the tail).
|
||||
*/
|
||||
setStreamComplete(complete: boolean): void {
|
||||
this.streamComplete = complete;
|
||||
// Only act when the genuine-end signal lands while we are parked in underrun (logically
|
||||
// playing but starved); a drained queue with no playback in flight — never started, or
|
||||
// already finished — is left untouched. Gated on underrun_, not isActive_, which is false
|
||||
// during a parked underrun.
|
||||
if (!complete || !this.underrun_) {
|
||||
return;
|
||||
}
|
||||
// The rebuffer threshold no longer applies — a complete stream yields no further buffers:
|
||||
// - tail buffers accumulated below the threshold while we were parked (the new hysteresis
|
||||
// kept us parked) → schedule them out; scheduleNewBuffers' underrun branch now resumes
|
||||
// because streamComplete overrides the lead gate, and handleSourceEnded fires the genuine
|
||||
// end when they drain. Without this the buffers would never schedule and we would park
|
||||
// forever (queue drained, isActive_ false, threshold never met).
|
||||
// - no tail at all (cursor already at the decoded end) → this drained state IS the end.
|
||||
if (this.nextBufferIndex < this.buffers.length) {
|
||||
this.scheduleNewBuffers();
|
||||
} else if (this.scheduledSources.length === 0) {
|
||||
this.finishPlayback();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get total duration of all stored buffers
|
||||
*/
|
||||
@@ -88,6 +246,169 @@ export class PlaybackScheduler {
|
||||
return this.playbackOffset;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure the back-retain bound (seconds of already-played audio kept un-evicted).
|
||||
* Provisional config seam — 21.2 feeds the real window policy in here. Negative values
|
||||
* are clamped to 0 (retain nothing behind the playhead).
|
||||
*/
|
||||
setBackRetainSeconds(seconds: number): void {
|
||||
this.backRetainSeconds = Math.max(0, seconds);
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure the forward back-pressure water-marks (seconds of decoded lookahead) and the OQ3
|
||||
* hard byte ceiling. Provisional config seam — 21.4 tunes the numbers. Low is clamped below
|
||||
* high so the hysteresis band is always valid; non-positive byte cap disables the OQ3 guard.
|
||||
*/
|
||||
setForwardWindow(highWaterSeconds: number, lowWaterSeconds: number, maxDecodedBytes: number): void {
|
||||
this.forwardHighWaterSeconds = Math.max(0, highWaterSeconds);
|
||||
this.forwardLowWaterSeconds = Math.max(0, Math.min(lowWaterSeconds, this.forwardHighWaterSeconds));
|
||||
this.maxDecodedBytes = maxDecodedBytes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seconds of decoded audio sitting AHEAD of the current playhead — the forward fill. This is
|
||||
* the single back-pressure signal (OQ7): the absolute end time of the last decoded buffer
|
||||
* minus the current playback position. Never negative (clamped at 0 when the playhead has
|
||||
* caught up to or passed the decoded tail).
|
||||
*/
|
||||
getForwardLookaheadSeconds(): number {
|
||||
const decodedEnd = this.getTotalDuration() + this.playbackOffset;
|
||||
return Math.max(0, decodedEnd - this.getCurrentPosition());
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimated bytes of decoded float PCM currently retained (OQ3 input). Web Audio AudioBuffers
|
||||
* are 32-bit float per sample per channel; frames = duration × sampleRate. Summed across the
|
||||
* retained buffers only — evicted buffers are already reclaimed, so this tracks the live
|
||||
* footprint, not the whole track.
|
||||
*/
|
||||
getDecodedByteEstimate(): number {
|
||||
let bytes = 0;
|
||||
for (const b of this.buffers) {
|
||||
bytes += b.length * b.numberOfChannels * BYTES_PER_FLOAT_SAMPLE;
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/**
|
||||
* The single shared production-pause decision (Phase 21.2, OQ6/OQ7). Both producers — the C#
|
||||
* read loop (21.2a) and the Opus demux/decode feed (21.2b) — call this and stop producing
|
||||
* while it returns true. Hysteresis: pause when forward lookahead exceeds the high-water mark
|
||||
* OR the decoded byte estimate exceeds the OQ3 ceiling; resume only once forward lookahead has
|
||||
* drained below the low-water mark AND the byte estimate is back under the ceiling. The
|
||||
* byte-ceiling test has no separate low-water band — it is the hard guard rail, so it releases
|
||||
* as soon as eviction brings the footprint back under the cap.
|
||||
*
|
||||
* Named `evaluateProductionPause` (not `isProductionPaused`) because each call may ADVANCE the
|
||||
* hysteresis latch (`productionPaused_`), making it a state-advancing evaluation, not a pure
|
||||
* read. `AudioPlayer.isProductionPaused()` is the pure-predicate wrapper exposed to callers
|
||||
* outside the scheduler.
|
||||
*/
|
||||
evaluateProductionPause(): boolean {
|
||||
const lookahead = this.getForwardLookaheadSeconds();
|
||||
const overByteCeiling = this.maxDecodedBytes > 0 && this.getDecodedByteEstimate() > this.maxDecodedBytes;
|
||||
|
||||
if (this.productionPaused_) {
|
||||
// Stay paused until BOTH the time window has drained below low-water AND the byte
|
||||
// footprint is back under the ceiling.
|
||||
if (lookahead <= this.forwardLowWaterSeconds && !overByteCeiling) {
|
||||
this.productionPaused_ = false;
|
||||
}
|
||||
} else if (lookahead >= this.forwardHighWaterSeconds || overByteCeiling) {
|
||||
this.productionPaused_ = true;
|
||||
}
|
||||
|
||||
return this.productionPaused_;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop already-played buffers from the front of the array, reclaiming their decoded float
|
||||
* memory, and advance the time anchor so all position/index bookkeeping stays exact.
|
||||
*
|
||||
* Eviction frontier: any buffer whose absolute END time is at or older than
|
||||
* (currentPosition - backRetainSeconds) is droppable. We evict a contiguous run from the
|
||||
* front only — buffers are appended in playback order, so the front is always the oldest.
|
||||
*
|
||||
* Two hard safety bounds keep the live frontier intact (the second half of the
|
||||
* index/time-anchor invariant):
|
||||
* 1. Never evict at/after `nextBufferIndex` — those are not yet scheduled; dropping them
|
||||
* would lose unplayed audio and corrupt the schedule cursor.
|
||||
* 2. Never evict a buffer still referenced by a live scheduled source — its
|
||||
* AudioBufferSourceNode is mid-flight and `handleSourceEnded` still tracks it.
|
||||
*
|
||||
* Returns the number of buffers evicted (0 if nothing was droppable).
|
||||
*
|
||||
* This is the SHARED eviction both decode paths get for free — no format branch. It does
|
||||
* not fetch, decode, or back-pressure (those are 21.2/21.3); with producers unchanged it
|
||||
* makes the *played* region provably memory-bounded on both paths.
|
||||
*/
|
||||
evictPlayedBuffers(): number {
|
||||
if (this.buffers.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Absolute time before which a fully-ended buffer may be dropped.
|
||||
const evictBefore = this.getCurrentPosition() - this.backRetainSeconds;
|
||||
|
||||
// Lowest index still referenced by a live scheduled source (or buffers.length if none).
|
||||
// Eviction must not cross this — those sources are playing now.
|
||||
let firstLiveIndex = this.buffers.length;
|
||||
for (const scheduled of this.scheduledSources) {
|
||||
if (scheduled.bufferIndex < firstLiveIndex) {
|
||||
firstLiveIndex = scheduled.bufferIndex;
|
||||
}
|
||||
}
|
||||
|
||||
// Hard ceiling on how many front buffers we may drop: not past the schedule cursor,
|
||||
// and not past the oldest live source.
|
||||
const maxEvictable = Math.min(this.nextBufferIndex, firstLiveIndex);
|
||||
|
||||
// Walk the front, accumulating absolute end times, counting droppable buffers.
|
||||
let evictCount = 0;
|
||||
let accumulatedEnd = this.playbackOffset;
|
||||
for (let i = 0; i < maxEvictable; i++) {
|
||||
accumulatedEnd += this.buffers[i].duration;
|
||||
// Drop buffers whose END is at or behind the retain frontier (inclusive bound).
|
||||
if (accumulatedEnd <= evictBefore) {
|
||||
evictCount = i + 1;
|
||||
} else {
|
||||
break; // later buffers end even later — nothing more is droppable
|
||||
}
|
||||
}
|
||||
|
||||
if (evictCount === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Sum the dropped duration BEFORE splicing, then advance the time anchor by it so
|
||||
// buffers[0] still names the correct absolute start time. This is the move that keeps
|
||||
// every position/scheduling query exact against a front-evicted array.
|
||||
let droppedDuration = 0;
|
||||
for (let i = 0; i < evictCount; i++) {
|
||||
droppedDuration += this.buffers[i].duration;
|
||||
}
|
||||
|
||||
this.buffers.splice(0, evictCount);
|
||||
|
||||
// Advance the absolute time anchor (offset) by the dropped duration AND drop the
|
||||
// buffer-relative anchor position by the same amount. These two move in lockstep:
|
||||
// getCurrentPosition() is (playbackAnchorPosition + playbackOffset + elapsed), so
|
||||
// adjusting only one would make the reported position jump by droppedDuration.
|
||||
// Moving both by +d / -d leaves the ABSOLUTE position unchanged while keeping
|
||||
// playbackAnchorPosition buffer-relative (the convention playFromPosition/pause use).
|
||||
this.playbackOffset += droppedDuration;
|
||||
this.playbackAnchorPosition -= droppedDuration;
|
||||
|
||||
// Every surviving absolute index shifts down by evictCount.
|
||||
this.nextBufferIndex -= evictCount;
|
||||
for (const scheduled of this.scheduledSources) {
|
||||
scheduled.bufferIndex -= evictCount;
|
||||
}
|
||||
|
||||
return evictCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start or resume playback from a specific position
|
||||
*/
|
||||
@@ -111,18 +432,25 @@ export class PlaybackScheduler {
|
||||
}
|
||||
|
||||
if (startBufferIndex >= this.buffers.length) {
|
||||
// Position landed at or past the end of all buffers. Previously this
|
||||
// returned silently, leaving the player stuck "playing" with no source
|
||||
// scheduled — a pause near the end followed by play never recovered.
|
||||
// Treat this as end-of-track so listeners (UI / end callback) fire.
|
||||
this.isActive_ = false;
|
||||
this.playbackAnchorTime = 0;
|
||||
this.playbackAnchorPosition = 0;
|
||||
this.onPlaybackEnded?.();
|
||||
// Position landed at or past the end of all currently-decoded buffers. This is
|
||||
// end-of-track ONLY if the stream is complete; otherwise it is a startup/underrun
|
||||
// gap (decode hasn't caught up to the playhead yet) and firing onPlaybackEnded here
|
||||
// would be a FALSE end — exactly the Opus-startup misfire. When complete, finish;
|
||||
// when still streaming, park in underrun so scheduleNewBuffers resumes on the next
|
||||
// decoded buffer rather than the player being stuck "playing" with nothing scheduled.
|
||||
if (this.streamComplete) {
|
||||
this.finishPlayback();
|
||||
} else {
|
||||
this.underrun_ = true;
|
||||
this.playbackAnchorPosition = position;
|
||||
this.nextBufferIndex = startBufferIndex;
|
||||
this.isActive_ = false; // no source to schedule yet; resume() re-anchors on refill
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Set timing anchors
|
||||
this.underrun_ = false;
|
||||
this.playbackAnchorPosition = position;
|
||||
this.playbackAnchorTime = this.contextManager.currentTime;
|
||||
this.nextScheduleTime = this.contextManager.currentTime + 0.01; // Small lookahead
|
||||
@@ -141,6 +469,34 @@ export class PlaybackScheduler {
|
||||
return; // No new buffers
|
||||
}
|
||||
|
||||
// Resume from a mid-stream underrun: the queue had drained ahead of decode and we parked
|
||||
// (isActive_ = false, underrun_ = true) instead of firing a false end. Newly decoded
|
||||
// buffers are now available at nextBufferIndex, so re-anchor the clock at the resume point
|
||||
// and re-enable scheduling. We re-anchor (rather than reusing the stale nextScheduleTime
|
||||
// captured before the gap) so the resumed audio is contiguous from "now" — a stale anchor
|
||||
// would schedule the next source in the past and the browser would drop or rush it.
|
||||
if (this.underrun_) {
|
||||
// Rebuffer hysteresis: do NOT resume on the first arriving buffer. With an empty scheduled
|
||||
// tail, resuming on a single buffer plays it (~20 ms for Opus) and immediately re-drains,
|
||||
// re-parking — the audible start/stop thrash on the Opus WebCodecs decode ramp. Stay parked
|
||||
// and keep accumulating until a healthy lead has rebuilt, so the resumed playback has the
|
||||
// same cushion a fresh start does. While parked the playhead is frozen, so each arriving
|
||||
// buffer grows the lead monotonically toward the threshold (no starvation/deadlock).
|
||||
//
|
||||
// streamComplete overrides the gate: a finished stream produces no further buffers, so a
|
||||
// tail shorter than the lead MUST still play out (here and via setStreamComplete) rather
|
||||
// than park forever. handleSourceEnded fires the genuine end once that tail drains.
|
||||
if (!this.streamComplete && !this.hasMinimumPlaybackLead()) {
|
||||
return; // still re-accumulating the rebuffer lead — remain parked
|
||||
}
|
||||
this.underrun_ = false;
|
||||
this.isActive_ = true;
|
||||
this.playbackAnchorTime = this.contextManager.currentTime;
|
||||
this.nextScheduleTime = this.contextManager.currentTime + 0.01;
|
||||
this.scheduleBuffersFrom(this.nextBufferIndex, 0);
|
||||
return;
|
||||
}
|
||||
|
||||
// Use isActive_ as the sentinel for "playback is running", not nextScheduleTime === 0.
|
||||
// AudioContext.currentTime can legitimately be 0 at context creation, which would cause
|
||||
// nextScheduleTime === 0 to incorrectly reset a value already set by playFromPosition.
|
||||
@@ -214,26 +570,69 @@ export class PlaybackScheduler {
|
||||
this.scheduledSources.splice(index, 1);
|
||||
}
|
||||
|
||||
// A source just finished, so its buffer is now behind the playhead — the natural
|
||||
// point to reclaim played memory. Eviction is self-contained (no fetch/back-pressure)
|
||||
// and runs before re-scheduling so index bookkeeping is settled first. This is the
|
||||
// 21.1 trigger that keeps the PLAYED region bounded with producers unchanged.
|
||||
this.evictPlayedBuffers();
|
||||
|
||||
// Schedule more buffers if available
|
||||
if (this.nextBufferIndex < this.buffers.length) {
|
||||
this.scheduleBuffersFrom(this.nextBufferIndex, 0);
|
||||
}
|
||||
|
||||
// Check if all playback has finished
|
||||
// The scheduled queue drained AND the cursor caught up to every decoded buffer. Whether
|
||||
// this is the end depends on the stream:
|
||||
// - streamComplete: genuine end-of-track — finish and fire onPlaybackEnded.
|
||||
// - still streaming: a mid-stream UNDERRUN (decode fell behind the playhead — the Opus
|
||||
// WebCodecs startup gap, or a network stall). Firing onPlaybackEnded here is the false
|
||||
// end this guards against. Park in underrun; scheduleNewBuffers resumes on the next
|
||||
// decoded buffer.
|
||||
if (this.scheduledSources.length === 0 && this.nextBufferIndex >= this.buffers.length) {
|
||||
this.isActive_ = false;
|
||||
this.playbackAnchorTime = 0;
|
||||
this.playbackAnchorPosition = 0;
|
||||
this.onPlaybackEnded?.();
|
||||
if (this.streamComplete) {
|
||||
this.finishPlayback();
|
||||
} else {
|
||||
this.underrun_ = true;
|
||||
// Mid-stream underrun: the scheduled queue drained and decode has not caught up. Report it
|
||||
// as decode pressure so the visualizer throttles — a sustained run of these is exactly the
|
||||
// HW-accel-off starvation the auto-throttle protects against. The hysteresis in the signal
|
||||
// ignores a lone startup-ramp underrun; only a sustained run engages the throttle.
|
||||
decodePressure.report();
|
||||
// Hold the playhead at the decoded tail so getCurrentPosition stays exact during
|
||||
// the gap. isActive_ goes false so no stale-anchor scheduling occurs; resume
|
||||
// re-anchors at currentTime when buffers arrive.
|
||||
this.playbackAnchorPosition = this.getCurrentPosition() - this.playbackOffset;
|
||||
this.playbackAnchorTime = 0;
|
||||
this.isActive_ = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finalise playback: stop the clock, reset anchors, and fire the end-of-playback callback. The
|
||||
* single genuine-end path, reached only when the stream is complete AND the queue has fully
|
||||
* drained (handleSourceEnded / setStreamComplete) or playback resumed past a complete stream's
|
||||
* end (playFromPosition). Never called for a transient startup/underrun gap.
|
||||
*/
|
||||
private finishPlayback(): void {
|
||||
this.isActive_ = false;
|
||||
this.underrun_ = false;
|
||||
this.playbackAnchorTime = 0;
|
||||
this.playbackAnchorPosition = 0;
|
||||
this.onPlaybackEnded?.();
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause playback - saves position and stops sources
|
||||
*/
|
||||
pause(): number {
|
||||
const position = this.getCurrentPosition();
|
||||
this.isActive_ = false; // Prevent handleSourceEnded from scheduling more
|
||||
// Clear the underrun flag: if the queue drained mid-stream and the user pauses before new
|
||||
// buffers arrive, a subsequent setStreamComplete must not fire finishPlayback while still
|
||||
// paused. On resume, playFromPosition re-parks underrun if the decoded tail still hasn't
|
||||
// caught up, so no genuine end is lost by clearing it here.
|
||||
this.underrun_ = false;
|
||||
this.stopAllSources();
|
||||
// getCurrentPosition() returns absolute time (anchor + playbackOffset); the anchor
|
||||
// is buffer-relative, so strip the offset back out before storing it.
|
||||
@@ -262,6 +661,8 @@ export class PlaybackScheduler {
|
||||
*/
|
||||
resetToStart(): void {
|
||||
this.isActive_ = false;
|
||||
this.underrun_ = false;
|
||||
this.streamComplete = false;
|
||||
this.stopAllSources();
|
||||
this.playbackAnchorPosition = 0;
|
||||
this.playbackAnchorTime = 0;
|
||||
@@ -274,6 +675,8 @@ export class PlaybackScheduler {
|
||||
*/
|
||||
clear(): void {
|
||||
this.isActive_ = false;
|
||||
this.underrun_ = false;
|
||||
this.streamComplete = false;
|
||||
this.stopAllSources();
|
||||
this.buffers = [];
|
||||
this.playbackAnchorPosition = 0;
|
||||
@@ -281,6 +684,9 @@ export class PlaybackScheduler {
|
||||
this.nextBufferIndex = 0;
|
||||
this.nextScheduleTime = 0;
|
||||
this.playbackOffset = 0;
|
||||
// Release the back-pressure latch — a fresh stream must start unthrottled so its first
|
||||
// chunks decode immediately (C2: no throttle-induced first-audio stall).
|
||||
this.productionPaused_ = false;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -288,6 +694,11 @@ export class PlaybackScheduler {
|
||||
*/
|
||||
clearForSeek(): void {
|
||||
this.isActive_ = false;
|
||||
this.underrun_ = false;
|
||||
// The range continuation is a fresh byte stream — it is NOT complete until its own
|
||||
// markStreamComplete. Reset so a stale "complete" from the pre-seek stream cannot make the
|
||||
// post-seek refill fire a premature end before its bytes arrive.
|
||||
this.streamComplete = false;
|
||||
this.stopAllSources();
|
||||
this.buffers = [];
|
||||
this.playbackAnchorPosition = 0;
|
||||
@@ -295,6 +706,9 @@ export class PlaybackScheduler {
|
||||
this.nextBufferIndex = 0;
|
||||
this.nextScheduleTime = 0;
|
||||
// Note: playbackOffset is NOT reset - it will be set by the caller
|
||||
// Release the back-pressure latch — the post-seek continuation must refill from the new
|
||||
// offset without inheriting the pre-seek paused state.
|
||||
this.productionPaused_ = false;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -311,6 +725,24 @@ export class PlaybackScheduler {
|
||||
return this.buffers.length >= minCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* True once at least `minPlaybackLeadSeconds` of decoded-but-unscheduled audio sits ahead of the
|
||||
* schedule cursor — the rebuffer-hysteresis gate for both a fresh playback start (cursor at 0, so
|
||||
* this measures the whole decoded head) and an underrun resume (cursor at the drained tail, so this
|
||||
* measures only the freshly-accumulated lead). Sums only up to the threshold and short-circuits, so
|
||||
* it is bounded (~one threshold's worth of buffers) regardless of how much is buffered ahead.
|
||||
*/
|
||||
hasMinimumPlaybackLead(): boolean {
|
||||
let lead = 0;
|
||||
for (let i = this.nextBufferIndex; i < this.buffers.length; i++) {
|
||||
lead += this.buffers[i].duration;
|
||||
if (lead >= this.minPlaybackLeadSeconds) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if playback is active
|
||||
*/
|
||||
|
||||
@@ -61,6 +61,16 @@ export class StreamDecoder {
|
||||
// at 4 GB by the 32-bit RIFF size field, so overflow is not a practical concern.
|
||||
private totalRawBytes: number = 0;
|
||||
private processedBytes: number = 0;
|
||||
|
||||
// Absolute count of raw bytes already DROPPED off the front of rawChunks (the memory bound).
|
||||
// processedBytes is an absolute cursor into the whole logical byte stream; rawChunks no longer
|
||||
// begins at stream byte 0 once consumed chunks are compacted away, so extractAlignedData walks
|
||||
// from discardedBytes (the absolute position of rawChunks[0]) rather than 0. totalRawBytes and
|
||||
// every offset stay absolute and unchanged — only the array's front moves. Without this, a long
|
||||
// WAV (e.g. a 92-min mix ≈ 970 MB raw) accumulates its ENTIRE decoded-from body in rawChunks
|
||||
// because consumed chunks were never released; Phase 21.2 bounds only the DECODED scheduler
|
||||
// queue, not this raw queue — so software (HW-accel-off) playback crashed the tab on memory.
|
||||
private discardedBytes: number = 0;
|
||||
private totalStreamLength: number = 0;
|
||||
private streamComplete: boolean = false;
|
||||
private headerError: string | null = null;
|
||||
@@ -94,6 +104,7 @@ export class StreamDecoder {
|
||||
this.rawChunks = [];
|
||||
this.totalRawBytes = 0;
|
||||
this.processedBytes = 0;
|
||||
this.discardedBytes = 0;
|
||||
this.totalStreamLength = totalStreamLength;
|
||||
this.streamComplete = false;
|
||||
this.headerBytesReceived = 0;
|
||||
@@ -228,6 +239,36 @@ export class StreamDecoder {
|
||||
this.totalRawBytes += data.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop fully-consumed raw chunks off the front of rawChunks, reclaiming their bytes. A chunk is
|
||||
* droppable only when its ENTIRE span lies at or before processedBytes (the decode cursor); a
|
||||
* chunk that straddles the cursor still has unconsumed tail bytes a later segment will read, so
|
||||
* the walk stops there. discardedBytes tracks the absolute start of rawChunks[0] so
|
||||
* extractAlignedData keeps reading the correct bytes after compaction. Splicing once at the end
|
||||
* (not per chunk) keeps this O(n) in the dropped count.
|
||||
*
|
||||
* This is the raw-side analogue of PlaybackScheduler.evictPlayedBuffers (the decoded side): both
|
||||
* keep their queue bounded to roughly the live window, so a long stream never balloons memory.
|
||||
*/
|
||||
private releaseConsumedChunks(): void {
|
||||
let dropCount = 0;
|
||||
let frontPos = this.discardedBytes;
|
||||
for (const chunk of this.rawChunks) {
|
||||
// Drop only when the whole chunk is behind the cursor (end <= processedBytes). A chunk
|
||||
// ending exactly at processedBytes has every byte consumed and is safe to drop.
|
||||
if (frontPos + chunk.length <= this.processedBytes) {
|
||||
frontPos += chunk.length;
|
||||
dropCount++;
|
||||
} else {
|
||||
break; // this chunk straddles the cursor (or is ahead) — stop.
|
||||
}
|
||||
}
|
||||
if (dropCount > 0) {
|
||||
this.rawChunks.splice(0, dropCount);
|
||||
this.discardedBytes = frontPos;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to decode the next segment of audio.
|
||||
*
|
||||
@@ -276,6 +317,9 @@ export class StreamDecoder {
|
||||
// Advance only after a successful decode so a thrown timeout/decode
|
||||
// failure does not silently drop the segment.
|
||||
this.processedBytes += alignedSize;
|
||||
// Release fully-consumed raw chunks now that the cursor has moved past them. This is the
|
||||
// memory bound: without it rawChunks retains the whole stream body (the OOM on long WAVs).
|
||||
this.releaseConsumedChunks();
|
||||
return { buffer, duration: buffer.duration };
|
||||
} catch (error) {
|
||||
// Re-throw typed errors so the outer drain loop in processChunk /
|
||||
@@ -339,7 +383,9 @@ export class StreamDecoder {
|
||||
let extractedOffset = 0;
|
||||
let remaining = size;
|
||||
let streamPosition = this.processedBytes;
|
||||
let currentPos = 0;
|
||||
// rawChunks[0] now begins at absolute stream byte `discardedBytes` (front-compaction has
|
||||
// dropped everything before it), so the walk starts there, not at 0.
|
||||
let currentPos = this.discardedBytes;
|
||||
|
||||
for (const chunk of this.rawChunks) {
|
||||
if (remaining <= 0) break;
|
||||
@@ -473,6 +519,7 @@ export class StreamDecoder {
|
||||
this.rawChunks = [];
|
||||
this.totalRawBytes = 0;
|
||||
this.processedBytes = 0;
|
||||
this.discardedBytes = 0;
|
||||
this.totalStreamLength = 0;
|
||||
this.streamComplete = false;
|
||||
this.headerBytesReceived = 0;
|
||||
@@ -501,6 +548,7 @@ export class StreamDecoder {
|
||||
this.rawChunks = [];
|
||||
this.totalRawBytes = 0;
|
||||
this.processedBytes = 0;
|
||||
this.discardedBytes = 0;
|
||||
this.streamComplete = false;
|
||||
this.headerBytesReceived = 0;
|
||||
this.headerSearchChunks = [];
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* decodePressure hysteresis tests — the Part-1 auto-throttle signal logic.
|
||||
*
|
||||
* These cover the four named behaviours that make the visualizer-throttle safe: it engages only on
|
||||
* SUSTAINED pressure, releases only after SUSTAINED recovery, never flaps on/off, and is a complete
|
||||
* no-op when decode is healthy. The clock is injected so every transition is asserted at an exact
|
||||
* timestamp — no real timers, fully deterministic.
|
||||
*
|
||||
* Run (no test runner configured; Node 22+ strips TS types natively — see OpusStreamDecoder.test.ts):
|
||||
* dotnet build DeepDrftPublic/DeepDrftPublic.csproj
|
||||
* cp DeepDrftPublic/Interop/audio/decodePressure.test.ts DeepDrftPublic/wwwroot/js/audio/
|
||||
* node DeepDrftPublic/wwwroot/js/audio/decodePressure.test.ts
|
||||
*
|
||||
* A thrown error / non-zero exit signals failure; "ALL <n> TESTS PASSED" signals success.
|
||||
*/
|
||||
|
||||
import {
|
||||
DecodePressureSignal,
|
||||
ENGAGE_EVENTS,
|
||||
ENGAGE_WINDOW_MS,
|
||||
RELEASE_QUIET_MS,
|
||||
MIN_ENGAGED_MS,
|
||||
} from './decodePressure.js';
|
||||
|
||||
// --- tiny inline harness (no dependencies) ---------------------------------------------------
|
||||
let passed = 0;
|
||||
const failures: string[] = [];
|
||||
function test(name: string, fn: () => void): void {
|
||||
try {
|
||||
fn();
|
||||
passed++;
|
||||
} catch (e) {
|
||||
failures.push(`FAIL: ${name}\n ${(e as Error).message}`);
|
||||
}
|
||||
}
|
||||
function assertTrue(actual: boolean, msg?: string): void {
|
||||
if (actual !== true) throw new Error(`${msg ?? 'assertTrue'}: expected true, got ${String(actual)}`);
|
||||
}
|
||||
function assertFalse(actual: boolean, msg?: string): void {
|
||||
if (actual !== false) throw new Error(`${msg ?? 'assertFalse'}: expected false, got ${String(actual)}`);
|
||||
}
|
||||
|
||||
/** A signal driven by a hand-advanced clock, so every transition is asserted at an exact time. */
|
||||
function makeSignal() {
|
||||
let now = 1000; // start at a non-zero base so "no prior stress" (-Infinity) is unambiguous
|
||||
const sig = new DecodePressureSignal(() => now);
|
||||
return {
|
||||
sig,
|
||||
at(ms: number) { now = ms; },
|
||||
advance(ms: number) { now += ms; },
|
||||
now() { return now; },
|
||||
};
|
||||
}
|
||||
|
||||
// --- no engage when healthy ------------------------------------------------------------------
|
||||
|
||||
test('healthy stream never engages (no reports at all)', () => {
|
||||
const { sig, advance } = makeSignal();
|
||||
for (let i = 0; i < 10; i++) {
|
||||
advance(1000);
|
||||
assertFalse(sig.isUnderPressure(), 'healthy must never be under pressure');
|
||||
}
|
||||
});
|
||||
|
||||
test('a single transient stress does not engage', () => {
|
||||
const { sig, advance } = makeSignal();
|
||||
sig.report();
|
||||
assertFalse(sig.isUnderPressure(), 'one event is not sustained');
|
||||
advance(500);
|
||||
assertFalse(sig.isUnderPressure(), 'still not sustained');
|
||||
});
|
||||
|
||||
test('fewer than ENGAGE_EVENTS within the window does not engage', () => {
|
||||
const { sig, advance } = makeSignal();
|
||||
for (let i = 0; i < ENGAGE_EVENTS - 1; i++) {
|
||||
sig.report();
|
||||
advance(10);
|
||||
}
|
||||
assertFalse(sig.isUnderPressure(), 'one short of the threshold must not engage');
|
||||
});
|
||||
|
||||
test('stress spread wider than the window never accumulates enough to engage', () => {
|
||||
const { sig, advance } = makeSignal();
|
||||
// One report per full window: the prune drops each before the next, so the live count never
|
||||
// reaches ENGAGE_EVENTS even after many reports.
|
||||
for (let i = 0; i < ENGAGE_EVENTS * 3; i++) {
|
||||
sig.report();
|
||||
assertFalse(sig.isUnderPressure(), 'spread-out stress is not sustained');
|
||||
advance(ENGAGE_WINDOW_MS);
|
||||
}
|
||||
});
|
||||
|
||||
// --- engages on sustained pressure -----------------------------------------------------------
|
||||
|
||||
test('ENGAGE_EVENTS within the window engages', () => {
|
||||
const { sig, advance } = makeSignal();
|
||||
for (let i = 0; i < ENGAGE_EVENTS; i++) {
|
||||
sig.report();
|
||||
advance(10); // all comfortably inside ENGAGE_WINDOW_MS
|
||||
}
|
||||
assertTrue(sig.isUnderPressure(), 'sustained pressure must engage');
|
||||
});
|
||||
|
||||
// --- releases after recovery -----------------------------------------------------------------
|
||||
|
||||
test('releases after sustained quiet past the min dwell', () => {
|
||||
const { sig, advance } = makeSignal();
|
||||
for (let i = 0; i < ENGAGE_EVENTS; i++) { sig.report(); advance(10); }
|
||||
assertTrue(sig.isUnderPressure(), 'engaged');
|
||||
|
||||
// Quiet long enough to satisfy BOTH the min engaged dwell and the release-quiet window.
|
||||
advance(Math.max(MIN_ENGAGED_MS, RELEASE_QUIET_MS) + 1);
|
||||
assertFalse(sig.isUnderPressure(), 'sustained recovery must release');
|
||||
});
|
||||
|
||||
test('re-engages after a release when a fresh burst arrives', () => {
|
||||
const { sig, advance } = makeSignal();
|
||||
for (let i = 0; i < ENGAGE_EVENTS; i++) { sig.report(); advance(10); }
|
||||
assertTrue(sig.isUnderPressure(), 'engaged first time');
|
||||
advance(Math.max(MIN_ENGAGED_MS, RELEASE_QUIET_MS) + 1);
|
||||
assertFalse(sig.isUnderPressure(), 'released');
|
||||
|
||||
for (let i = 0; i < ENGAGE_EVENTS; i++) { sig.report(); advance(10); }
|
||||
assertTrue(sig.isUnderPressure(), 'a fresh sustained burst re-engages');
|
||||
});
|
||||
|
||||
// --- no flap ---------------------------------------------------------------------------------
|
||||
|
||||
test('stays engaged during a brief quiet shorter than the release window', () => {
|
||||
const { sig, advance } = makeSignal();
|
||||
for (let i = 0; i < ENGAGE_EVENTS; i++) { sig.report(); advance(10); }
|
||||
assertTrue(sig.isUnderPressure(), 'engaged');
|
||||
|
||||
// A gap shorter than RELEASE_QUIET_MS must NOT release — that is the anti-flap guarantee.
|
||||
advance(RELEASE_QUIET_MS - 100);
|
||||
assertTrue(sig.isUnderPressure(), 'a brief quiet must not drop the throttle');
|
||||
});
|
||||
|
||||
test('continued stress holds the throttle engaged indefinitely', () => {
|
||||
const { sig, advance } = makeSignal();
|
||||
for (let i = 0; i < ENGAGE_EVENTS; i++) { sig.report(); advance(10); }
|
||||
assertTrue(sig.isUnderPressure(), 'engaged');
|
||||
|
||||
// Keep reporting at a cadence under the release window; it must never release.
|
||||
for (let i = 0; i < 20; i++) {
|
||||
advance(RELEASE_QUIET_MS - 100);
|
||||
sig.report();
|
||||
assertTrue(sig.isUnderPressure(), 'ongoing stress keeps it engaged');
|
||||
}
|
||||
});
|
||||
|
||||
// --- report ----------------------------------------------------------------------------------
|
||||
if (failures.length > 0) {
|
||||
console.error(failures.join('\n'));
|
||||
throw new Error(`${failures.length} test(s) failed, ${passed} passed`);
|
||||
}
|
||||
console.log(`ALL ${passed} TESTS PASSED`);
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Shared decode-pressure signal — the seam that lets the audio decode pipeline protect itself
|
||||
* from the WebGL visualizer under CPU contention.
|
||||
*
|
||||
* THE PROBLEM (browser-confirmed): with hardware acceleration OFF the WaveformVisualizer's WebGL2
|
||||
* lava-lamp software-renders on the main thread. WebCodecs Opus decode also runs on the main thread,
|
||||
* so a 60 fps software render starves decode → it falls behind realtime → playback underruns. Turning
|
||||
* the visualizer off makes decode keep up perfectly. With HW accel ON the render is on the GPU and
|
||||
* there is no contention; WAV/lossless decodes synchronously and never pressures decode either.
|
||||
*
|
||||
* THE SEAM: this module is a singleton shared by two otherwise-independent browser module graphs —
|
||||
* the audio pipeline (`js/audio/*`, the PRODUCER) and the visualizer (`js/visualizer/*`, the
|
||||
* CONSUMER) — because an ES module is instantiated once per URL. The producer reports decode stress;
|
||||
* the consumer reads {@link DecodePressureSignal.isUnderPressure} each frame and throttles its render
|
||||
* cadence so the main thread yields time back to decode. No routing through C#, no constructor growth.
|
||||
*
|
||||
* HYSTERESIS (no flap): the signal engages only on SUSTAINED stress (≥ ENGAGE_EVENTS reports within
|
||||
* ENGAGE_WINDOW_MS) and releases only after SUSTAINED recovery (no stress for RELEASE_QUIET_MS, and
|
||||
* never before a MIN_ENGAGED_MS dwell). A lone startup-ramp blip never engages; once engaged the
|
||||
* throttle cannot toggle off frame-to-frame.
|
||||
*
|
||||
* HEALTHY-CASE NO-OP: when decode keeps up nothing ever calls report(), so {@link isUnderPressure}
|
||||
* stays false forever and the consumer runs at full quality. This protection only activates under
|
||||
* genuine, sustained decode starvation.
|
||||
*/
|
||||
|
||||
/** Stress reports required within {@link ENGAGE_WINDOW_MS} to engage the throttle. */
|
||||
export const ENGAGE_EVENTS = 5;
|
||||
/** Sliding window (ms) over which {@link ENGAGE_EVENTS} stress reports count toward engaging. */
|
||||
export const ENGAGE_WINDOW_MS = 2500;
|
||||
/** Stress-free dwell (ms) required before the throttle releases. */
|
||||
export const RELEASE_QUIET_MS = 1500;
|
||||
/** Minimum engaged dwell (ms) before release is even considered — the anti-flap floor. */
|
||||
export const MIN_ENGAGED_MS = 1000;
|
||||
|
||||
type Clock = () => number;
|
||||
|
||||
export class DecodePressureSignal {
|
||||
// Timestamps of recent stress reports, pruned to the engage window. Length ≥ ENGAGE_EVENTS is the
|
||||
// "sustained pressure" condition. Bounded by the window, so this never grows unbounded.
|
||||
private stressTimestamps: number[] = [];
|
||||
private lastStressMs = Number.NEGATIVE_INFINITY;
|
||||
private engaged = false;
|
||||
private engagedAtMs = 0;
|
||||
|
||||
// Clock injectable purely for deterministic unit tests; production uses performance.now().
|
||||
constructor(private readonly now: Clock = () => performance.now()) {}
|
||||
|
||||
/**
|
||||
* Report one unit of decode stress — decode falling behind realtime. Called by the producer at
|
||||
* each genuine lag event: the WebCodecs decode queue staying non-empty past its yield ceiling
|
||||
* (OpusStreamDecoder) and the scheduler parking on a mid-stream underrun (PlaybackScheduler).
|
||||
*/
|
||||
report(): void {
|
||||
const t = this.now();
|
||||
this.lastStressMs = t;
|
||||
this.stressTimestamps.push(t);
|
||||
this.prune(t);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether decode is under sustained pressure right now. Pure read for the caller, but it ADVANCES
|
||||
* the hysteresis latch (engage on sustained stress, release on sustained quiet past the min dwell)
|
||||
* — so the transition is evaluated lazily on the clock, identical whether called once or per frame.
|
||||
*/
|
||||
isUnderPressure(): boolean {
|
||||
const t = this.now();
|
||||
this.prune(t);
|
||||
|
||||
if (this.engaged) {
|
||||
const engagedFor = t - this.engagedAtMs;
|
||||
const quietFor = t - this.lastStressMs;
|
||||
if (engagedFor >= MIN_ENGAGED_MS && quietFor >= RELEASE_QUIET_MS) {
|
||||
this.engaged = false;
|
||||
}
|
||||
} else if (this.stressTimestamps.length >= ENGAGE_EVENTS) {
|
||||
this.engaged = true;
|
||||
this.engagedAtMs = t;
|
||||
}
|
||||
return this.engaged;
|
||||
}
|
||||
|
||||
/** Drop stress timestamps older than the engage window so the count reflects only the live window. */
|
||||
private prune(t: number): void {
|
||||
const cutoff = t - ENGAGE_WINDOW_MS;
|
||||
while (this.stressTimestamps.length > 0 && this.stressTimestamps[0] < cutoff) {
|
||||
this.stressTimestamps.shift();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The process-wide signal both the audio pipeline and the visualizer share. */
|
||||
export const decodePressure = new DecodePressureSignal();
|
||||
@@ -3,6 +3,7 @@
|
||||
*/
|
||||
|
||||
import { AudioPlayer, AudioResult, StreamingResult, AudioState } from './AudioPlayer.js';
|
||||
import { canDecodeOggOpus } from './OpusCapability.js';
|
||||
|
||||
// Player instances by ID
|
||||
const audioPlayers = new Map<string, AudioPlayer>();
|
||||
@@ -31,12 +32,26 @@ const DeepDrftAudio = {
|
||||
}
|
||||
},
|
||||
|
||||
initializeStreaming: (playerId: string, totalStreamLength: number, contentType: string): AudioResult => {
|
||||
initializeStreaming: async (playerId: string, totalStreamLength: number, contentType: string): Promise<AudioResult> => {
|
||||
const player = audioPlayers.get(playerId);
|
||||
if (!player) return { success: false, error: 'Player not found' };
|
||||
return player.initializeStreaming(totalStreamLength, contentType);
|
||||
},
|
||||
|
||||
// Opus injection seam (wave 18.4). Wave 18.5 fetches the per-track sidecar (setup header +
|
||||
// seek index) over HTTP and hands the raw bytes here BEFORE initializeStreaming on an Opus
|
||||
// stream. This module never fetches the sidecar — it only parses + stores it on the player.
|
||||
setOpusSidecar: (playerId: string, sidecarBytes: Uint8Array): AudioResult => {
|
||||
const player = audioPlayers.get(playerId);
|
||||
if (!player) return { success: false, error: 'Player not found' };
|
||||
return player.setOpusSidecar(sidecarBytes);
|
||||
},
|
||||
|
||||
// Capability seam. Resolves whether this browser can stream-decode Ogg Opus via WebCodecs
|
||||
// (AudioDecoder + codec:'opus'; Safari < 16.4 / older Firefox cannot). The player consumes this
|
||||
// to choose lossless when unsupported; this module only reports the capability.
|
||||
canDecodeOggOpus: (): Promise<boolean> => canDecodeOggOpus(),
|
||||
|
||||
processStreamingChunk: async (playerId: string, chunk: Uint8Array): Promise<StreamingResult> => {
|
||||
const player = audioPlayers.get(playerId);
|
||||
if (!player) return { success: false, error: 'Player not found' };
|
||||
@@ -102,12 +117,39 @@ const DeepDrftAudio = {
|
||||
return player?.calculateByteOffset(positionSeconds) ?? 0;
|
||||
},
|
||||
|
||||
// "Load at timestamp" seam (Phase 18 wave 18.6 format switch). Resolve the file-absolute byte offset
|
||||
// to begin a stream at `position` with no playback/buffer state — the C# load-from-position path calls
|
||||
// this after initializeStreaming (Opus: sidecar resolves immediately; WAV: after a header probe) and
|
||||
// then streams from the returned offset via the seek/refill loop. seekBeyondBuffer:true + byteOffset.
|
||||
resolveStreamOffset: (playerId: string, position: number): AudioResult => {
|
||||
const player = audioPlayers.get(playerId);
|
||||
if (!player) return { success: false, error: 'Player not found' };
|
||||
return player.resolveStreamOffset(position);
|
||||
},
|
||||
|
||||
// Phase 21.2a back-pressure poll: the C# read loop calls this WHILE throttled to learn when
|
||||
// the scheduler has drained below low-water and reading may resume. A missing player reads as
|
||||
// "not paused" so a torn-down player never wedges a loop that is already exiting.
|
||||
isProductionPaused: (playerId: string): boolean => {
|
||||
const player = audioPlayers.get(playerId);
|
||||
return player?.isProductionPaused() ?? false;
|
||||
},
|
||||
|
||||
reinitializeFromOffset: (playerId: string, totalStreamLength: number, seekPosition: number): AudioResult => {
|
||||
const player = audioPlayers.get(playerId);
|
||||
if (!player) return { success: false, error: 'Player not found' };
|
||||
return player.reinitializeFromOffset(totalStreamLength, seekPosition);
|
||||
},
|
||||
|
||||
// Phase 21.3 / AC6: recover into a clean paused-but-loaded state after a window-miss refill
|
||||
// (seek-back past the retained tail) failed its Range fetch or reinit. Prevents the starved
|
||||
// scheduler from firing a silent false end; leaves the track loaded so a retry is possible.
|
||||
recoverFromFailedRefill: (playerId: string, seekPosition: number): AudioResult => {
|
||||
const player = audioPlayers.get(playerId);
|
||||
if (!player) return { success: false, error: 'Player not found' };
|
||||
return player.recoverFromFailedRefill(seekPosition);
|
||||
},
|
||||
|
||||
setVolume: (playerId: string, volume: number): AudioResult => {
|
||||
const player = audioPlayers.get(playerId);
|
||||
if (!player) return { success: false, error: 'Player not found' };
|
||||
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Minimal ambient WebCodecs declarations.
|
||||
*
|
||||
* TypeScript 5.9's bundled lib.dom.d.ts does NOT yet ship the WebCodecs audio types
|
||||
* (`AudioDecoder`, `EncodedAudioChunk`, `AudioData`, `AudioDecoderConfig`), and this repo has no
|
||||
* package.json / node_modules to pull in `@types/dom-webcodecs`. Rather than add a dependency
|
||||
* toolchain for one feature, this declares exactly the slice of the WebCodecs surface the Opus
|
||||
* streaming decoder uses — nothing more. The shapes follow the W3C WebCodecs spec.
|
||||
*
|
||||
* These are runtime-optional: `AudioDecoder` is absent on Safari < 16.4 and older Firefox, so every
|
||||
* use site guards on `typeof AudioDecoder !== 'undefined'` before touching it (the capability gate).
|
||||
*/
|
||||
|
||||
interface AudioDecoderConfig {
|
||||
codec: string;
|
||||
sampleRate: number;
|
||||
numberOfChannels: number;
|
||||
/** Codec-specific setup bytes. For Opus this is the OpusHead identification header. */
|
||||
description?: BufferSource;
|
||||
}
|
||||
|
||||
interface AudioDecoderSupport {
|
||||
supported: boolean;
|
||||
config: AudioDecoderConfig;
|
||||
}
|
||||
|
||||
type AudioSampleFormat = 'u8' | 's16' | 's24' | 's32' | 'f32' | 'u8-planar' | 's16-planar' | 's24-planar' | 's32-planar' | 'f32-planar';
|
||||
|
||||
interface AudioDataCopyToOptions {
|
||||
planeIndex: number;
|
||||
frameOffset?: number;
|
||||
frameCount?: number;
|
||||
format?: AudioSampleFormat;
|
||||
}
|
||||
|
||||
interface AudioData {
|
||||
readonly format: AudioSampleFormat | null;
|
||||
readonly sampleRate: number;
|
||||
readonly numberOfFrames: number;
|
||||
readonly numberOfChannels: number;
|
||||
readonly duration: number;
|
||||
/** Presentation timestamp in microseconds. */
|
||||
readonly timestamp: number;
|
||||
allocationSize(options: AudioDataCopyToOptions): number;
|
||||
copyTo(destination: BufferSource, options: AudioDataCopyToOptions): void;
|
||||
close(): void;
|
||||
}
|
||||
|
||||
interface EncodedAudioChunkInit {
|
||||
type: 'key' | 'delta';
|
||||
/** Presentation timestamp in microseconds. */
|
||||
timestamp: number;
|
||||
duration?: number;
|
||||
data: BufferSource;
|
||||
}
|
||||
|
||||
declare class EncodedAudioChunk {
|
||||
constructor(init: EncodedAudioChunkInit);
|
||||
readonly type: 'key' | 'delta';
|
||||
readonly timestamp: number;
|
||||
readonly duration: number | null;
|
||||
readonly byteLength: number;
|
||||
}
|
||||
|
||||
interface AudioDecoderInit {
|
||||
output: (data: AudioData) => void;
|
||||
error: (error: DOMException) => void;
|
||||
}
|
||||
|
||||
type CodecState = 'unconfigured' | 'configured' | 'closed';
|
||||
|
||||
declare class AudioDecoder {
|
||||
constructor(init: AudioDecoderInit);
|
||||
readonly state: CodecState;
|
||||
readonly decodeQueueSize: number;
|
||||
configure(config: AudioDecoderConfig): void;
|
||||
decode(chunk: EncodedAudioChunk): void;
|
||||
flush(): Promise<void>;
|
||||
reset(): void;
|
||||
close(): void;
|
||||
static isConfigSupported(config: AudioDecoderConfig): Promise<AudioDecoderSupport>;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Listener-settings interop (Phase 18 wave 18.6). A safe, eval-free cookie helper for persisting
|
||||
* public-site preferences (streaming quality, and any future setting added under PublicSiteSettings).
|
||||
* The 365-day durable-truth seam dark mode uses — same mechanism, no eval.
|
||||
*
|
||||
* Exposed on window.DeepDrftSettings; imported once in App.razor.
|
||||
*/
|
||||
|
||||
const DeepDrftSettings = {
|
||||
/**
|
||||
* Write a cookie with the given name, value, and lifetime. Equivalent to the browser's
|
||||
* document.cookie assignment but without building JS via string interpolation or eval.
|
||||
* Path is always "/"; SameSite is always "Lax" — matches the dark-mode cookie semantics.
|
||||
*/
|
||||
setCookie: (name: string, value: string, days: number): void => {
|
||||
const expires = new Date();
|
||||
expires.setTime(expires.getTime() + days * 24 * 60 * 60 * 1000);
|
||||
document.cookie =
|
||||
`${encodeURIComponent(name)}=${encodeURIComponent(value)}` +
|
||||
`; expires=${expires.toUTCString()}` +
|
||||
`; path=/; SameSite=Lax`;
|
||||
},
|
||||
};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
DeepDrftSettings: typeof DeepDrftSettings;
|
||||
}
|
||||
}
|
||||
|
||||
window.DeepDrftSettings = DeepDrftSettings;
|
||||
|
||||
export { DeepDrftSettings };
|
||||
@@ -44,6 +44,13 @@
|
||||
* position while !isPlaying). The loop stops only on tab-hidden (visibilitychange) and dispose.
|
||||
*/
|
||||
|
||||
import { decodePressure } from '../audio/decodePressure.js';
|
||||
|
||||
// Re-exported so the Blazor bridge (WaveformVisualizer.razor.cs) reaches the HW-accel probe through
|
||||
// the same module reference it already imports for create() — one JS import surface, no second handle.
|
||||
// The probe itself (and its unit-tested pure classifier) lives in hwAccel.ts.
|
||||
export { detectHardwareAcceleration } from './hwAccel.js';
|
||||
|
||||
// ── Tuning anchors (see spec §B). These are the load-bearing constants. ──────────
|
||||
|
||||
/**
|
||||
@@ -148,6 +155,16 @@ const RIBBON_HALF_WIDTH_FRAC = 0.92;
|
||||
*/
|
||||
const MAX_DPR = 2;
|
||||
|
||||
/**
|
||||
* Minimum milliseconds between drawn frames WHILE decode is under sustained pressure (Part 1 —
|
||||
* auto-protect audio). 1000/15 ≈ 66.7 ms caps the loop at ~15 fps, cutting the main-thread WebGL
|
||||
* software-render + physics cost by ~75% so the synchronous WebCodecs Opus decode (which shares the
|
||||
* main thread when HW accel is off) gets the time it needs to keep up. The decodePressure signal is
|
||||
* false in the common case (HW accel on, or lossless), so this cap never applies and the loop draws
|
||||
* every frame at full quality. Tunable; the exact fps that clears starvation is browser-confirmed.
|
||||
*/
|
||||
const PRESSURE_THROTTLE_FRAME_MS = 1000 / 15;
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════════════
|
||||
// R2 — the wax-blob lava physics (CPU step + uniform upload). The lava is now a real
|
||||
// Lagrangian particle system integrated each frame on the JS side and rendered as
|
||||
@@ -1679,6 +1696,10 @@ export function create(canvas: HTMLCanvasElement): WaveformVisualizerHandle {
|
||||
let rafId: number | null = null;
|
||||
let disposed = false;
|
||||
const startTimeMs = performance.now();
|
||||
// Wall-clock of the last DRAWN continuous-loop frame, for the decode-pressure throttle (Part 1).
|
||||
// While decodePressure.isUnderPressure() the loop draws at most once per PRESSURE_THROTTLE_FRAME_MS
|
||||
// so the main thread yields time back to a starved decode; unthrottled it draws every frame.
|
||||
let lastDrawMs = performance.now();
|
||||
// Wall-clock anchor for the physics dt (separate from the playhead decay clock).
|
||||
let lastPhysicsMs = performance.now();
|
||||
|
||||
@@ -1923,9 +1944,30 @@ export function create(canvas: HTMLCanvasElement): WaveformVisualizerHandle {
|
||||
rafId = null;
|
||||
return;
|
||||
}
|
||||
|
||||
// Auto-protect audio under decode pressure (Part 1). When the WebCodecs Opus decode pipeline
|
||||
// reports SUSTAINED lag (decodePressure.isUnderPressure()), throttle the draw cadence to
|
||||
// ~PRESSURE_THROTTLE_FRAME_MS so this loop's main-thread GL + physics cost yields time back to
|
||||
// decode; we still reschedule every frame so full cadence resumes the instant decode recovers.
|
||||
// A no-op when decode is healthy — isUnderPressure() stays false, the gate is always open, and
|
||||
// every frame draws exactly as before. Skipping a draw also skips the physics step (it runs
|
||||
// inside draw()), and its dt is clamped to PHYSICS_MAX_DT, so a throttled gap never lurches the
|
||||
// lava. redrawOnce() (idle/control-tweak stills) is intentionally NOT throttled — those are rare
|
||||
// one-shots, not the continuous loop.
|
||||
const nowMs = performance.now();
|
||||
if (!decodePressure.isUnderPressure() || nowMs - lastDrawMs >= PRESSURE_THROTTLE_FRAME_MS) {
|
||||
lastDrawMs = nowMs;
|
||||
drawFrame();
|
||||
}
|
||||
|
||||
rafId = requestAnimationFrame(frame);
|
||||
}
|
||||
|
||||
/** One drawn continuous-loop frame: the GL draw plus the gated FPS/lava diagnostic tally. */
|
||||
function drawFrame(): void {
|
||||
draw();
|
||||
|
||||
// FPS tally: count this callback, and once per elapsed second emit the rate.
|
||||
// FPS tally: count this drawn frame, and once per elapsed second emit the rate.
|
||||
// performance.now() is cheap (no GPU stall, unlike gl.getError); the gated log
|
||||
// fires at most once/sec, so this adds no meaningful per-frame cost.
|
||||
if (DEBUG) {
|
||||
@@ -1968,10 +2010,6 @@ export function create(canvas: HTMLCanvasElement): WaveformVisualizerHandle {
|
||||
fpsWindowStartMs = nowMs;
|
||||
}
|
||||
}
|
||||
|
||||
// Reschedule unconditionally — the loop runs continuously now (lava reframe Part C); it is
|
||||
// stopped only by dispose() or the tab going hidden, never by audio pausing.
|
||||
rafId = requestAnimationFrame(frame);
|
||||
}
|
||||
|
||||
// ── Tab-visibility gating (lava reframe Part C power-saving). ────────────────────
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* hwAccel classifier tests — the pure software-renderer signature matching and the
|
||||
* uncertainty/failure policy that drives the lava default-off decision.
|
||||
*
|
||||
* These cover the code-PROVABLE half of the feature: given a renderer string (or its absence, or a
|
||||
* total WebGL failure), is the browser classified "accelerated" (lava on) or not (lava off)? The
|
||||
* impure probe (detectHardwareAcceleration → real getContext) is browser-confirmed, not unit-tested.
|
||||
*
|
||||
* Same harness convention as decodePressure.test.ts — no test runner in this repo; Node 22+ strips TS
|
||||
* types natively. Run a copy from the COMPILED output so the `./hwAccel.js` import specifier resolves:
|
||||
*
|
||||
* dotnet build DeepDrftPublic/DeepDrftPublic.csproj
|
||||
* cp DeepDrftPublic/Interop/visualizer/hwAccel.test.ts DeepDrftPublic/wwwroot/js/visualizer/
|
||||
* node DeepDrftPublic/wwwroot/js/visualizer/hwAccel.test.ts
|
||||
*
|
||||
* A thrown error / non-zero exit signals failure; "ALL <n> TESTS PASSED" signals success.
|
||||
* Excluded from the production tsc build via tsconfig `exclude: Interop/ ** /*.test.ts`.
|
||||
*/
|
||||
|
||||
import {
|
||||
classifyHardwareAcceleration,
|
||||
isSoftwareRenderer,
|
||||
SOFTWARE_RENDERER_SIGNATURES,
|
||||
} from './hwAccel.js';
|
||||
|
||||
// --- tiny inline harness (no dependencies) ---------------------------------------------------
|
||||
let passed = 0;
|
||||
const failures: string[] = [];
|
||||
function test(name: string, fn: () => void): void {
|
||||
try {
|
||||
fn();
|
||||
passed++;
|
||||
} catch (e) {
|
||||
failures.push(`FAIL: ${name}\n ${(e as Error).message}`);
|
||||
}
|
||||
}
|
||||
function assertTrue(actual: boolean, msg?: string): void {
|
||||
if (actual !== true) throw new Error(`${msg ?? 'assertTrue'}: expected true, got ${String(actual)}`);
|
||||
}
|
||||
function assertFalse(actual: boolean, msg?: string): void {
|
||||
if (actual !== false) throw new Error(`${msg ?? 'assertFalse'}: expected false, got ${String(actual)}`);
|
||||
}
|
||||
|
||||
// --- isSoftwareRenderer: positive matches -----------------------------------------------------
|
||||
|
||||
// Real-world software renderer strings, as reported by UNMASKED_RENDERER_WEBGL on accel-off configs.
|
||||
const SOFTWARE_STRINGS = [
|
||||
'Google SwiftShader',
|
||||
'ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (LLVM 10.0.0) (0x0000C0DE)), SwiftShader driver)',
|
||||
'llvmpipe (LLVM 12.0.0, 256 bits)',
|
||||
'Gallium 0.4 on llvmpipe (LLVM 17.0.6, 256 bits)',
|
||||
'softpipe',
|
||||
'Microsoft Basic Render Driver',
|
||||
'Mesa OffScreen',
|
||||
'Software Rasterizer',
|
||||
];
|
||||
|
||||
for (const s of SOFTWARE_STRINGS) {
|
||||
test(`isSoftwareRenderer matches software string: "${s}"`, () => {
|
||||
assertTrue(isSoftwareRenderer(s), `"${s}" should match a software signature`);
|
||||
});
|
||||
}
|
||||
|
||||
// --- isSoftwareRenderer: hardware (GPU) strings must NOT match ---------------------------------
|
||||
|
||||
const HARDWARE_STRINGS = [
|
||||
'ANGLE (NVIDIA, NVIDIA GeForce RTX 3080 Direct3D11 vs_5_0 ps_5_0, D3D11)',
|
||||
'ANGLE (Intel, Intel(R) Iris(R) Xe Graphics Direct3D11 vs_5_0 ps_5_0, D3D11)',
|
||||
'ANGLE (AMD, AMD Radeon RX 6800 XT Direct3D11 vs_5_0 ps_5_0, D3D11)',
|
||||
'Apple GPU',
|
||||
'Mali-G78',
|
||||
'Adreno (TM) 650',
|
||||
];
|
||||
|
||||
for (const s of HARDWARE_STRINGS) {
|
||||
test(`isSoftwareRenderer rejects hardware string: "${s}"`, () => {
|
||||
assertFalse(isSoftwareRenderer(s), `"${s}" should NOT match any software signature`);
|
||||
});
|
||||
}
|
||||
|
||||
// --- case-insensitivity -----------------------------------------------------------------------
|
||||
|
||||
test('isSoftwareRenderer is case-insensitive', () => {
|
||||
assertTrue(isSoftwareRenderer('SWIFTSHADER'), 'upper-case must still match');
|
||||
assertTrue(isSoftwareRenderer('LlVmPiPe'), 'mixed-case must still match');
|
||||
});
|
||||
|
||||
test('every declared signature self-matches (sanity on the list)', () => {
|
||||
for (const sig of SOFTWARE_RENDERER_SIGNATURES) {
|
||||
assertTrue(isSoftwareRenderer(sig), `signature "${sig}" must match itself`);
|
||||
}
|
||||
});
|
||||
|
||||
// --- classifyHardwareAcceleration: the full policy --------------------------------------------
|
||||
|
||||
test('positive software match → NOT accelerated (lava off)', () => {
|
||||
assertFalse(
|
||||
classifyHardwareAcceleration(true, 'Google SwiftShader'),
|
||||
'a working context with a software renderer must classify as not accelerated',
|
||||
);
|
||||
});
|
||||
|
||||
test('real GPU renderer → accelerated (lava on)', () => {
|
||||
assertTrue(
|
||||
classifyHardwareAcceleration(true, 'ANGLE (NVIDIA GeForce RTX 3080, D3D11)'),
|
||||
'a working context with a GPU renderer must classify as accelerated',
|
||||
);
|
||||
});
|
||||
|
||||
// Uncertainty / default-on case: context works but the renderer string is masked or absent.
|
||||
test('masked renderer (null) with a working context → accelerated (default on)', () => {
|
||||
assertTrue(
|
||||
classifyHardwareAcceleration(true, null),
|
||||
'an unknown renderer must favor the HW-accel majority',
|
||||
);
|
||||
});
|
||||
|
||||
test('empty/whitespace renderer with a working context → accelerated (default on)', () => {
|
||||
assertTrue(classifyHardwareAcceleration(true, ''), 'empty string is unknown, not software');
|
||||
assertTrue(classifyHardwareAcceleration(true, ' '), 'whitespace is unknown, not software');
|
||||
});
|
||||
|
||||
// Total-WebGL-failure case: no context at all → lava can't run → not accelerated.
|
||||
test('no WebGL context at all → NOT accelerated (lava off), regardless of renderer arg', () => {
|
||||
assertFalse(classifyHardwareAcceleration(false, null), 'no context → lava off');
|
||||
assertFalse(
|
||||
classifyHardwareAcceleration(false, 'ANGLE (NVIDIA GeForce RTX 3080, D3D11)'),
|
||||
'no context dominates even a GPU-looking string',
|
||||
);
|
||||
});
|
||||
|
||||
// --- report ----------------------------------------------------------------------------------
|
||||
if (failures.length > 0) {
|
||||
console.error(failures.join('\n'));
|
||||
throw new Error(`${failures.length} test(s) failed, ${passed} passed`);
|
||||
}
|
||||
console.log(`ALL ${passed} TESTS PASSED`);
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* Hardware-acceleration probe for the lava-lamp visualizer.
|
||||
*
|
||||
* WHY: with hardware acceleration OFF the WebGL2 lava field software-renders on the main thread and
|
||||
* starves WebCodecs Opus decode → playback struggles. The decodePressure auto-throttle alone is not
|
||||
* enough — even throttled, software-rendered lava is too expensive. So when there is no HW-accel
|
||||
* support we default the LAVA subsystem OFF (the expensive part) while keeping the WAVEFORM ON. With
|
||||
* HW accel present (the common case) nothing changes — lava defaults on, full quality.
|
||||
*
|
||||
* The probe creates a throwaway WebGL context, reads the unmasked renderer string via
|
||||
* WEBGL_debug_renderer_info, and matches it against known software-renderer signatures.
|
||||
*
|
||||
* UNCERTAINTY POLICY (favor the HW-accel majority): lava is disabled ONLY on a positive
|
||||
* software-renderer match or a total failure to obtain any WebGL context (lava can't run at all). If
|
||||
* the renderer string is unavailable/masked (some privacy configs strip
|
||||
* WEBGL_debug_renderer_info) but a context otherwise succeeds, we default to "accelerated" — we do not
|
||||
* disable lava on absence of evidence, only on positive evidence of software rendering.
|
||||
*
|
||||
* LIMIT (browser-confirmed, not code-provable): UNMASKED_RENDERER_WEBGL can be masked, and a given
|
||||
* browser running with HW accel OFF may report a string none of these signatures match — in which
|
||||
* case this probe reports "accelerated" and lava stays on. The signature list below is the only
|
||||
* tunable; if a real software-renderer string slips through, add it here.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Case-insensitive substrings that positively identify a software (non-GPU) WebGL renderer. Matching
|
||||
* any one of these means the browser is software-rendering WebGL → lava off. Order is irrelevant.
|
||||
*/
|
||||
export const SOFTWARE_RENDERER_SIGNATURES: readonly string[] = [
|
||||
'swiftshader', // Chrome's software GL fallback (also "Google SwiftShader")
|
||||
'llvmpipe', // Mesa software rasterizer (Linux)
|
||||
'softpipe', // Mesa software rasterizer (older/gallium)
|
||||
'microsoft basic render', // Windows "Microsoft Basic Render Driver"
|
||||
'mesa offscreen', // Mesa headless/offscreen software path
|
||||
'software', // generic catch-all ("... Software ...")
|
||||
];
|
||||
|
||||
/**
|
||||
* Pure predicate: does this renderer string positively identify a software renderer? Case-insensitive
|
||||
* substring match against {@link SOFTWARE_RENDERER_SIGNATURES}. Empty/whitespace is NOT a match — a
|
||||
* masked/absent string is "unknown", not "software" (see {@link classifyHardwareAcceleration}).
|
||||
*/
|
||||
export function isSoftwareRenderer(renderer: string): boolean {
|
||||
const r = renderer.toLowerCase();
|
||||
return SOFTWARE_RENDERER_SIGNATURES.some((sig) => r.includes(sig));
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure classifier mapping probe observations to "is hardware accelerated?". Split out from the
|
||||
* DOM-touching {@link detectHardwareAcceleration} so the policy is unit-testable without a browser.
|
||||
*
|
||||
* • no WebGL context at all → false (lava can't run — total failure)
|
||||
* • renderer masked/absent → true (favor the HW-accel majority — absence of evidence)
|
||||
* • positive software match → false (positive evidence of software rendering)
|
||||
* • otherwise → true (a real GPU renderer string)
|
||||
*/
|
||||
export function classifyHardwareAcceleration(hasWebglContext: boolean, renderer: string | null): boolean {
|
||||
if (!hasWebglContext) return false;
|
||||
if (renderer === null || renderer.trim() === '') return true;
|
||||
return !isSoftwareRenderer(renderer);
|
||||
}
|
||||
|
||||
/** Read the unmasked renderer string, or null when the debug extension is unavailable/masked. */
|
||||
function readUnmaskedRenderer(gl: WebGLRenderingContext | WebGL2RenderingContext): string | null {
|
||||
const ext = gl.getExtension('WEBGL_debug_renderer_info');
|
||||
if (!ext) return null;
|
||||
const renderer = gl.getParameter(ext.UNMASKED_RENDERER_WEBGL);
|
||||
return typeof renderer === 'string' ? renderer : null;
|
||||
}
|
||||
|
||||
// Probe once per page — the renderer is a constant for the lifetime of the document. Cached so the
|
||||
// scoped C# control-state's one-time default-set never pays for a second throwaway context.
|
||||
let cached: boolean | undefined;
|
||||
|
||||
/**
|
||||
* Probe the browser for WebGL hardware acceleration. Returns true when the lava subsystem should
|
||||
* default ON (HW accel present or renderer unknown), false when it should default OFF (positive
|
||||
* software-renderer match or no WebGL context at all). Cached after the first call; never throws.
|
||||
*/
|
||||
export function detectHardwareAcceleration(): boolean {
|
||||
if (cached !== undefined) return cached;
|
||||
cached = probe();
|
||||
return cached;
|
||||
}
|
||||
|
||||
function probe(): boolean {
|
||||
try {
|
||||
const canvas = document.createElement('canvas');
|
||||
const gl = (canvas.getContext('webgl2') ?? canvas.getContext('webgl')) as
|
||||
| WebGLRenderingContext
|
||||
| WebGL2RenderingContext
|
||||
| null;
|
||||
if (!gl) return classifyHardwareAcceleration(false, null);
|
||||
const result = classifyHardwareAcceleration(true, readUnmaskedRenderer(gl));
|
||||
// Release the throwaway context — WebGL contexts are a scarce per-page resource (~16 in
|
||||
// Chrome before force-eviction). The renderer string is already captured in `result` above
|
||||
// so this is safe to call before returning. Inner try/catch ensures a rogue loseContext
|
||||
// implementation (or a browser that surfaces it incorrectly) cannot silently swallow the
|
||||
// result or re-throw out of probe() and trigger the defensive `return true` fallback.
|
||||
try { gl.getExtension('WEBGL_lose_context')?.loseContext(); } catch { /* defensive */ }
|
||||
return result;
|
||||
} catch {
|
||||
// getContext/createElement do not throw in practice; this guard is purely defensive. An
|
||||
// unexpected probe failure should NOT regress the HW-accel majority, so default to
|
||||
// accelerated (lava on) — only the clean "no context" path above disables lava.
|
||||
return true;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user