fixed API and stopping delay

This commit is contained in:
Marco Mooren
2026-05-27 12:54:56 +02:00
parent 470d4e8e76
commit 7b8d78ddaf
22 changed files with 495 additions and 98 deletions

View File

@@ -111,6 +111,38 @@ export class Player {
};
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
@@ -174,13 +206,18 @@ export class Player {
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;
// 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.
@@ -191,6 +228,29 @@ export class Player {
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); }
@@ -224,17 +284,60 @@ export class Player {
// 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) this.audio.play().catch(() => { });
else this.audio.pause();
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) {
@@ -253,14 +356,20 @@ export class Player {
// 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
// Pre-warm / resume AudioContext while we still have the user-gesture
// credit. iOS will keep it running for the rest of the session.
if (this.sync.enabled) {
// 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 (Ctor) {
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;
@@ -378,6 +487,13 @@ export class Player {
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.
@@ -462,12 +578,10 @@ export class Player {
/**
* 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.
* 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.
@@ -513,9 +627,9 @@ export class Player {
gain.gain.value = (this._logicalVolume ?? a.volume);
a.volume = 1;
src.connect(delay);
delay.connect(analyser);
analyser.connect(gain);
gain.connect(ctx.destination);
delay.connect(gain);
gain.connect(analyser);
analyser.connect(ctx.destination);
a[ANALYSER_KEY] = analyser;
a[DELAY_KEY] = delay;
a[GAIN_KEY] = gain;
@@ -606,7 +720,10 @@ export class Player {
if (graph?.delay) {
const cur = graph.delay.delayTime.value;
if (Math.abs(cur - clamped) > 0.01) {
// 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);