Files
music/web/gapless-engine.ts
T
Claude Opus 5 6e07da7a36 music, extracted from the platform into its own repository
Everything the plugin is, moved out of officerdev/platform on 2026-08-15 —
41 files, unchanged from the tree they left.

  manifest.ts   identity, one permission, ffmpeg/ffprobe declared
  api/          the sidecar proxy; the prefix comes from mountPrefix()
  sidecar/      the whole /api/music contract — indexing, streaming, per-user state
  db/           music_favorites, _playlists, _playlist_items, _now_playing
  web/          panels, layout, and the player: engine, bar, lyrics, favourites
  cliamp/       the second playback path, parked — not working, kept deliberately
  widgets/      the dashboard widget, parked — plugins cannot contribute widgets
  assets/       icon.png, the dock tile
  scripts/      the reindex CLI

PLUGIN.md is the design record: what moved, what stayed, what broke, and why.
MUSIC_API.md is the contract the phone and tablet apps speak, and the reason
the sidecar's HTTP shape is not free to change.

── It does not build here, and that is the point ──

The platform resolves `hooks/useClient`, `officerdev`, `officerdb/db` and `@@/*`
through the workspace links in its own node_modules. Measured from this
directory, outside the platform checkout, every one of them fails to resolve —
7 imports in the backend, ~29 in the frontend.

So this repository is the source of truth, not yet a buildable unit. Making it
one means the host API becoming something a plugin can depend on rather than
something it reaches into. That is the next problem, and having the code here
is what makes it unavoidable rather than theoretical.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 17:34:51 +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);
}
}