feat: crossfade preview suite, loudness normalization, debug overlay
Preview modal audio, five rounds: - True track-change crossfade: scheduleSettle no longer silences the outgoing clip at t=0; preview_load's equal-power fade owns both ramps, gated on the incoming voice's first Playing event (audible_marks), with failure/overstay fallbacks. CROSSFADE_MS = 340 — locked to the reel's card-slide so audio and visuals land together. - Two-neighbour lease prefetch + a 15-min on-disk clip byte-cache (recent_clips): steps in BOTH directions crossfade in ~15 ms; revisits never re-download. - Adaptive settle (isolated steps fire immediately, bursts coalesce) and anticipatory auto-advance (step at clip end minus 400 ms) so auto-play crossfades over the tail instead of gapping. - Session resume memory: revisited tracks continue where they left off (decoder-level start offset via play_local_from/resume_at); finished tracks restart from the top. - Open/close crossfades with the MAIN player: fade-pause with the desired-paused latch landing at ramp end, fade-resume from the paused position; MAIN_FADE_GEN guards; focus handshake relaxation documented in focusBridge. Volume normalization (Settings > Playback, default on): - Dependency-free BS.1770/EBU R128 meter in the player crate (K-weighting, 400 ms gated blocks, incremental) + analyzer that reads the same growing file playback streams from — first plays are measured at download speed. - Gain layered under user volume; audible start GATED on a preliminary measurement (10 s of audio or 800 ms budget) so the level is right from the first sample and never changes mid-track; prelim boosts capped 3 dB, peak-aware clipping guard, persistent loudness-cache.json. - Gapless boundaries get their own sample-accurate gain via SetNextNormGainDb, fed by preload analysis. Debug overlay (Ctrl+Shift+D): process-tree CPU / PDH GPU / working-set + private-commit RAM / network rates / FPS / current-track download progress read from the backend's own StreamProgress / live normalization state / preview session phase. Polls only while visible. 131 JS + player + app tests green; every path verified live over CDP.
This commit is contained in:
1 parent
5e877c0b0a
commit
793f78370e
27 files changed
+2834
-178
No files matched your search
@@ -25,10 +25,13 @@ function manifest(n = 3) {
|
||||
function makeScheduler() {
|
||||
let queue = new Map();
|
||||
let nextId = 1;
|
||||
const delays = [];
|
||||
return {
|
||||
delays, // every ms passed to set(), in order — for adaptive-settle asserts
|
||||
set(fn, ms) {
|
||||
const id = nextId++;
|
||||
queue.set(id, fn);
|
||||
delays.push(ms);
|
||||
return id;
|
||||
},
|
||||
clear(id) {
|
||||
@@ -56,7 +59,7 @@ function makeTransport() {
|
||||
log,
|
||||
async load(lease, c, opts) {
|
||||
ctx = c;
|
||||
log.push(["load", lease.opaqueLeaseId, c.generation, !!opts?.startPaused]);
|
||||
log.push(["load", lease.opaqueLeaseId, c.generation, !!opts?.startPaused, opts?.startAtMs ?? 0]);
|
||||
},
|
||||
async play() {
|
||||
log.push(["play"]);
|
||||
@@ -79,6 +82,9 @@ function makeTransport() {
|
||||
end() {
|
||||
if (ctx) dispatch({ type: "MEDIA_ENDED", ...ctx });
|
||||
},
|
||||
position(ms) {
|
||||
if (ctx) dispatch({ type: "MEDIA_POSITION", ...ctx, positionMs: ms });
|
||||
},
|
||||
};
|
||||
return {
|
||||
transport: t,
|
||||
@@ -148,7 +154,7 @@ test("open: focus, settle, resolve, load audible, play", async () => {
|
||||
const st = service.getState();
|
||||
assert.equal(st.phase, PHASE.PLAYING); // lease landed, played, confirmed
|
||||
const load = transport.log.find((e) => e[0] === "load");
|
||||
assert.deepEqual(load, ["load", "op-1", st.requestGeneration, false]); // audible → not parked
|
||||
assert.deepEqual(load, ["load", "op-1", st.requestGeneration, false, 0]); // audible → not parked, from the top
|
||||
assert.ok(transport.log.some((e) => e[0] === "play"));
|
||||
});
|
||||
|
||||
@@ -207,6 +213,119 @@ test("failed resolve retries exactly once, then fails the item in place", async
|
||||
assert.equal(resolver.callCount("2"), 3, "failure is settled — no more calls");
|
||||
});
|
||||
|
||||
// -------------------------------------------- adaptive settle / auto-play --
|
||||
|
||||
test("isolated steps fire the pipeline immediately; bursts pay the settle window", async () => {
|
||||
const { service, scheduler, advance, manifest } = makeService();
|
||||
await service.open({ sessionId: SID, manifest }); // isolated → 0
|
||||
service.step(1); // same instant as the open's activation → burst → 150
|
||||
advance(300);
|
||||
service.step(1); // beyond the burst window → isolated → 0
|
||||
assert.deepEqual(scheduler.delays, [0, 150, 0]);
|
||||
});
|
||||
|
||||
test("auto-play: nearing the clip's end advances early so the next track crossfades in", async () => {
|
||||
const { service, scheduler, transport, advance, manifest } = makeService();
|
||||
await service.open({ sessionId: SID, manifest });
|
||||
await scheduler.flush();
|
||||
await drainMicrotasks(); // playing item 1, neighbour prefetched
|
||||
const genBefore = service.getState().requestGeneration;
|
||||
advance(1000); // clear the burst window: the early step must fire at once
|
||||
transport.position(29_700); // inside EARLY_ADVANCE_MS of the 30 s clip
|
||||
assert.ok(scheduler.pending() >= 1, "early advance armed");
|
||||
await scheduler.flush(); // STEP dispatched, settle armed at delay 0
|
||||
await scheduler.flush(); // pipeline: load + play item 2
|
||||
const st = service.getState();
|
||||
assert.equal(st.activeManifestIndex, 1, "advanced before the clip ended");
|
||||
assert.equal(st.phase, PHASE.PLAYING);
|
||||
assert.ok(st.requestGeneration > genBefore);
|
||||
assert.equal(st.desiredPlaying, true, "listen intent survives the auto-advance");
|
||||
assert.equal(scheduler.delays.at(-1), 0, "the early step skipped the settle window");
|
||||
});
|
||||
|
||||
test("auto-play: the LAST playable track never advances early", async () => {
|
||||
const { service, scheduler, transport, manifest } = makeService();
|
||||
await service.open({ sessionId: SID, manifest });
|
||||
await scheduler.flush();
|
||||
service.step(1);
|
||||
await scheduler.flush();
|
||||
service.step(1);
|
||||
await scheduler.flush(); // riding on item 3 of 3
|
||||
await drainMicrotasks();
|
||||
assert.equal(service.getState().activeManifestIndex, 2);
|
||||
transport.position(29_800);
|
||||
assert.equal(scheduler.pending(), 0, "no early advance armed on the last track");
|
||||
transport.end(); // natural end still lands in the replay state
|
||||
assert.equal(service.getState().phase, PHASE.ENDED);
|
||||
});
|
||||
|
||||
// ------------------------------------------------------- resume memory ----
|
||||
|
||||
test("resume: stepping back to a track picks up where it left off", async () => {
|
||||
const { service, scheduler, transport, advance, manifest } = makeService();
|
||||
await service.open({ sessionId: SID, manifest });
|
||||
await scheduler.flush();
|
||||
await drainMicrotasks(); // playing item 1
|
||||
transport.position(12_000); // listened 12 s in
|
||||
advance(300);
|
||||
service.step(1);
|
||||
await scheduler.flush();
|
||||
await drainMicrotasks(); // playing item 2 from the top
|
||||
assert.equal(transport.log.filter((e) => e[0] === "load").at(-1)[4], 0);
|
||||
advance(300);
|
||||
service.step(-1);
|
||||
await scheduler.flush();
|
||||
const back = transport.log.filter((e) => e[0] === "load").at(-1);
|
||||
assert.equal(back[1], "op-1");
|
||||
assert.equal(back[4], 12_000, "revisit resumes at the departure position");
|
||||
assert.equal(service.getState().positionMs, 12_000, "scrubber painted at the resume point");
|
||||
assert.equal(service.getState().phase, PHASE.PLAYING);
|
||||
});
|
||||
|
||||
test("resume: a track that finished (auto-advance) restarts from the top", async () => {
|
||||
const { service, scheduler, transport, advance, manifest } = makeService();
|
||||
await service.open({ sessionId: SID, manifest });
|
||||
await scheduler.flush();
|
||||
await drainMicrotasks(); // playing item 1
|
||||
advance(1000);
|
||||
transport.position(29_700); // the anticipatory auto-advance point
|
||||
await scheduler.flush(); // early STEP
|
||||
await scheduler.flush(); // playing item 2
|
||||
assert.equal(service.getState().activeManifestIndex, 1);
|
||||
advance(300);
|
||||
service.step(-1);
|
||||
await scheduler.flush();
|
||||
const back = transport.log.filter((e) => e[0] === "load").at(-1);
|
||||
assert.equal(back[1], "op-1");
|
||||
assert.equal(back[4], 0, "a finished clip restarts from the beginning");
|
||||
});
|
||||
|
||||
test("resume: flicking past a track does not erase its remembered position", async () => {
|
||||
const { service, scheduler, transport, advance, manifest } = makeService();
|
||||
await service.open({ sessionId: SID, manifest });
|
||||
await scheduler.flush();
|
||||
await drainMicrotasks(); // playing item 1
|
||||
transport.position(9_000);
|
||||
advance(300);
|
||||
service.step(1); // land on 2
|
||||
await scheduler.flush();
|
||||
await drainMicrotasks();
|
||||
advance(300);
|
||||
service.step(1); // land on 3 — item 2 leaves at position 0
|
||||
await scheduler.flush();
|
||||
await drainMicrotasks();
|
||||
advance(300);
|
||||
service.step(-1); // back to 2: no remembered position → from the top
|
||||
await scheduler.flush();
|
||||
assert.equal(transport.log.filter((e) => e[0] === "load").at(-1)[4], 0);
|
||||
advance(300);
|
||||
service.step(-1); // back to 1: the 9 s position survived the pass-through
|
||||
await scheduler.flush();
|
||||
const back = transport.log.filter((e) => e[0] === "load").at(-1);
|
||||
assert.equal(back[1], "op-1");
|
||||
assert.equal(back[4], 9_000);
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------- prefetch ---
|
||||
|
||||
test("neighbor lease prefetch: stepping forward skips the resolve", async () => {
|
||||
@@ -221,6 +340,45 @@ test("neighbor lease prefetch: stepping forward skips the resolve", async () =>
|
||||
assert.equal(service.getState().phase, PHASE.PLAYING);
|
||||
});
|
||||
|
||||
test("backward prefetch: stepping back to the previous track skips the resolve", async () => {
|
||||
const { service, scheduler, resolver, manifest } = makeService();
|
||||
await service.open({ sessionId: SID, manifest });
|
||||
await scheduler.flush();
|
||||
await drainMicrotasks(); // landed on 1; forward neighbour 2 prefetched
|
||||
service.step(1);
|
||||
await scheduler.flush();
|
||||
await drainMicrotasks(); // landed on 2; BOTH neighbours (1, 3) prefetched
|
||||
const before = resolver.callCount("1");
|
||||
assert.ok(before >= 2, "backward neighbour was prefetched after the step");
|
||||
service.step(-1);
|
||||
await scheduler.flush();
|
||||
assert.equal(service.getState().phase, PHASE.PLAYING);
|
||||
assert.equal(resolver.callCount("1"), before, "backward step used the prefetched lease");
|
||||
});
|
||||
|
||||
test("pause clicked mid-step transition parks the pipeline instead of playing on", async () => {
|
||||
const { service, scheduler, transport, manifest } = makeService();
|
||||
await service.open({ sessionId: SID, manifest });
|
||||
await scheduler.flush();
|
||||
assert.equal(service.getState().phase, PHASE.PLAYING);
|
||||
|
||||
service.step(1); // phase leaves PLAYING while the step resolves
|
||||
assert.equal(service.getState().phase, PHASE.RESOLVING_MEDIA);
|
||||
await service.togglePlay(); // user hits pause DURING the transition
|
||||
assert.equal(service.getState().desiredPlaying, false);
|
||||
assert.ok(transport.log.some((e) => e[0] === "pause"), "outgoing audio silenced");
|
||||
|
||||
const playsBefore = transport.log.filter((e) => e[0] === "play").length;
|
||||
await scheduler.flush();
|
||||
const load = transport.log.filter((e) => e[0] === "load").at(-1);
|
||||
assert.equal(load[3], true, "the new track loads parked");
|
||||
assert.equal(
|
||||
transport.log.filter((e) => e[0] === "play").length,
|
||||
playsBefore,
|
||||
"nothing auto-plays after parking"
|
||||
);
|
||||
});
|
||||
|
||||
test("a prefetched lease past 80% of its safe window is not trusted", async () => {
|
||||
const { service, scheduler, resolver, advance, manifest } = makeService({
|
||||
plan: {
|
||||
@@ -238,6 +396,143 @@ test("a prefetched lease past 80% of its safe window is not trusted", async () =
|
||||
assert.equal(resolver.callCount("2"), 2, "stale prefetch re-resolved");
|
||||
});
|
||||
|
||||
// ------------------------------------------- crossfade (fade transports) --
|
||||
|
||||
/** Fade-capable transport double (the native transport's shape): fadeOut
|
||||
* exists, and load records the crossfadeMs it was handed. */
|
||||
function makeFadeTransport() {
|
||||
const log = [];
|
||||
let dispatch = null;
|
||||
let ctx = null;
|
||||
const t = {
|
||||
log,
|
||||
async load(lease, c, opts) {
|
||||
ctx = c;
|
||||
log.push(["load", lease.opaqueLeaseId, c.generation, !!opts?.startPaused, opts?.crossfadeMs ?? 0]);
|
||||
},
|
||||
async play() {
|
||||
log.push(["play"]);
|
||||
if (ctx) dispatch({ type: "MEDIA_PLAYING", ...ctx });
|
||||
},
|
||||
async pause() {
|
||||
log.push(["pause"]);
|
||||
},
|
||||
async seek() {},
|
||||
async fadeOut(ms) {
|
||||
log.push(["fadeOut", ms]);
|
||||
},
|
||||
async stop() {
|
||||
ctx = null;
|
||||
log.push(["stop"]);
|
||||
},
|
||||
async dispose() {
|
||||
log.push(["dispose"]);
|
||||
},
|
||||
};
|
||||
return { transport: t, factory: (d) => ((dispatch = d), t) };
|
||||
}
|
||||
|
||||
function makeFadeService({ plan, n = 3 } = {}) {
|
||||
const scheduler = makeScheduler();
|
||||
const { transport, factory } = makeFadeTransport();
|
||||
const focus = makeFocus();
|
||||
const resolver = createFixtureResolver(
|
||||
plan ?? { 1: { opaqueLeaseId: "op-1" }, 2: { opaqueLeaseId: "op-2" }, 3: { opaqueLeaseId: "op-3" } }
|
||||
);
|
||||
const service = createPreviewService({
|
||||
resolver,
|
||||
transportFactory: factory,
|
||||
focus,
|
||||
scheduler,
|
||||
monotonicNow: () => 0,
|
||||
});
|
||||
return { service, scheduler, transport, focus, manifest: manifest(n) };
|
||||
}
|
||||
|
||||
test("fade transport: close crossfades — main returns at once, teardown waits for the tail", async () => {
|
||||
const { service, scheduler, transport, focus, manifest } = makeFadeService();
|
||||
await service.open({ sessionId: SID, manifest });
|
||||
await scheduler.flush();
|
||||
await drainMicrotasks(); // playing item 1
|
||||
const closing = service.close();
|
||||
await drainMicrotasks();
|
||||
assert.ok(transport.log.some((e) => e[0] === "fadeOut"), "the preview rings down");
|
||||
assert.equal(focus.calls.release, 1, "main resumes while the fade is still audible");
|
||||
assert.ok(!transport.log.some((e) => e[0] === "dispose"), "teardown holds for the fade tail");
|
||||
await scheduler.flush(); // the tail timer
|
||||
await closing;
|
||||
assert.ok(transport.log.some((e) => e[0] === "dispose"), "then the transport is torn down");
|
||||
});
|
||||
|
||||
test("fade transport: outgoing keeps sounding through a step — load's crossfade owns the handover", async () => {
|
||||
const { service, scheduler, transport, manifest } = makeFadeService();
|
||||
await service.open({ sessionId: SID, manifest });
|
||||
await scheduler.flush(); // playing op-1, op-2 prefetched
|
||||
await drainMicrotasks();
|
||||
transport.log.length = 0;
|
||||
|
||||
service.step(1);
|
||||
assert.ok(
|
||||
!transport.log.some((e) => e[0] === "fadeOut" || e[0] === "stop"),
|
||||
"nothing retires the outgoing at step time — it must still be sounding"
|
||||
);
|
||||
await scheduler.flush();
|
||||
const load = transport.log.find((e) => e[0] === "load");
|
||||
assert.equal(load[1], "op-2");
|
||||
assert.equal(load[4], 340, "audible step loads with the reel-slide-length crossfade");
|
||||
assert.ok(
|
||||
!transport.log.some((e) => e[0] === "fadeOut"),
|
||||
"no standalone fade — the crossfade inside load ramps the outgoing"
|
||||
);
|
||||
assert.equal(scheduler.pending(), 0, "overstay guard cleared once the load landed");
|
||||
});
|
||||
|
||||
test("fade transport: failed resolve fades the outgoing out instead of leaving it playing", async () => {
|
||||
const { service, scheduler, transport, manifest } = makeFadeService({
|
||||
plan: {
|
||||
1: { opaqueLeaseId: "op-1" },
|
||||
2: { failure: "network", retryable: false },
|
||||
3: { opaqueLeaseId: "op-3" },
|
||||
},
|
||||
});
|
||||
await service.open({ sessionId: SID, manifest });
|
||||
await scheduler.flush();
|
||||
await drainMicrotasks();
|
||||
service.step(1);
|
||||
await scheduler.flush();
|
||||
assert.equal(service.getState().phase, PHASE.ITEM_FAILED);
|
||||
assert.deepEqual(
|
||||
transport.log.filter((e) => e[0] === "fadeOut"),
|
||||
[["fadeOut", 340]],
|
||||
"failure retires the outgoing with the standalone fade"
|
||||
);
|
||||
});
|
||||
|
||||
test("fade transport: an overstaying resolve trips the guard and fades the outgoing", async () => {
|
||||
const { service, scheduler, transport, manifest } = makeFadeService({
|
||||
plan: {
|
||||
1: { opaqueLeaseId: "op-1" },
|
||||
2: () => new Promise(() => {}), // resolve never returns
|
||||
3: { opaqueLeaseId: "op-3" },
|
||||
},
|
||||
});
|
||||
await service.open({ sessionId: SID, manifest });
|
||||
await scheduler.flush();
|
||||
await drainMicrotasks();
|
||||
transport.log.length = 0;
|
||||
|
||||
service.step(1);
|
||||
await scheduler.flush(); // settle fires, pipeline blocks on the resolve, guard armed
|
||||
assert.equal(scheduler.pending(), 1, "overstay guard is armed while the resolve hangs");
|
||||
await scheduler.flush(); // guard fires
|
||||
assert.deepEqual(
|
||||
transport.log.filter((e) => e[0] === "fadeOut"),
|
||||
[["fadeOut", 340]],
|
||||
"the outgoing is faded out rather than playing on under the new card"
|
||||
);
|
||||
assert.ok(!transport.log.some((e) => e[0] === "load"), "nothing ever loaded");
|
||||
});
|
||||
|
||||
// ------------------------------------------------------ close/handoff -----
|
||||
|
||||
test("close during resolve: terminal, transport torn down, focus released once", async () => {
|
||||
|
||||
Reference in new issue
Block a user