Files
radio-explorer/web/player.js
2026-05-27 12:54:56 +02:00

823 lines
38 KiB
JavaScript

import Hls from 'hls.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';
// 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 || (() => { });
// 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', () => {
// Restore output gain in case a prior pause/stop muted the
// post-DelayNode GainNode for instant silence.
if (this._outputMuted) {
// When sync is active the delay node was reset to 0 in stop().
// Keep the gain muted while the delay line pre-fills with
// bufferMs of the new station's audio, then atomically snap
// the delay to bufferMs and unmute. This gives clean silence
// during the buffer fill instead of the old "playing empty"
// sensation (gain open but only zeros coming through the delay).
if (this.sync.enabled && this.audio[DELAY_KEY] && this.sync.bufferMs > 0) {
clearTimeout(this._delayPrefillTimer);
const targetMs = this.sync.bufferMs;
this._delayPrefillTimer = setTimeout(() => {
this._delayPrefillTimer = null;
if (this.audio.paused || !this.sync.enabled) return;
const d = this.audio[DELAY_KEY];
const c = this.audio[CTX_KEY];
if (d && c) {
try {
d.delayTime.cancelScheduledValues(c.currentTime);
d.delayTime.value = targetMs / 1000;
} catch { /* ignore */ }
}
this.sync.currentDelay = targetMs / 1000;
this._setOutputMuted(false);
}, targetMs);
} else {
// Solo mode or no delay graph — unmute immediately so
// audio kicks in the moment the first sample is decoded.
this._setOutputMuted(false);
}
}
// 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;
const map = { 1: 'aborted', 2: 'network', 3: 'decode', 4: 'src not supported' };
const reason = map[code] || `code ${code}`;
console.warn('[player] audio error', reason, this.audio.currentSrc);
this.emit({ playing: false, loading: false, error: `stream error: ${reason}` });
});
}
emit(extra) {
this.onState({
stationId: this.station?.id ?? null,
stationName: this.station?.name ?? null,
genres: this.station?.genres || [],
volume: this._logicalVolume ?? this.audio.volume,
...extra
});
}
setVolume(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.
// When the output is currently muted (pause/stop) skip the
// hardware-gain write — _setOutputMuted owns the gain in that
// state and would otherwise un-mute the speaker mid-pause.
if (!this._outputMuted) {
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({});
}
// Cut (or restore) the post-DelayNode gain. Used by pause/stop so the
// user gets instant silence instead of waiting for the DelayNode's
// buffered samples (up to bufferMs) to drain out the speaker. Cheap
// no-op when the WebAudio graph hasn't been built (solo / no-sync
// path — there the audio element's own pause IS instant).
_setOutputMuted(muted) {
this._outputMuted = !!muted;
const gain = this.audio[GAIN_KEY];
const ctx = this.audio[CTX_KEY];
if (!gain || !ctx) return;
const target = muted ? 0 : (this._logicalVolume ?? 1);
try {
const t = ctx.currentTime;
gain.gain.cancelScheduledValues(t);
gain.gain.setValueAtTime(gain.gain.value, t);
// 30 ms ramp matches setVolume — feels instant, avoids the
// click that an abrupt setValueAtTime can produce.
gain.gain.linearRampToValueAtTime(target, t + 0.03);
} catch {
gain.gain.value = target;
}
}
/** 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; }
// Cancel any pending delay-prefill unmute so a timer from a previous
// station doesn't fire and unmute mid-switch.
clearTimeout(this._delayPrefillTimer);
this._delayPrefillTimer = null;
// Hard-cut the output gain immediately. We bypass the 30 ms ramp that
// _setOutputMuted uses (appropriate for pause/resume to avoid pops)
// and go straight to zero — we're about to suspend the AudioContext
// anyway, so the ramp would just be cancelled a quantum later.
this._outputMuted = true;
const gain = this.audio[GAIN_KEY];
const ctx = this.audio[CTX_KEY];
if (gain && ctx) {
try {
gain.gain.cancelScheduledValues(ctx.currentTime);
gain.gain.setValueAtTime(0, ctx.currentTime);
} catch { gain.gain.value = 0; }
} else if (gain) {
gain.gain.value = 0;
}
this.audio.pause();
this.audio.removeAttribute('src');
this.audio.load();
if (this.hls) { this.hls.destroy(); this.hls = null; }
// Reset the DelayNode to 0 so residual buffered audio doesn't linger.
const delay = this.audio[DELAY_KEY];
if (delay && ctx) {
try {
delay.delayTime.cancelScheduledValues(ctx.currentTime);
delay.delayTime.value = 0;
} catch { /* ignore */ }
}
// Suspend the AudioContext: immediately freezes all WebAudio rendering
// (delay drain, analyser updates, etc.). play() / _ensureAudioGraph()
// will resume it when the next station starts.
if (ctx) { try { ctx.suspend().catch(() => { }); } catch { /* ignore */ } }
this._resetSyncAnchor();
}
togglePause() {
if (!this.station) return;
if (this.audio.paused) {
// Un-mute first so the speaker is live when the new samples
// begin to flow through the DelayNode. (No-op if no graph.)
this._setOutputMuted(false);
this.audio.play().catch(() => { });
} else {
// Mute the post-DelayNode gain BEFORE pausing so the speaker
// goes silent immediately. Without this, the DelayNode keeps
// draining its buffered samples (up to bufferMs) out the
// speaker after audio.pause() — what the user perceives as
// "the stream kept playing after I clicked pause".
this._setOutputMuted(true);
this.audio.pause();
}
}
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;
// 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 / resume AudioContext while we still have the user-gesture
// credit. iOS will keep it running for the rest of the session.
// Also resumes a context that was suspended by stop() — required even
// in solo mode if a WebAudio graph was built during a previous sync
// session (audio is routed through the graph, not the raw element).
{
const Ctor = typeof window !== 'undefined' && (window.AudioContext || window.webkitAudioContext);
if (this.sync.enabled && 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(() => { });
} else {
const existingCtx = this.audio[CTX_KEY];
if (existingCtx && existingCtx.state === 'suspended') existingCtx.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`, 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;
}
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 });
this.hls.loadSource(url);
this.hls.attachMedia(this.audio);
this.hls.on(Hls.Events.MANIFEST_PARSED, () => this.audio.play().catch(() => { }));
} else if (this.audio.canPlayType('application/vnd.apple.mpegurl')) {
this.audio.src = url;
this.audio.play().catch(() => { });
} else {
this.emit({ playing: false, loading: false, error: 'HLS not supported' });
}
} 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; }
// If a prefill timer was waiting to unmute, cancel it and unmute now
// since we're leaving sync mode.
if (this._delayPrefillTimer) {
clearTimeout(this._delayPrefillTimer);
this._delayPrefillTimer = null;
if (this._outputMuted && !this.audio.paused) this._setOutputMuted(false);
}
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 -> GainNode -> AnalyserNode -> destination
* The analyser is placed AFTER the gain so the spectrum it produces
* is only active when audio is actually audible — it stays silent
* during the muted prefill period (gain=0) after a station change.
* 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(gain);
gain.connect(analyser);
analyser.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;
// While the delay-prefill timer is running (station just switched,
// delay was reset to 0), do not ramp the delay back up — the timer
// will snap it to the target value once the line has pre-filled.
if (Math.abs(cur - clamped) > 0.01 && !this._delayPrefillTimer) {
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
});
}
}
}