forked from Keelrleek/SoulStream
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.
125 lines
4.8 KiB
JavaScript
125 lines
4.8 KiB
JavaScript
// Native preview transport — the production implementation of the transport
|
|
// contract in transport.js, over the Tauri preview backend.
|
|
//
|
|
// The backend's event stream (`soulstream:preview`) knows nothing about
|
|
// sessions or generations; THIS wrapper is what makes those events safe. It
|
|
// stamps every incoming event with the {sessionId, generation, sessionKey}
|
|
// of the load that is CURRENTLY active on this side of the boundary, and the
|
|
// reducer's guard does the rest: after a step, events from the superseded
|
|
// clip carry the old generation and land inert. stop()/dispose() clear the
|
|
// context, so a straggler event after teardown dispatches nothing at all.
|
|
|
|
import * as api from "../api.js";
|
|
import { isValidLease } from "./lease.js";
|
|
|
|
export function createNativeTransport(dispatch) {
|
|
let ctx = null; // { sessionId, generation, sessionKey }
|
|
let unlisten = null;
|
|
let disposed = false;
|
|
|
|
const stamp = (type, extra = {}) => {
|
|
if (!ctx) return;
|
|
dispatch({ type, ...ctx, ...extra });
|
|
};
|
|
|
|
async function ensureSubscribed() {
|
|
if (unlisten || disposed) return;
|
|
unlisten = await api.onEvent("soulstream:preview", (ev) => {
|
|
switch (ev.kind) {
|
|
case "playing":
|
|
stamp("MEDIA_PLAYING");
|
|
break;
|
|
case "paused":
|
|
stamp("MEDIA_PAUSED");
|
|
break;
|
|
case "position":
|
|
stamp("MEDIA_POSITION", { positionMs: Math.round(ev.secs * 1000) });
|
|
break;
|
|
case "ended":
|
|
stamp("MEDIA_ENDED");
|
|
break;
|
|
case "error":
|
|
stamp("ITEM_FAILED", {
|
|
sessionKey: ctx?.sessionKey,
|
|
errorClass: "transport",
|
|
});
|
|
break;
|
|
// "buffering"/"stopped" carry no session-level meaning: the
|
|
// reducer derives buffering from LEASE_READY + desiredPlaying,
|
|
// and stops are always consequences of our own commands.
|
|
default:
|
|
break;
|
|
}
|
|
});
|
|
}
|
|
|
|
return {
|
|
/** Accept a native lease and hand its clip to the preview player.
|
|
* `startPaused` maps to the backend's desired-paused latch, so a
|
|
* parked open can never leak audio (Phase 0.4). */
|
|
async load(lease, { sessionId, generation, sessionKey }, { startPaused = false, crossfadeMs = 0, startAtMs = 0 } = {}) {
|
|
if (disposed) throw new Error("transport disposed");
|
|
await ensureSubscribed();
|
|
if (!isValidLease(lease) || lease.transport !== "native") {
|
|
ctx = { sessionId, generation, sessionKey };
|
|
stamp("ITEM_FAILED", { sessionKey, errorClass: "invalid_media" });
|
|
ctx = null;
|
|
return;
|
|
}
|
|
ctx = { sessionId, generation, sessionKey };
|
|
// startAtMs rides into the DECODER (session resume): the clip
|
|
// comes up already positioned, no post-load seek race.
|
|
await api.previewLoad(
|
|
lease.opaqueLeaseId,
|
|
startPaused,
|
|
crossfadeMs,
|
|
startAtMs > 0 ? startAtMs / 1000 : null
|
|
);
|
|
},
|
|
async play() {
|
|
if (!ctx) return;
|
|
await api.previewControl("resume");
|
|
},
|
|
async pause() {
|
|
if (!ctx) return;
|
|
await api.previewControl("pause");
|
|
},
|
|
async seek(positionMs) {
|
|
if (!ctx) return;
|
|
await api.previewControl("seek", positionMs / 1000);
|
|
},
|
|
async setVolume(v) {
|
|
await api.previewControl("volume", v);
|
|
},
|
|
/** Crossfade out-half: ramp the current clip to silence instead of
|
|
* cutting it. ctx is deliberately KEPT — the tail's events carry the
|
|
* superseded generation and die at the reducer, and the next load
|
|
* will restamp; clearing ctx here would also mute a failure event
|
|
* racing the fade. */
|
|
async fadeOut(ms) {
|
|
if (!ctx) return;
|
|
await api.previewControl("fade_out", ms);
|
|
},
|
|
async stop() {
|
|
ctx = null; // events from the dying clip stamp nothing
|
|
await api.previewControl("stop");
|
|
},
|
|
/** Idempotent teardown: silences the backend, deletes clip files,
|
|
* detaches the event listener. Close, navigation and hot reload all
|
|
* funnel here. */
|
|
async dispose() {
|
|
if (disposed) return;
|
|
disposed = true;
|
|
ctx = null;
|
|
try {
|
|
await api.previewDispose();
|
|
} finally {
|
|
if (unlisten) {
|
|
unlisten();
|
|
unlisten = null;
|
|
}
|
|
}
|
|
},
|
|
};
|
|
}
|