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>
376 lines
14 KiB
TypeScript
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);
|
|
}
|
|
}
|