feat: add multi-user support for favorites management and room clock synchronization
- Implemented a new API endpoint for retrieving and managing user favorites in /api/users. - Added functionality for admins to edit the shared "main" user's favorites. - Created a one-shot DB smoke test script for verifying multi-user kiosk migrations. - Introduced a RoomClock class for synchronizing server time across clients using WebSocket.
This commit is contained in:
644
web/player.js
644
web/player.js
@@ -1,17 +1,151 @@
|
||||
import Hls from 'hls.js';
|
||||
import { api } from './shared/api.js';
|
||||
import { api, isAbort } from './shared/api.js';
|
||||
|
||||
// Shared symbols so the visualiser and the player attach to the same
|
||||
// AudioContext / MediaElementSource for a given <audio> element.
|
||||
// `createMediaElementSource` may only be called once per element.
|
||||
const CTX_KEY = Symbol.for('oradio.audio.ctx');
|
||||
const SRC_KEY = Symbol.for('oradio.audio.src');
|
||||
const ANALYSER_KEY = Symbol.for('oradio.audio.analyser');
|
||||
const DELAY_KEY = Symbol.for('oradio.audio.delay');
|
||||
const GAIN_KEY = Symbol.for('oradio.audio.gain');
|
||||
|
||||
// How much audio every client is willing to hold in its DelayNode buffer.
|
||||
// 30 s is the safe ceiling for most browsers' DelayNode maxDelayTime.
|
||||
const MAX_DELAY_SEC = 30;
|
||||
// 3s is responsive on a well-connected device and feels close to live.
|
||||
// Slow phones (iPhone cold-start ~4s) will report 'lagging' on the sync
|
||||
// chip until the user bumps the buffer via the room-pill slider — that's
|
||||
// the right UX escape hatch rather than forcing everyone to pay a 12s
|
||||
// startup lag.
|
||||
const DEFAULT_BUFFER_MS = 3000;
|
||||
const MIN_BUFFER_MS = 1500;
|
||||
const MAX_BUFFER_MS = 25000;
|
||||
|
||||
// Light, opt-in tracing for the sync pipeline. Enable in the browser via:
|
||||
// localStorage.setItem('oradio.debugSync', '1')
|
||||
function syncDebugOn() {
|
||||
try { return localStorage.getItem('oradio.debugSync') === '1'; }
|
||||
catch { return false; }
|
||||
}
|
||||
export function logSync(tag, payload) {
|
||||
if (!syncDebugOn()) return;
|
||||
try { console.log('[player:sync]', tag, payload || ''); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
function readBufferPref() {
|
||||
try {
|
||||
const raw = Number(localStorage.getItem('oradio.syncBufferMs'));
|
||||
if (Number.isFinite(raw) && raw >= MIN_BUFFER_MS && raw <= MAX_BUFFER_MS) return raw;
|
||||
} catch { /* private mode */ }
|
||||
return DEFAULT_BUFFER_MS;
|
||||
}
|
||||
|
||||
export class Player {
|
||||
constructor({ onState }) {
|
||||
this.audio = new Audio();
|
||||
this.audio.preload = 'none';
|
||||
// Note: do NOT set crossOrigin — most Icecast/SHOUTcast servers don't send
|
||||
// CORS headers and the browser will then refuse to play the stream.
|
||||
// iOS Safari refuses to play inline without this; without it the
|
||||
// OS may also pause/route audio unpredictably which makes sync
|
||||
// measurement jump.
|
||||
this.audio.playsInline = true;
|
||||
this.audio.setAttribute('playsinline', '');
|
||||
this.audio.setAttribute('webkit-playsinline', '');
|
||||
this.hls = null;
|
||||
this.station = null;
|
||||
this.streamId = null;
|
||||
this.usingProxy = false;
|
||||
// Volume tracked separately from `audio.volume` because once the
|
||||
// WebAudio graph is built we pin the element to 1 and drive the
|
||||
// GainNode at the END of the chain (post-DelayNode) instead.
|
||||
this._logicalVolume = this.audio.volume;
|
||||
// Monotonically increasing token incremented on every play()/stop().
|
||||
// Any async continuation that captured an older token bails out so
|
||||
// a slow /resolve from station A can't clobber the audio src after
|
||||
// the user has already moved on to station B.
|
||||
this._playGen = 0;
|
||||
// AbortController for the currently in-flight /resolve. Aborted on
|
||||
// stop() or whenever a new play() begins.
|
||||
this._playAbort = null;
|
||||
this.onState = onState || (() => { });
|
||||
this.audio.addEventListener('playing', () => this.emit({ playing: true, loading: false, error: null }));
|
||||
this.audio.addEventListener('pause', () => this.emit({ playing: false, loading: false }));
|
||||
// Cross-client sync state.
|
||||
//
|
||||
// Three anchoring sources, tried in order of accuracy each tick:
|
||||
// 1. HLS PROGRAM-DATE-TIME — for HLS streams that publish PDT,
|
||||
// every client locks to absolute stream time, so they converge
|
||||
// regardless of join order or network history.
|
||||
// 2. Master `sync-pos` — for non-HLS streams the master display
|
||||
// announces (atServerNow, masterCT) every ~2 s; peers compute
|
||||
// their drift versus master's projected position.
|
||||
// 3. Local first decoded sample — fallback before either of the
|
||||
// above is available.
|
||||
//
|
||||
// DelayNode holds `bufferMs` on every client so each speaker emits
|
||||
// its (corrected) audio at the same wall-clock instant. A small PI
|
||||
// controller nudges `audio.playbackRate` (±0.5 % max) to keep the
|
||||
// local decoder's rate locked to the shared timeline.
|
||||
this.sync = {
|
||||
enabled: false,
|
||||
clock: null, // RoomClock instance
|
||||
startedAt: null, // server epoch ms when room playback began
|
||||
t0Wall: null, // clock.now() at this client's first decoded sample
|
||||
t0Audio: null, // audio.currentTime at that same moment
|
||||
// Master sync-pos snapshot (peers only; master ignores its own).
|
||||
masterStationId: null,
|
||||
masterCT: null, // master's audio.currentTime at atServerNow
|
||||
masterAt: null, // server epoch ms when masterCT was sampled
|
||||
// Latest HLS PDT observation (informational; PDT is read live each tick).
|
||||
pdtMs: null,
|
||||
// PI controller state.
|
||||
integral: 0,
|
||||
// Most-recent active anchor source — 'hls-pdt' | 'master' | 'local'.
|
||||
anchorSource: null,
|
||||
bufferMs: readBufferPref(),
|
||||
currentDelay: 0,
|
||||
targetDelay: 0,
|
||||
driftMs: 0, // measured audio-vs-clock drift (ms; positive = audio ahead)
|
||||
rate: 1.0, // last applied audio.playbackRate
|
||||
error: 0,
|
||||
status: 'off', // 'off' | 'no-anchor' | 'measuring' | 'in-sync' | 'lagging' | 'no-buffer'
|
||||
timer: null
|
||||
};
|
||||
this.onSyncChange = null; // optional sync status listener
|
||||
this.audio.addEventListener('playing', () => {
|
||||
// First decoded sample after a fresh load — anchor the drift
|
||||
// reference. From now on we expect audio.currentTime to track
|
||||
// (clock.now() - t0Wall) / 1000 + t0Audio. Any divergence is
|
||||
// drift to correct via playbackRate.
|
||||
if (this.sync.enabled && this.sync.t0Wall == null && this.sync.clock) {
|
||||
this.sync.t0Wall = this.sync.clock.now();
|
||||
this.sync.t0Audio = this.audio.currentTime;
|
||||
logSync('anchor t0', { t0Wall: this.sync.t0Wall, t0Audio: this.sync.t0Audio });
|
||||
// Graph was (ideally) pre-built in play() so the DelayNode is
|
||||
// already in the path; this just guarantees it for the case
|
||||
// where sync was toggled on after playback started.
|
||||
this._ensureAudioGraph();
|
||||
this._syncTick();
|
||||
}
|
||||
// Notify external listeners (master uses this to re-anchor
|
||||
// `started_at` to its actual first decoded sample).
|
||||
if (this.onPlayingOnce && !this._announcedPlayingForStation) {
|
||||
this._announcedPlayingForStation = this.station?.id ?? true;
|
||||
try { this.onPlayingOnce(this.station); } catch (err) { console.warn('[player] onPlayingOnce', err); }
|
||||
}
|
||||
this.emit({ playing: true, loading: false, error: null });
|
||||
});
|
||||
this.audio.addEventListener('pause', () => {
|
||||
// play() sets _silentStop before its internal stop() so the
|
||||
// resulting (asynchronous) pause event doesn't emit a transient
|
||||
// {stationId: old, playing: false} to onState — the consumer
|
||||
// would otherwise re-broadcast it to the room and every kiosk
|
||||
// would briefly see the wrong state. User-initiated stop() /
|
||||
// pause() leave the flag clear and emit normally.
|
||||
if (this._silentStop) {
|
||||
this._silentStop = false;
|
||||
return;
|
||||
}
|
||||
this.emit({ playing: false, loading: false });
|
||||
});
|
||||
this.audio.addEventListener('waiting', () => this.emit({ loading: true }));
|
||||
this.audio.addEventListener('error', () => {
|
||||
const code = this.audio.error?.code;
|
||||
@@ -27,21 +161,74 @@ export class Player {
|
||||
stationId: this.station?.id ?? null,
|
||||
stationName: this.station?.name ?? null,
|
||||
genres: this.station?.genres || [],
|
||||
volume: this.audio.volume,
|
||||
volume: this._logicalVolume ?? this.audio.volume,
|
||||
...extra
|
||||
});
|
||||
}
|
||||
|
||||
setVolume(v) {
|
||||
this.audio.volume = Math.max(0, Math.min(1, v));
|
||||
const clamped = Math.max(0, Math.min(1, Number(v) || 0));
|
||||
this._logicalVolume = clamped;
|
||||
const gain = this.audio[GAIN_KEY];
|
||||
const ctx = this.audio[CTX_KEY];
|
||||
if (gain && ctx) {
|
||||
// Route via the post-DelayNode GainNode so the change is audible
|
||||
// immediately, not queued behind `bufferMs` of buffered audio.
|
||||
try {
|
||||
const t = ctx.currentTime;
|
||||
gain.gain.cancelScheduledValues(t);
|
||||
gain.gain.setValueAtTime(gain.gain.value, t);
|
||||
gain.gain.linearRampToValueAtTime(clamped, t + 0.03);
|
||||
} catch {
|
||||
gain.gain.value = clamped;
|
||||
}
|
||||
// Audio element is pinned to 1 once the graph exists — its volume
|
||||
// is applied pre-delay and would compound with the GainNode.
|
||||
if (this.audio.volume !== 1) this.audio.volume = 1;
|
||||
} else {
|
||||
this.audio.volume = clamped;
|
||||
}
|
||||
this.emit({});
|
||||
}
|
||||
|
||||
/** Alias kept for clarity: local <audio>.volume only; never broadcasts. */
|
||||
setLocalVolume(v) { this.setVolume(v); }
|
||||
|
||||
setMuted(m) {
|
||||
this.audio.muted = !!m;
|
||||
this.emit({});
|
||||
}
|
||||
getMuted() { return !!this.audio.muted; }
|
||||
|
||||
/**
|
||||
* Route output to a specific audio sink. Must call BOTH the audio element
|
||||
* setSinkId() AND the AudioContext setSinkId() — once the WebAudio graph
|
||||
* is built, audio exits through `AudioContext.destination` and the audio
|
||||
* element's sink is bypassed entirely.
|
||||
*/
|
||||
async setSinkId(deviceId) {
|
||||
const id = String(deviceId || '');
|
||||
this._sinkId = id;
|
||||
if (this.audio.setSinkId) {
|
||||
try { await this.audio.setSinkId(id); }
|
||||
catch (err) { console.warn('[player] audio.setSinkId failed', err); }
|
||||
}
|
||||
const ctx = this.audio[CTX_KEY];
|
||||
if (ctx && typeof ctx.setSinkId === 'function') {
|
||||
try { await ctx.setSinkId(id); }
|
||||
catch (err) { console.warn('[player] AudioContext.setSinkId failed', err); }
|
||||
}
|
||||
}
|
||||
|
||||
stop() {
|
||||
// Invalidate any in-flight play() continuation immediately.
|
||||
this._playGen++;
|
||||
if (this._playAbort) { try { this._playAbort.abort(); } catch { /* ignore */ } this._playAbort = null; }
|
||||
this.audio.pause();
|
||||
this.audio.removeAttribute('src');
|
||||
this.audio.load();
|
||||
if (this.hls) { this.hls.destroy(); this.hls = null; }
|
||||
this._resetSyncAnchor();
|
||||
}
|
||||
|
||||
togglePause() {
|
||||
@@ -51,18 +238,71 @@ export class Player {
|
||||
}
|
||||
|
||||
async play(station) {
|
||||
// Mute the pause event that this internal stop() will produce so
|
||||
// the consumer doesn't see a transient {playing:false, stationId:
|
||||
// old} mid-switch. The pause listener consumes the flag and resets.
|
||||
if (!this.audio.paused) this._silentStop = true;
|
||||
this.stop();
|
||||
const gen = ++this._playGen;
|
||||
this.station = station;
|
||||
this.emit({ playing: false, loading: true });
|
||||
let resolved;
|
||||
// Reset the first-playing latch so onPlayingOnce fires once per new
|
||||
// station (master uses this to re-anchor started_at).
|
||||
this._announcedPlayingForStation = null;
|
||||
logSync('play()', { stationId: station?.id, syncEnabled: this.sync.enabled, gen });
|
||||
// Emit BEFORE the network call so the UI flips to the new
|
||||
// station name immediately. Otherwise on slow phones the old
|
||||
// name stays visible for the duration of /resolve + connect.
|
||||
this.emit({ playing: false, loading: true, error: null });
|
||||
// Pre-warm AudioContext while we still have the user-gesture
|
||||
// credit. iOS will keep it running for the rest of the session.
|
||||
if (this.sync.enabled) {
|
||||
const Ctor = typeof window !== 'undefined' && (window.AudioContext || window.webkitAudioContext);
|
||||
if (Ctor) {
|
||||
let ctx = this.audio[CTX_KEY];
|
||||
if (!ctx) { try { ctx = new Ctor(); this.audio[CTX_KEY] = ctx; } catch { /* ignore */ } }
|
||||
if (ctx && ctx.state === 'suspended') ctx.resume().catch(() => { });
|
||||
}
|
||||
}
|
||||
let resolved, streamMeta;
|
||||
const ac = (typeof AbortController !== 'undefined') ? new AbortController() : null;
|
||||
this._playAbort = ac;
|
||||
try {
|
||||
const r = await api.post(`/api/stations/${station.id}/resolve`);
|
||||
const r = await api.post(`/api/stations/${station.id}/resolve`, null, ac ? { signal: ac.signal } : undefined);
|
||||
// The user may have picked another station while we were waiting.
|
||||
if (gen !== this._playGen) { logSync('play() stale-resolve discarded', { gen }); return; }
|
||||
resolved = r.resolved;
|
||||
streamMeta = r.stream || null;
|
||||
} catch (err) {
|
||||
if (isAbort(err) || gen !== this._playGen) return;
|
||||
this.emit({ playing: false, loading: false, error: err.message });
|
||||
return;
|
||||
}
|
||||
const url = resolved.url;
|
||||
this.streamId = streamMeta?.id ?? null;
|
||||
|
||||
// When room sync is active and the stream isn't HLS, route through the
|
||||
// same-origin proxy. That guarantees a CORS-clean MediaElementSource
|
||||
// on every platform (iOS Safari, Android, Firefox, plain Chrome) so
|
||||
// the DelayNode-based sync graph can actually be built. Otherwise
|
||||
// some browsers refuse to let WebAudio touch the cross-origin stream.
|
||||
const wantProxy = this.sync.enabled && resolved.format !== 'hls';
|
||||
this.usingProxy = wantProxy;
|
||||
let url = resolved.url;
|
||||
if (wantProxy && this.streamId != null) {
|
||||
url = `/api/stations/${station.id}/proxy?streamId=${this.streamId}`;
|
||||
}
|
||||
|
||||
// crossOrigin: not needed for the same-origin proxy. For direct
|
||||
// streams we only set it when running inside Electron (main process
|
||||
// rewrites CORS headers); regular browsers MUST leave it unset or
|
||||
// playback fails on Icecast/SHOUTcast servers.
|
||||
if (wantProxy) {
|
||||
this.audio.removeAttribute('crossorigin');
|
||||
} else if (typeof window !== 'undefined' && window.oradioNative?.isElectron) {
|
||||
this.audio.crossOrigin = 'anonymous';
|
||||
} else {
|
||||
this.audio.removeAttribute('crossorigin');
|
||||
}
|
||||
|
||||
if (resolved.format === 'hls') {
|
||||
if (Hls.isSupported()) {
|
||||
this.hls = new Hls({ enableWorker: true });
|
||||
@@ -77,7 +317,389 @@ export class Player {
|
||||
}
|
||||
} else {
|
||||
this.audio.src = url;
|
||||
// Pre-build the WebAudio graph BEFORE play resolves so the
|
||||
// DelayNode sits in the audio path from the very first decoded
|
||||
// sample. On iOS this matters: otherwise a few seconds of audio
|
||||
// play straight to the speakers before the graph is attached
|
||||
// and the client appears "seconds behind" the rest of the room.
|
||||
if (this.sync.enabled) this._ensureAudioGraph();
|
||||
this.audio.play().catch(() => { });
|
||||
}
|
||||
}
|
||||
|
||||
// -------- Cross-client stream sync --------
|
||||
|
||||
/**
|
||||
* Enable buffer-based output alignment. Every client routes audio
|
||||
* through a DelayNode set to `bufferMs` and uses the shared RoomClock
|
||||
* to keep the local decoder's pace locked to the server timeline via
|
||||
* micro-adjustments to `audio.playbackRate`. Net effect: every
|
||||
* speaker emits the live broadcast at the same wall-clock instant,
|
||||
* within network-jitter.
|
||||
*/
|
||||
enableSync({ clock, startedAt }) {
|
||||
const wasEnabled = this.sync.enabled;
|
||||
this.sync.enabled = true;
|
||||
this.sync.clock = clock;
|
||||
this.sync.startedAt = startedAt || null;
|
||||
this.sync.status = startedAt ? 'measuring' : 'no-anchor';
|
||||
if (!wasEnabled) this._resetSyncAnchor();
|
||||
if (!this.sync.timer) {
|
||||
this.sync.timer = setInterval(() => this._syncTick(), 500);
|
||||
}
|
||||
// If audio is already playing without a drift anchor (e.g. user
|
||||
// toggled sync on mid-stream), capture it now.
|
||||
if (!this.audio.paused && this.audio.readyState >= 2 && this.sync.t0Wall == null && this.sync.clock) {
|
||||
this.sync.t0Wall = this.sync.clock.now();
|
||||
this.sync.t0Audio = this.audio.currentTime;
|
||||
}
|
||||
this._ensureAudioGraph();
|
||||
this._syncTick();
|
||||
this._emitSync();
|
||||
}
|
||||
|
||||
updateSyncTarget(startedAt) {
|
||||
if (!this.sync.enabled) return;
|
||||
if (startedAt === this.sync.startedAt) return;
|
||||
this.sync.startedAt = startedAt || null;
|
||||
// A change in the room's started_at means a new playback session — drop
|
||||
// our anchor so we re-measure once audio resumes.
|
||||
this._resetSyncAnchor();
|
||||
// Reflect the truth: we're no longer aligned to anything until the
|
||||
// new stream actually starts. Without this the chip stays green
|
||||
// while the old audio is still playing.
|
||||
this.sync.status = startedAt ? 'measuring' : 'no-anchor';
|
||||
this._emitSync();
|
||||
this._syncTick();
|
||||
}
|
||||
|
||||
disableSync() {
|
||||
this.sync.enabled = false;
|
||||
this.sync.startedAt = null;
|
||||
this.sync.status = 'off';
|
||||
if (this.sync.timer) { clearInterval(this.sync.timer); this.sync.timer = null; }
|
||||
try { this.audio.playbackRate = 1.0; } catch { /* ignore */ }
|
||||
// Drain the DelayNode so we don't keep holding several seconds of
|
||||
// audio when the user switches back to solo mode.
|
||||
const delay = this.audio[DELAY_KEY];
|
||||
const ctx = this.audio[CTX_KEY];
|
||||
if (delay && ctx) {
|
||||
try {
|
||||
delay.delayTime.cancelScheduledValues(ctx.currentTime);
|
||||
delay.delayTime.linearRampToValueAtTime(0, ctx.currentTime + 0.2);
|
||||
} catch { /* not started yet */ }
|
||||
}
|
||||
this.sync.currentDelay = 0;
|
||||
this.sync.targetDelay = 0;
|
||||
this._resetSyncAnchor();
|
||||
this._emitSync();
|
||||
}
|
||||
|
||||
/** User-tunable buffer in ms. Larger = easier sync, longer to-start lag. */
|
||||
setSyncBufferMs(ms) {
|
||||
const clamped = Math.max(MIN_BUFFER_MS, Math.min(MAX_BUFFER_MS, Math.round(Number(ms) || 0)));
|
||||
if (clamped === this.sync.bufferMs) return clamped;
|
||||
this.sync.bufferMs = clamped;
|
||||
try { localStorage.setItem('oradio.syncBufferMs', String(clamped)); } catch { /* private mode */ }
|
||||
this._syncTick();
|
||||
this._emitSync();
|
||||
return clamped;
|
||||
}
|
||||
|
||||
getSyncBufferMs() { return this.sync.bufferMs; }
|
||||
|
||||
/** Web Audio handles for the visualiser to tap into. */
|
||||
getAudioContext() { return this.audio[CTX_KEY] || null; }
|
||||
getAnalyser() { return this.audio[ANALYSER_KEY] || null; }
|
||||
|
||||
_resetSyncAnchor() {
|
||||
this.sync.t0Wall = null;
|
||||
this.sync.t0Audio = null;
|
||||
this.sync.masterCT = null;
|
||||
this.sync.masterAt = null;
|
||||
this.sync.masterStationId = null;
|
||||
this.sync.pdtMs = null;
|
||||
this.sync.integral = 0;
|
||||
this.sync.anchorSource = null;
|
||||
this.sync.driftMs = 0;
|
||||
this.sync.rate = 1.0;
|
||||
try { this.audio.playbackRate = 1.0; } catch { /* ignore */ }
|
||||
this.sync.error = 0;
|
||||
this.sync.currentDelay = 0;
|
||||
this.sync.targetDelay = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Accept a `sync-pos` snapshot from the room's master display. Used as
|
||||
* the anchor for non-HLS streams (HLS prefers PROGRAM-DATE-TIME). Master
|
||||
* ignores its own broadcasts — the server forwards sync-pos with except=
|
||||
* sender so this only fires on peers.
|
||||
*/
|
||||
acceptMasterPos({ stationId, masterCT, atServerNow, bufferMs }) {
|
||||
if (!this.sync.enabled) return;
|
||||
if (!Number.isFinite(masterCT) || !Number.isFinite(atServerNow)) return;
|
||||
// Only trust positions for the station we believe we're playing —
|
||||
// otherwise a stale snapshot for the previous station would mis-anchor
|
||||
// immediately after a switch.
|
||||
const myId = this.station?.id ?? null;
|
||||
if (stationId != null && myId != null && stationId !== myId) return;
|
||||
this.sync.masterStationId = stationId ?? myId;
|
||||
this.sync.masterCT = masterCT;
|
||||
this.sync.masterAt = atServerNow;
|
||||
// Switching anchor source — drop integral so it doesn't blow up.
|
||||
this.sync.integral = 0;
|
||||
// Buffer must be the same on every client — otherwise the DelayNode
|
||||
// delays differ and speakers play the same content at different
|
||||
// wall-clock times. Master is the source of truth; peers adopt.
|
||||
if (Number.isFinite(bufferMs) && bufferMs !== this.sync.bufferMs) {
|
||||
this.sync.bufferMs = Math.max(MIN_BUFFER_MS, Math.min(MAX_BUFFER_MS, Math.round(bufferMs)));
|
||||
// Don't write to localStorage here — that pref is the user's
|
||||
// *requested* buffer and we want it back when they leave sync.
|
||||
this._emitSync();
|
||||
}
|
||||
logSync('master-pos', { stationId, masterCT, atServerNow, bufferMs });
|
||||
}
|
||||
|
||||
/**
|
||||
* Build (once per <audio>) the Web Audio chain:
|
||||
* MediaElementSource -> DelayNode -> AnalyserNode -> destination
|
||||
* The analyser is placed AFTER the delay so the spectrum it produces
|
||||
* matches the audio the user actually hears (the speakers emit the
|
||||
* delayed signal). Consequence: the visualiser is silent for `bufferMs`
|
||||
* after a station change while the delay refills with new audio —
|
||||
* that's correct, not a bug.
|
||||
* If the audio is cross-origin without CORS, `createMediaElementSource`
|
||||
* throws and we silently bail — sync degrades to 'no-buffer' in that
|
||||
* environment but plain playback continues.
|
||||
*/
|
||||
_ensureAudioGraph() {
|
||||
if (typeof window === 'undefined') return null;
|
||||
const Ctor = window.AudioContext || window.webkitAudioContext;
|
||||
if (!Ctor) return null;
|
||||
const a = this.audio;
|
||||
let ctx = a[CTX_KEY];
|
||||
if (!ctx) {
|
||||
try { ctx = new Ctor(); } catch { return null; }
|
||||
a[CTX_KEY] = ctx;
|
||||
// Apply any sink chosen before the context existed.
|
||||
if (this._sinkId && typeof ctx.setSinkId === 'function') {
|
||||
ctx.setSinkId(this._sinkId).catch((err) =>
|
||||
console.warn('[player] AudioContext.setSinkId on create failed', err));
|
||||
}
|
||||
}
|
||||
if (ctx.state === 'suspended') {
|
||||
ctx.resume().catch(() => { /* needs user gesture; the play click counts */ });
|
||||
}
|
||||
let src = a[SRC_KEY];
|
||||
if (!src) {
|
||||
try {
|
||||
src = ctx.createMediaElementSource(a);
|
||||
a[SRC_KEY] = src;
|
||||
} catch (err) {
|
||||
// CORS-tainted media — common in plain browsers on direct
|
||||
// Icecast streams. Sync via DelayNode is unavailable here.
|
||||
console.warn('[player] WebAudio unavailable (CORS):', err?.message || err);
|
||||
return null;
|
||||
}
|
||||
const analyser = ctx.createAnalyser();
|
||||
analyser.fftSize = 1024;
|
||||
analyser.smoothingTimeConstant = 0.8;
|
||||
const delay = ctx.createDelay(MAX_DELAY_SEC);
|
||||
delay.delayTime.value = 0;
|
||||
const gain = ctx.createGain();
|
||||
// Adopt the audio element's current volume, then pin the element
|
||||
// to 1 so volume control happens post-delay (immediate) instead
|
||||
// of pre-delay (queued behind `bufferMs` of audio).
|
||||
gain.gain.value = (this._logicalVolume ?? a.volume);
|
||||
a.volume = 1;
|
||||
src.connect(delay);
|
||||
delay.connect(analyser);
|
||||
analyser.connect(gain);
|
||||
gain.connect(ctx.destination);
|
||||
a[ANALYSER_KEY] = analyser;
|
||||
a[DELAY_KEY] = delay;
|
||||
a[GAIN_KEY] = gain;
|
||||
}
|
||||
return { ctx, src, analyser: a[ANALYSER_KEY], delay: a[DELAY_KEY], gain: a[GAIN_KEY] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the best available anchor for this tick and compute drift.
|
||||
*
|
||||
* Priority:
|
||||
* 1. HLS PROGRAM-DATE-TIME (absolute stream time) — converges any
|
||||
* number of clients to the same stream moment.
|
||||
* 2. Master `sync-pos` — projects master's audio.currentTime forward
|
||||
* using the shared room clock.
|
||||
* 3. Local first-frame anchor.
|
||||
*
|
||||
* Returns { source, driftSec, expectedAudio, pdtMs? } or null.
|
||||
*/
|
||||
_measureDrift() {
|
||||
const s = this.sync;
|
||||
const audio = this.audio;
|
||||
if (!s.clock || !s.clock.synced) return null;
|
||||
const clockNow = s.clock.now();
|
||||
const ct = audio.currentTime;
|
||||
|
||||
// 1) HLS PROGRAM-DATE-TIME. hls.js exposes `playingDate` once it has
|
||||
// parsed a fragment carrying PDT. Sync rule: at wall-clock T,
|
||||
// every client plays the stream moment PDT == (T - bufferMs).
|
||||
const pd = this.hls?.playingDate;
|
||||
if (pd && typeof pd.getTime === 'function') {
|
||||
const pdtMs = pd.getTime();
|
||||
if (Number.isFinite(pdtMs) && pdtMs > 0) {
|
||||
const expectedPdtMs = clockNow - s.bufferMs;
|
||||
const driftSec = (pdtMs - expectedPdtMs) / 1000;
|
||||
return {
|
||||
source: 'hls-pdt',
|
||||
driftSec,
|
||||
expectedAudio: ct - driftSec,
|
||||
pdtMs
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Master sync-pos. Project master's audio CT forward by elapsed
|
||||
// server time since the snapshot.
|
||||
const myId = this.station?.id ?? null;
|
||||
if (s.masterCT != null && s.masterAt != null
|
||||
&& (s.masterStationId == null || myId == null || s.masterStationId === myId)) {
|
||||
const expectedAudio = s.masterCT + (clockNow - s.masterAt) / 1000;
|
||||
const driftSec = ct - expectedAudio;
|
||||
return { source: 'master', driftSec, expectedAudio };
|
||||
}
|
||||
|
||||
// 3) Local first-frame anchor.
|
||||
if (s.t0Wall != null && s.t0Audio != null) {
|
||||
const expectedAudio = s.t0Audio + (clockNow - s.t0Wall) / 1000;
|
||||
const driftSec = ct - expectedAudio;
|
||||
return { source: 'local', driftSec, expectedAudio };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
_syncTick() {
|
||||
const s = this.sync;
|
||||
if (!s.enabled || !s.clock) return;
|
||||
if (!s.startedAt) {
|
||||
if (s.status !== 'no-anchor') { s.status = 'no-anchor'; this._emitSync(); }
|
||||
return;
|
||||
}
|
||||
if (this.audio.paused || this.audio.readyState < 2) return;
|
||||
// Hold 'measuring' until the clock has at least one good sample —
|
||||
// before then the offset is 0 and drift estimates would be junk.
|
||||
if (!s.clock.synced) {
|
||||
if (s.status !== 'measuring') { s.status = 'measuring'; this._emitSync(); }
|
||||
return;
|
||||
}
|
||||
|
||||
const graph = this._ensureAudioGraph();
|
||||
|
||||
// DelayNode hold = bufferMs for every client. Combined with the
|
||||
// rate-trim controller below, this aligns every speaker's output to
|
||||
// the same wall-clock instant on the shared timeline.
|
||||
const desiredSec = s.bufferMs / 1000;
|
||||
const clamped = Math.max(0, Math.min(MAX_DELAY_SEC, desiredSec));
|
||||
s.targetDelay = clamped;
|
||||
s.error = 0;
|
||||
|
||||
if (graph?.delay) {
|
||||
const cur = graph.delay.delayTime.value;
|
||||
if (Math.abs(cur - clamped) > 0.01) {
|
||||
try {
|
||||
const t = graph.ctx.currentTime;
|
||||
graph.delay.delayTime.cancelScheduledValues(t);
|
||||
graph.delay.delayTime.setValueAtTime(cur, t);
|
||||
graph.delay.delayTime.linearRampToValueAtTime(clamped, t + 0.25);
|
||||
} catch {
|
||||
graph.delay.delayTime.value = clamped;
|
||||
}
|
||||
s.currentDelay = clamped;
|
||||
}
|
||||
} else {
|
||||
s.currentDelay = 0;
|
||||
}
|
||||
|
||||
const measurement = this._measureDrift();
|
||||
if (!measurement) {
|
||||
// No usable anchor yet (e.g. local mode without a t0). Hold the
|
||||
// rate at 1.0 and wait for either HLS PDT, a master sync-pos, or
|
||||
// the audio's first decoded sample.
|
||||
return;
|
||||
}
|
||||
const driftSec = measurement.driftSec;
|
||||
s.driftMs = driftSec * 1000;
|
||||
if (measurement.source !== s.anchorSource) {
|
||||
// Anchor source changed (e.g. PDT became available) — integrator
|
||||
// is suspect across the transition; drop it.
|
||||
s.integral = 0;
|
||||
s.anchorSource = measurement.source;
|
||||
logSync('anchor source', { source: measurement.source });
|
||||
}
|
||||
if (measurement.pdtMs != null) s.pdtMs = measurement.pdtMs;
|
||||
|
||||
// Re-anchor escape hatch for catastrophic drift (suspended tab,
|
||||
// decoder stall): drop integral, reset local anchor, leave rate at
|
||||
// 1.0 for one tick. Only meaningful for the local-anchor path; PDT
|
||||
// and master-pos re-measure live so they self-correct.
|
||||
if (Math.abs(driftSec) > 1.0) {
|
||||
logSync('drift re-anchor', { source: measurement.source, driftSec });
|
||||
s.t0Wall = s.clock.now();
|
||||
s.t0Audio = this.audio.currentTime;
|
||||
s.integral = 0;
|
||||
try { this.audio.playbackRate = 1.0; s.rate = 1.0; } catch { /* ignore */ }
|
||||
} else {
|
||||
// PI controller. 10ms deadband (below that the measurement is
|
||||
// dominated by clock jitter). dt = tick interval = 0.5s.
|
||||
// - positive drift = audio ahead of where it should be → rate < 1
|
||||
// - negative drift = audio behind → rate > 1
|
||||
// ±1% rate cap: still inaudible for music, but a 200ms gap closes
|
||||
// in ~20s instead of 40s. Larger caps start to colour speech.
|
||||
const Kp = 0.05;
|
||||
const Ki = 0.003;
|
||||
const dt = 0.5;
|
||||
const RATE_CAP = 0.010;
|
||||
const usedErr = Math.abs(driftSec) < 0.010 ? 0 : driftSec;
|
||||
s.integral = Math.max(-1.0, Math.min(1.0, s.integral + usedErr * dt));
|
||||
let adj = -(Kp * usedErr + Ki * s.integral);
|
||||
adj = Math.max(-RATE_CAP, Math.min(RATE_CAP, adj));
|
||||
const rate = 1.0 + adj;
|
||||
if (Math.abs(rate - s.rate) > 0.0001) {
|
||||
try { this.audio.playbackRate = rate; s.rate = rate; }
|
||||
catch { /* some browsers refuse rate changes on streamed media */ }
|
||||
}
|
||||
}
|
||||
|
||||
let status;
|
||||
if (!graph?.delay) status = 'no-buffer';
|
||||
else if (Math.abs(driftSec) > 0.05) status = 'lagging';
|
||||
else status = 'in-sync';
|
||||
if (status !== s.status) {
|
||||
s.status = status;
|
||||
logSync('status', { status, source: measurement.source, driftMs: s.driftMs, delay: clamped, rate: s.rate });
|
||||
this._emitSync();
|
||||
}
|
||||
}
|
||||
|
||||
_emitSync() {
|
||||
if (this.onSyncChange) {
|
||||
this.onSyncChange({
|
||||
status: this.sync.status,
|
||||
error: this.sync.error,
|
||||
startedAt: this.sync.startedAt,
|
||||
bufferMs: this.sync.bufferMs,
|
||||
delay: this.sync.currentDelay,
|
||||
driftMs: this.sync.driftMs,
|
||||
rate: this.sync.rate,
|
||||
anchorSource: this.sync.anchorSource,
|
||||
pdtMs: this.sync.pdtMs,
|
||||
clockOffset: this.sync.clock?.offset ?? 0,
|
||||
clockRtt: this.sync.clock?.rtt ?? null,
|
||||
clockStd: this.sync.clock?.offsetStd ?? null,
|
||||
currentTime: this.audio.currentTime,
|
||||
paused: this.audio.paused
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user