fixed API and stopping delay
This commit is contained in:
161
web/player.js
161
web/player.js
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user