823 lines
38 KiB
JavaScript
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
|
|
});
|
|
}
|
|
}
|
|
}
|