Files
platform/plugins/music/web/gapless-engine.ts
T
pastilhas 0a55964db5 the player moves to the plugin, and src/ has no music code left
officerdev/src/MusicPlayer/ → plugins/music/web/. Engine, state, bar,
favourites, lyrics toggle and the library vocabulary — ten files. The barrel
stops exporting a player it no longer has, and DashboardLayout stops rendering
one.

The reasoning that kept it was removed rather than refuted. It stayed because
the dashboard widget imported useMusicPlayer from officerdev and the platform
cannot import from a plugin, so the state had to stay whatever was decided
about the UI. The owner moved the widget into the plugin in the previous
commit, and the constraint went with it: the whole remaining dependency became
one line, DashboardLayout.tsx:66.

MusicPlayerHost is mounted inside the MusicDetail panel. That reads odd until
you notice it already returned null on /music — the mini bar is the transport
there, and the host existed purely to own the GaplessEngine. In the panel it
does exactly that, and the bar code stays intact for whenever there is a slot.

[phase 2] Leaving /music unmounts the host and playback stops. Deferred on the
owner's call; the bar was "navigating away must not break the application", and
that holds: seekPlayer is optional-chained so a call with no host registered is
a no-op, registerPlayerSeek clears only its own registration, the host's
cleanup destroys the engine and nulls its ref, and the queue is global state so
returning to /music remounts and reloads. Solving it properly needs either a
shell slot a plugin can contribute to — which reopens "there is no way to
export a component" — or the engine hoisted to module scope, which keeps the
rule and loses only the off-route controls.

Also: the parked widget now imports the player as a sibling rather than through
officerdev, and shared.ts stopped being a re-export shim now that the real file
is in the plugin.

Verified: tsgo clean, 797 tests / 787 pass / same 7. Server restarts, mounts
/example /music /offscale, / and /music both 200, and the player is in the
built bundle (music.volume, music:lyrics, now-playing?device=web all present —
GaplessEngine is a class name and the production build is minified, so grepping
for it proves nothing).

Not verified by me: what it looks like in a browser. That needs your eyes.
2026-08-15 14:42:13 +00:00

376 lines
14 KiB
TypeScript

// Sample-accurate gapless playback engine (Web Audio).
//
// The HTML5 <audio> element cannot play tracks back-to-back without a gap: advancing swaps the element's
// `src`, which re-fetches + re-buffers the next file. For a continuous DJ mix (already silence-trimmed at
// the edges) that gap is audible. This engine instead decodes each track to an AudioBuffer and schedules
// the NEXT track's AudioBufferSourceNode to `start()` at the precise AudioContext time the current track
// ends — so the seam is sample-accurate, zero gap.
//
// Cost of that guarantee: it must download + decode the WHOLE next file to PCM ahead of time (a ~10-min
// track ≈ 200 MB in memory), so we cache only a few decoded buffers. Startup / manual-skip therefore has
// a decode latency (surfaced via onLoadingChange); auto-advance is pre-decoded so it's instant.
export type EngineTrack = { key: string; url: string };
export type EngineCallbacks = {
/** The engine advanced to a new track on its OWN (natural boundary) — mirror it into UI state. */
onIndex?: (index: number) => void;
/** Current position + duration (seconds). Fires ~per animation frame while playing, and on load/seek. */
onTime?: (positionSec: number, durationSec: number) => void;
/** Playback stopped because the queue ran out (host should reflect paused state). */
onEndOfQueue?: () => void;
/** True while the CURRENT track is being fetched/decoded (startup / skip / seek). */
onLoadingChange?: (loading: boolean) => void;
};
const CACHE_MAX = 3; // decoded buffers kept (current + next + a little headroom); bounds memory
export class GaplessEngine {
private ctx: AudioContext | null = null;
private gain: GainNode | null = null;
private volume = 1;
private buffers = new Map<string, AudioBuffer>(); // url → decoded PCM (insertion-ordered LRU)
private inflight = new Map<string, Promise<AudioBuffer | null>>();
private queue: EngineTrack[] = [];
private index = 0;
private cur: AudioBufferSourceNode | null = null;
private nxt: AudioBufferSourceNode | null = null;
// EVERY source node this engine has started and that has not yet ended. `cur`/`nxt` are the two it is
// reasoning about; this is the set it is responsible for silencing. They diverged once — a duplicate
// begin() left a source playing that nothing held a reference to, so nothing could ever stop it — and
// an orphan in Web Audio is unstoppable and inaudible to the code. Add here, stop from here.
private live = new Set<AudioBufferSourceNode>();
private curBaseTime = 0; // ctx time that corresponds to position 0 of the current track
private curDuration = 0;
private nextStartAt = 0; // ctx time the scheduled `nxt` will begin (= current track's end)
private pendingOffset = 0; // where the current track should (re)start from — seek/resume offset
private started = false; // has the current track's source actually been started?
private playing = false;
private gen = 0; // bumped on any disruptive change; stale async/onended callbacks check it and bail
// The generation a begin() is currently in flight for, or -1. `gen` alone cannot express this: it marks
// a change, and two begin() calls for the SAME generation are exactly the case that must be refused.
private beginGen = -1;
private raf = 0;
private cb: EngineCallbacks;
constructor(cb: EngineCallbacks) {
this.cb = cb;
}
private ensureCtx(): AudioContext {
if (!this.ctx) {
this.ctx = new AudioContext();
this.gain = this.ctx.createGain();
this.gain.gain.value = this.volume;
this.gain.connect(this.ctx.destination);
}
return this.ctx;
}
/** Resume the context from a user gesture (autoplay policy). Safe to call repeatedly. */
unlock(): void {
const ctx = this.ensureCtx();
if (ctx.state === 'suspended') void ctx.resume();
}
setVolume(v: number): void {
this.volume = v;
if (this.gain) this.gain.gain.value = v;
}
// ── Decoding (fetch full file → PCM), deduped + LRU-capped ──
private decode(url: string): Promise<AudioBuffer | null> {
const cached = this.buffers.get(url);
if (cached) return Promise.resolve(cached);
const existing = this.inflight.get(url);
if (existing) return existing;
const ctx = this.ensureCtx();
const p = (async () => {
try {
const res = await fetch(url);
if (!res.ok) return null;
const bytes = await res.arrayBuffer();
const decoded = await ctx.decodeAudioData(bytes);
this.buffers.set(url, decoded);
this.evict();
return decoded;
} catch {
return null;
} finally {
this.inflight.delete(url);
}
})();
this.inflight.set(url, p);
return p;
}
private evict(): void {
while (this.buffers.size > CACHE_MAX) {
const keep = new Set([this.queue[this.index]?.url, this.queue[this.index + 1]?.url]);
let removed = false;
for (const k of this.buffers.keys()) {
if (!keep.has(k)) {
this.buffers.delete(k);
removed = true;
break;
}
}
if (!removed) break; // everything left is current/next — stop
}
}
// ── Public control surface ──
/** Load a queue and either start it (autoplay) or prepare it paused at `seekTo`. */
load(queue: EngineTrack[], index: number, autoplay: boolean, seekTo = 0): void {
this.gen++;
this.stopSources();
this.queue = queue;
this.index = queue.length ? Math.max(0, Math.min(index, queue.length - 1)) : 0;
this.playing = autoplay && queue.length > 0;
this.started = false;
this.pendingOffset = Math.max(0, seekTo);
this.curDuration = 0;
this.curBaseTime = 0;
this.startTicker();
if (!queue.length) return;
if (autoplay) void this.begin(this.gen);
else void this.prepare(this.gen);
}
/** Manual jump to an arbitrary index (prev / next button / track click). A small decode hitch is fine. */
skipTo(index: number): void {
this.gen++;
this.stopSources();
this.index = Math.max(0, Math.min(index, this.queue.length - 1));
this.pendingOffset = 0;
this.started = false;
this.curDuration = 0;
if (this.playing) void this.begin(this.gen);
else void this.prepare(this.gen);
}
seek(sec: number): void {
if (!this.queue[this.index]) return;
this.gen++;
this.stopSources();
this.pendingOffset = Math.max(0, this.curDuration ? Math.min(sec, this.curDuration) : sec);
this.started = false;
if (this.playing) {
void this.begin(this.gen);
} else {
this.cb.onTime?.(this.pendingOffset, this.curDuration);
void this.prepare(this.gen);
}
}
play(): void {
this.playing = true;
const ctx = this.ensureCtx();
if (!this.started) void this.begin(this.gen);
else if (ctx.state === 'suspended') void ctx.resume();
}
pause(): void {
this.playing = false;
if (this.ctx && this.ctx.state === 'running') void this.ctx.suspend();
}
destroy(): void {
this.gen++;
if (this.raf) cancelAnimationFrame(this.raf);
this.raf = 0;
this.stopSources();
this.buffers.clear();
this.inflight.clear();
if (this.ctx) {
void this.ctx.close();
this.ctx = null;
this.gain = null;
}
}
// ── Internals ──
/** Decode the current track without starting it — paused/prepared (restore, or seek while paused). */
private async prepare(gen: number): Promise<void> {
const t = this.queue[this.index];
if (!t) return;
this.cb.onLoadingChange?.(true);
const buf = await this.decode(t.url);
this.cb.onLoadingChange?.(false);
if (gen !== this.gen || !buf) return;
this.curDuration = buf.duration;
this.cb.onTime?.(Math.min(this.pendingOffset, buf.duration), buf.duration);
void this.preloadNext(gen);
}
/**
* Start the current track at `pendingOffset` (decoding first if needed).
*
* Re-entrant calls for the same generation are REFUSED, and that guard is the whole reason this method
* is not simply idempotent-by-gen. `started` only becomes true at the very end, after a fetch and a
* full decode — seconds, for a long track. Anything that calls begin() in that window sees
* `started === false` and starts a second, parallel decode of the same track, and both finish and both
* call startBuffer(): two sources of the same audio playing at once, only one of them in `cur`.
*
* That was not a rare race. Starting a queue from a paused player fired it every single time: the host
* commits a new queue and `playing: true` together, its queue effect calls load(autoplay) → begin, and
* its playing effect then calls play() → begin again, same generation. The audible result compounds —
* the untracked twin keeps its own onended, so at the track boundary advance() runs twice, the index
* jumps two tracks and a second source is promoted while the first is still sounding.
*/
private async begin(gen: number): Promise<void> {
if (this.beginGen === gen) return;
this.beginGen = gen;
try {
const ctx = this.ensureCtx();
if (ctx.state === 'suspended') await ctx.resume();
if (gen !== this.gen) return;
const t = this.queue[this.index];
if (!t) return;
this.cb.onLoadingChange?.(true);
const buf = await this.decode(t.url);
this.cb.onLoadingChange?.(false);
if (gen !== this.gen || !buf) return;
this.startBuffer(buf, this.pendingOffset, gen);
void this.preloadNext(gen);
} finally {
// Released even on the bail paths: a decode that fails must not leave this generation permanently
// unable to start, or a failed track would wedge the player until something bumped `gen`.
if (this.beginGen === gen) this.beginGen = -1;
}
}
private startBuffer(buf: AudioBuffer, offset: number, gen: number): void {
const ctx = this.ensureCtx();
const src = ctx.createBufferSource();
src.buffer = buf;
src.connect(this.gain!);
const startAt = ctx.currentTime;
const off = Math.min(Math.max(0, offset), buf.duration);
src.start(startAt, off);
this.live.add(src);
src.onended = () => this.onSourceEnded(src, gen);
this.cur = src;
this.curDuration = buf.duration;
this.curBaseTime = startAt - off; // position = ctx.currentTime - curBaseTime
this.started = true;
this.playing = true;
}
/** Decode index+1 and schedule it to begin exactly when the current track ends. */
private async preloadNext(gen: number): Promise<void> {
if (this.nxt) {
const stale = this.nxt;
this.live.delete(stale);
try {
stale.onended = null;
stale.stop();
} catch {
/* not started */
}
try {
stale.disconnect();
} catch {
/* already disconnected */
}
this.nxt = null;
}
const forIndex = this.index;
const nextTrack = this.queue[forIndex + 1];
if (!nextTrack || !this.started) return;
const buf = await this.decode(nextTrack.url);
if (gen !== this.gen || this.index !== forIndex || !buf) return;
const ctx = this.ensureCtx();
const boundary = this.curBaseTime + this.curDuration;
if (boundary <= ctx.currentTime) return; // already past — advance() will start it fresh
const src = ctx.createBufferSource();
src.buffer = buf;
src.connect(this.gain!);
src.start(boundary, 0);
this.live.add(src);
src.onended = () => this.onSourceEnded(src, gen);
this.nxt = src;
this.nextStartAt = boundary;
}
/**
* A source finished. Only the one the engine considers CURRENT may drive the queue forward.
*
* Without the identity check, any source that outlives its bookkeeping still advances the queue when it
* ends — so one stray node does not just play unwanted audio, it desynchronises the index for
* everything after it. `cur` is the single source of truth for "what is playing"; ending anything else
* is bookkeeping, not an event.
*/
private onSourceEnded(src: AudioBufferSourceNode, gen: number): void {
this.live.delete(src);
if (gen !== this.gen || this.cur !== src) return;
this.advance();
}
/** Fired at a track boundary (its source ended): move to the next track. */
private advance(): void {
if (this.index + 1 >= this.queue.length) {
this.playing = false;
this.started = false;
this.cur = null;
this.cb.onEndOfQueue?.();
return;
}
this.index++;
const promoted = this.nxt;
this.nxt = null;
if (promoted?.buffer) {
// The next source was scheduled to start at the exact boundary — it's already playing seamlessly.
this.cur = promoted;
this.curDuration = promoted.buffer.duration;
this.curBaseTime = this.nextStartAt;
this.started = true;
this.cb.onIndex?.(this.index);
void this.preloadNext(this.gen);
} else {
// Next wasn't decoded in time (rare) — start it now, accepting a tiny gap this once.
this.cb.onIndex?.(this.index);
this.pendingOffset = 0;
this.started = false;
void this.begin(this.gen);
}
}
/** Silence everything this engine has started. Iterates `live`, not just cur/nxt — see its declaration. */
private stopSources(): void {
for (const s of this.live) {
try {
s.onended = null;
s.stop();
} catch {
/* not started */
}
try {
s.disconnect();
} catch {
/* already disconnected */
}
}
this.live.clear();
this.cur = null;
this.nxt = null;
}
private startTicker(): void {
if (this.raf) return;
const tick = () => {
this.raf = requestAnimationFrame(tick);
if (!this.ctx || !this.started || !this.playing) return;
const pos = this.ctx.currentTime - this.curBaseTime;
this.cb.onTime?.(Math.max(0, Math.min(pos, this.curDuration)), this.curDuration);
};
this.raf = requestAnimationFrame(tick);
}
}