Merge streaming-overhaul into dev (Opus low-data streaming, windowed streaming, HW-accel-off stabilization)

This commit is contained in:
daniel-c-harvey
2026-06-26 11:14:59 -04:00
97 changed files with 10086 additions and 591 deletions
+3
View File
@@ -25,6 +25,7 @@
<script src="_framework/blazor.web.js"></script>
<script src=@Assets["_content/MudBlazor/MudBlazor.min.js"]></script>
<script type="module">
import('./js/settings/settings.js');
import('./js/audio/index.js');
import('./js/telemetry/beacon.js');
import('./js/telemetry/anonid.js');
@@ -37,6 +38,7 @@
[Inject] public required DarkModeService DarkModeService { get; set; }
[Inject] public required SeoEnvironment SeoEnvironment { get; set; }
[Inject] public required IWebHostEnvironment HostEnvironment { get; set; }
[Inject] public required SettingsService SettingsService { get; set; }
protected override void OnInitialized()
{
@@ -45,6 +47,7 @@
// Seed the environment-gated robots bridge during prerender; [PersistentState] rounds it to WASM
// so both render passes resolve the same default robots (Production → index, else noindex).
SeoEnvironment.IsProduction = HostEnvironment.IsProduction();
SettingsService.CheckSettings();
}
}
@@ -205,21 +205,26 @@ public class TrackProxyController : ControllerBase
/// <summary>
/// Proxies audio streaming from DeepDrftAPI as a transparent HTTP Range relay.
/// Forwards the incoming Range header upstream and relays the upstream status
/// (200 full, 206 partial, 416 unsatisfiable) and range-related response headers
/// back to the browser verbatim. The proxy does not slice — the upstream already did.
/// Forwards the incoming Range header upstream and the optional <c>format</c> selector
/// (Phase 18.3 — <c>opus|lossless</c>, threaded the same way the listing params are),
/// and relays the upstream status (200 full, 206 partial, 416 unsatisfiable) and
/// range-related response headers back to the browser verbatim. The proxy does not
/// slice — the upstream already did.
/// </summary>
[HttpGet("{trackId}")]
public async Task<ActionResult> GetTrack(
string trackId,
[FromQuery] string? format = null,
CancellationToken ct = default)
{
var rangeHeader = Request.Headers.Range.ToString();
_logger.LogInformation("Proxying track {TrackId} range '{Range}'", trackId, rangeHeader);
_logger.LogInformation("Proxying track {TrackId} range '{Range}' format '{Format}'", trackId, rangeHeader, format);
var request = new HttpRequestMessage(
HttpMethod.Get,
$"api/track/{Uri.EscapeDataString(trackId)}");
var path = $"api/track/{Uri.EscapeDataString(trackId)}";
if (!string.IsNullOrWhiteSpace(format))
path += $"?format={Uri.EscapeDataString(format)}";
var request = new HttpRequestMessage(HttpMethod.Get, path);
// Forward the browser's Range header upstream so DeepDrftAPI slices the file.
// TryAddWithoutValidation avoids RangeHeaderValue reparsing — we relay the raw
@@ -355,4 +360,40 @@ public class TrackProxyController : ControllerBase
return Content(json, "application/json");
}
}
/// <summary>
/// Proxies a track's Opus seek/setup sidecar (raw bytes) from DeepDrftAPI (Phase 18.3). Unauthenticated,
/// same posture as the audio stream forward. The sidecar is a small one-time fetch (≤ ~115 KB), so it is
/// buffered and relayed; a 404 (no Opus artifact / no sidecar stored) passes through so the client
/// degrades to lossless rather than treating it as an error. The "opus/seekdata" 3-segment route makes a
/// collision with the parameterized "{trackId}" audio route impossible.
/// </summary>
[HttpGet("{trackId}/opus/seekdata")]
public async Task<ActionResult> GetOpusSeekData(string trackId, CancellationToken ct = default)
{
var path = $"api/track/{Uri.EscapeDataString(trackId)}/opus/seekdata";
HttpResponseMessage upstream;
try
{
upstream = await _upstream.GetAsync(path, HttpCompletionOption.ResponseHeadersRead, ct);
}
catch (Exception ex)
{
_logger.LogError(ex, "Upstream call to DeepDrftAPI track/{TrackId}/opus/seekdata failed", trackId);
return StatusCode(502, "Upstream unavailable");
}
using (upstream)
{
if (!upstream.IsSuccessStatusCode)
{
_logger.LogWarning("DeepDrftAPI track/{TrackId}/opus/seekdata returned {Status}", trackId, (int)upstream.StatusCode);
return StatusCode((int)upstream.StatusCode);
}
var bytes = await upstream.Content.ReadAsByteArrayAsync(ct);
return File(bytes, "application/octet-stream");
}
}
}
@@ -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`);
}
+300 -24
View File
@@ -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;
}
+298
View File
@@ -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 1664 KB chunks.
*
* Lacing rules (RFC 3533 §6): a page's segment table lists N segment lengths (0255). 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;
}
}
+196
View File
@@ -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`);
}
+449 -17
View File
@@ -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.10.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
*/
+49 -1
View File
@@ -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();
+43 -1
View File
@@ -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
View File
@@ -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;
}
}
@@ -0,0 +1,25 @@
using DeepDrftPublic.Client.Common;
using DeepDrftPublic.Client.Services;
namespace DeepDrftPublic.Services;
/// <summary>
/// Server-side prerender reader for public-site listener settings (Phase 18 wave 18.6), the sibling of
/// <see cref="DarkModeService"/>. Reads each preference's cookie via <see cref="IHttpContextAccessor"/>
/// during prerender and seeds the scoped <see cref="PublicSiteSettings"/>, which <c>MainLayout</c> then
/// rounds through <c>PersistentComponentState</c> into WASM — so the first paint already reflects the
/// listener's choice with no wrong-default flash (the streaming-quality analogue of the wrong-theme fix).
/// Inherits the shared cookie names + parsers from <see cref="SettingsServiceBase"/> so the server read and
/// the client write agree on one wire format.
/// </summary>
public class SettingsService(PublicSiteSettings settings, IHttpContextAccessor httpAccessor) : SettingsServiceBase
{
public void CheckSettings()
{
var cookies = httpAccessor.HttpContext?.Request.Cookies;
if (cookies is null) return;
cookies.TryGetValue(StreamQualityCookieName, out var streamQuality);
settings.StreamQuality = ParseStreamQuality(streamQuality);
}
}
+5
View File
@@ -11,5 +11,10 @@ public static class Startup
builder.Services
.AddHttpContextAccessor()
.AddScoped<DarkModeService>();
// Server prerender read for public-site listener settings (Phase 18 wave 18.6), sibling to
// DarkModeService. PublicSiteSettings itself is registered in the client Startup (shared by SSR and
// WASM); this seeds it from the streamQuality cookie during prerender.
builder.Services.AddScoped<SettingsService>();
}
}
+1 -2
View File
@@ -1,8 +1,7 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
"Default": "Warning"
}
},
"AllowedHosts": "*",
+2 -1
View File
@@ -21,6 +21,7 @@
"node_modules",
"bin/**/*",
"obj/**/*",
"publish/**/*"
"publish/**/*",
"Interop/**/*.test.ts"
]
}
@@ -463,6 +463,46 @@ h2, h3, h4, h5, h6,
flex: 1 1 auto;
}
/* Public-site Settings menu (Phase 18 wave 18.6). The MudMenu body renders inside .mud-popover, which
already re-points --mud-palette-surface to the theme-aware --deepdrft-popover-surface (see above), so the
panel inherits the correct surface + text in both themes with no dark override. These rules are layout
only: padding, the section heading, and each settings item's label/control stacking. Scoped via the
global stylesheet (not CSS isolation) because the menu body portals out of the component's DOM scope. */
.dd-settings-panel {
padding: 0.75rem 1rem;
min-width: 240px;
max-width: 320px;
color: var(--deepdrft-page-text);
}
.dd-settings-heading {
font-family: "DM Sans", sans-serif;
font-size: 0.7rem;
font-weight: 600;
letter-spacing: 0.12em;
text-transform: uppercase;
opacity: 0.7;
margin-bottom: 0.5rem;
}
.dd-settings-item + .dd-settings-item {
margin-top: 0.75rem;
padding-top: 0.75rem;
border-top: 1px solid var(--deepdrft-popover-surface);
}
.dd-settings-item-label {
font-weight: 500;
margin-bottom: 0.25rem;
}
/* The honest capability note under the quality control (OQ2 / AC7). */
.dd-setting-note {
font-size: 0.75rem;
opacity: 0.75;
margin-top: 0.25rem;
}
.deepdrft-share-embed-field .mud-input-slot {
font-family: var(--deepdrft-font-mono) !important;
font-size: 0.75rem;