radio / src /stream-engine.ts
tfrere's picture
tfrere HF Staff
Cursor
fix: stop the rain bed reopening its stream on every frame
179aaf6
Raw History Blame Contribute Delete
16.2 kB
/**
* Keeps one `<audio>` element playing one live stream, for as long as it can.
*
* The engine owns everything between "here is a URL" and "sound is coming out":
* transport choice, reconnection, and stall detection. It deliberately knows
* nothing about Web Audio, the robot, or the station ring, so it can be reused
* for any stream (the tuner and the ambient rain bed each get their own).
*
* Two transports:
*
* - `hls` β€” the URL is an HLS playlist. hls.js does the buffering, and it
* is far better at it than we could be: per-segment retries, gap
* jumping, and recovery across network changes. Roughly 10% of
* webradios expose an `.m3u8`, so this is the lucky path.
* - `native` β€” everything else (raw Icecast/Shoutcast MP3, AAC, Ogg). The
* browser's own media stack does the buffering; we only decide
* *when* to give up on the current connection and reopen it.
*
* The one thing that matters on the native path is patience. Tearing the
* element down calls `load()`, which throws away whatever the browser had
* buffered β€” so reacting to the first `error` means cutting the sound
* ourselves, before the outage was even audible. Instead we look at how much
* audio is still buffered ahead of the playhead and wait until it is nearly
* spent before reopening. Short outages become inaudible.
*
* CORS: `crossOrigin = "anonymous"` is required for the caller to route the
* element through Web Audio without tainting it.
*/
import type Hls from "hls.js";
export type StreamTransport = "hls" | "native";
export interface StreamEngineEvents {
/** Sound is actually coming out. */
onPlaying?: () => void;
/** Buffering: opening, stalled, reconnecting, or offline. */
onWaiting?: () => void;
/** Unrecoverable: gave up after exhausting the reconnect attempts. */
onError?: (message: string) => void;
}
export interface StreamEngineOptions {
/** Shown in console warnings, e.g. `"station"` or `"rain"`. */
label: string;
events?: StreamEngineEvents;
}
/** How long to wait for a fresh connection to produce data before failing it. */
const OPEN_TIMEOUT_MS = 18_000;
/** No progress for this long while "playing" means the connection is dead. */
const STALL_TIMEOUT_MS = 12_000;
/** Floor between two reconnect attempts, doubled on each failure. */
const RETRY_BASE_MS = 4_000;
const RETRY_MAX_MS = 16_000;
const MAX_ATTEMPTS = 4;
/**
* Reopen this long before the buffer would actually run dry, so the new
* connection has a chance to deliver its first frames during the overlap.
*/
const REOPEN_MARGIN_S = 1.5;
/** Give up on in-place hls.js recovery after this many fatal errors in a row. */
const MAX_HLS_RECOVERIES = 3;
export class StreamEngine {
private readonly events: StreamEngineEvents;
private readonly label: string;
private hls: Hls | null = null;
private hlsRecoveries = 0;
/** Canonical URL of the current stream (without any cache-bust suffix). */
private url: string | null = null;
private transport: StreamTransport = "native";
/** Bumped on every `open()` so stale async work can bail out. */
private generation = 0;
private opening = false;
/** True while we are tearing the element down, so its `error` is expected. */
private detaching = false;
private attempts = 0;
private lastAttemptAt = 0;
private retryTimer: number | null = null;
private lastMediaTime = 0;
private lastProgressAt = 0;
constructor(
private readonly el: HTMLAudioElement,
options: StreamEngineOptions,
) {
this.label = options.label;
this.events = options.events ?? {};
el.crossOrigin = "anonymous";
el.preload = "none";
el.addEventListener("playing", this.onElPlaying);
el.addEventListener("waiting", this.onElWaiting);
el.addEventListener("stalled", this.onElWaiting);
el.addEventListener("timeupdate", this.onElTimeUpdate);
el.addEventListener("ended", this.onElEnded);
el.addEventListener("error", this.onElError);
}
// ───────────────────────── public API ─────────────────────────
/** URL currently being played, or null when stopped. */
getUrl(): string | null {
return this.url;
}
/** Which transport the current stream ended up using. */
getTransport(): StreamTransport {
return this.transport;
}
/** True when the element has data and is not paused. */
isPlaying(): boolean {
const hasSource = !!(this.el.currentSrc || this.el.src);
return (
!this.el.error &&
hasSource &&
this.el.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA &&
!this.el.paused
);
}
/**
* True when the caller should call `open(url)`: different stream, or the
* current one is dead and the retry cooldown has elapsed. Lets a render loop
* poll this cheaply without spamming reconnects.
*/
needsReload(url: string): boolean {
if (this.opening) return false;
if (this.url !== url) return true;
if (this.isPlaying()) return false;
return performance.now() - this.lastAttemptAt >= RETRY_BASE_MS;
}
/**
* Switch to `url` and start playing. Pass `force` to reopen the same URL.
*
* Live mounts are single-consumer, so the stream is never prefetched: the
* connection is opened once, here, on demand.
*/
async open(url: string, force = false): Promise<void> {
// Re-entering for a stream we are already fetching would bump `generation`
// and make the in-flight attempt abandon itself, so a caller that polls
// this would restart the connection instead of letting it finish.
if (!force && this.url === url && (this.isPlaying() || this.opening)) return;
this.clearRetryTimer();
this.url = url;
this.transport = isHlsUrl(url) ? "hls" : "native";
const generation = ++this.generation;
this.events.onWaiting?.();
try {
await this.connect(url, generation);
} catch (firstError) {
if (generation !== this.generation) return;
// A stale connection on the mount is a common cause of an instant
// failure; a cache-busted URL usually gets a fresh one.
try {
await this.connect(cacheBust(url), generation);
} catch (secondError) {
if (generation !== this.generation) return;
this.url = null;
this.detach();
this.events.onError?.(describeError(secondError));
}
}
}
/** Stop playing and release the connection. */
close(): void {
this.generation++;
this.clearRetryTimer();
this.attempts = 0;
this.url = null;
// Bumping the generation above orphans any in-flight attempt, which then
// returns without clearing this β€” so clear it here, or the engine looks
// busy connecting forever and refuses to reopen.
this.opening = false;
this.detach();
}
/**
* One stall sweep. Safe to call at any cadence β€” the reconnect path has its
* own cooldown and attempt budget. Call it from a timer that keeps firing
* while the tab is hidden, since `timeupdate` alone is throttled there.
*/
checkHealth(): void {
if (!this.url || this.el.paused || this.detaching || this.el.error) return;
if (!navigator.onLine) {
this.events.onWaiting?.();
return;
}
if (performance.now() - this.lastProgressAt >= STALL_TIMEOUT_MS) {
this.scheduleReconnect("stall");
}
}
dispose(): void {
this.close();
this.el.removeEventListener("playing", this.onElPlaying);
this.el.removeEventListener("waiting", this.onElWaiting);
this.el.removeEventListener("stalled", this.onElWaiting);
this.el.removeEventListener("timeupdate", this.onElTimeUpdate);
this.el.removeEventListener("ended", this.onElEnded);
this.el.removeEventListener("error", this.onElError);
}
// ───────────────────────── element events ─────────────────────────
private readonly onElPlaying = (): void => {
this.attempts = 0;
this.hlsRecoveries = 0;
this.lastProgressAt = performance.now();
this.events.onPlaying?.();
};
private readonly onElWaiting = (): void => {
this.events.onWaiting?.();
};
private readonly onElTimeUpdate = (): void => {
if (this.el.currentTime === this.lastMediaTime) return;
this.lastMediaTime = this.el.currentTime;
this.lastProgressAt = performance.now();
};
private readonly onElEnded = (): void => {
this.scheduleReconnect("ended");
};
private readonly onElError = (): void => {
// hls.js reports its own failures; an element error under it is a symptom.
if (this.detaching || this.hls) return;
this.scheduleReconnect(mediaErrorLabel(this.el));
};
// ───────────────────────── connecting ─────────────────────────
private async connect(src: string, generation: number): Promise<void> {
this.opening = true;
try {
this.detach();
if (generation !== this.generation) return;
if (this.transport === "hls") {
await this.attachHls(src);
} else {
setNativeSource(this.el, src);
}
if (generation !== this.generation) return;
await waitForData(this.el, OPEN_TIMEOUT_MS);
if (generation !== this.generation) return;
await play(this.el);
this.lastProgressAt = performance.now();
} finally {
if (generation === this.generation) this.opening = false;
}
}
/**
* hls.js is loaded on demand so its ~108 kB (gzipped) only lands for the
* minority of stations that actually stream HLS. Falls back to the native
* transport when the platform plays HLS on its own (Safari).
*/
private async attachHls(src: string): Promise<void> {
const { default: Hls } = await import("hls.js/light");
if (!Hls.isSupported()) {
this.transport = "native";
setNativeSource(this.el, src);
return;
}
const hls = new Hls({
// This is a radio for a robot: nobody minds being half a minute behind
// the live edge, and depth of buffer is exactly what absorbs an outage.
// In practice this sits ~25 s ahead of the playhead on a 4 s-segment
// stream, so an outage shorter than that is never heard at all.
lowLatencyMode: false,
liveSyncDurationCount: 4,
maxBufferLength: 30,
// Bound memory growth on a stream that never ends.
backBufferLength: 30,
});
this.hls = hls;
hls.on(Hls.Events.ERROR, (_event, data) => {
if (!data.fatal) return;
if (this.hlsRecoveries >= MAX_HLS_RECOVERIES) {
this.scheduleReconnect(`hls ${data.details}`);
return;
}
this.hlsRecoveries++;
this.events.onWaiting?.();
if (data.type === Hls.ErrorTypes.NETWORK_ERROR) {
hls.startLoad();
} else if (data.type === Hls.ErrorTypes.MEDIA_ERROR) {
hls.recoverMediaError();
} else {
this.scheduleReconnect(`hls ${data.details}`);
}
});
hls.attachMedia(this.el);
hls.loadSource(src);
}
/** Drop the current connection so the next `open()` starts from scratch. */
private detach(): void {
this.detaching = true;
try {
this.hls?.destroy();
this.hls = null;
this.el.pause();
this.el.removeAttribute("src");
while (this.el.firstChild) this.el.removeChild(this.el.firstChild);
this.el.load();
} finally {
this.detaching = false;
}
}
// ───────────────────────── reconnecting ─────────────────────────
/**
* Queue a reconnect, timed so the buffered audio gets played out first.
*
* The delay is whichever is longer: the exponential backoff (so we don't
* hammer a mount that is down) or the audio still buffered ahead of the
* playhead (so a brief outage never becomes an audible gap).
*/
private scheduleReconnect(reason: string): void {
const url = this.url;
if (!url || this.detaching || this.retryTimer != null) return;
if (this.attempts >= MAX_ATTEMPTS) {
this.events.onError?.(`connection lost (${reason})`);
return;
}
const now = performance.now();
if (now - this.lastAttemptAt < RETRY_BASE_MS) return;
const backoffMs = Math.min(RETRY_MAX_MS, RETRY_BASE_MS * 2 ** this.attempts);
const bufferedMs = Math.max(0, bufferAhead(this.el) - REOPEN_MARGIN_S) * 1000;
const delay = Math.max(backoffMs, bufferedMs);
this.attempts++;
this.lastAttemptAt = now;
// Only announce buffering once the listener is about to hear it.
if (bufferedMs <= 0) this.events.onWaiting?.();
this.retryTimer = window.setTimeout(() => {
this.retryTimer = null;
if (this.url !== url) return;
console.warn(`[${this.label}] reconnect (${reason}), attempt ${this.attempts}`);
void this.open(url, true);
}, delay);
}
private clearRetryTimer(): void {
if (this.retryTimer == null) return;
window.clearTimeout(this.retryTimer);
this.retryTimer = null;
}
}
// ───────────────────────── helpers ─────────────────────────
function isHlsUrl(url: string): boolean {
const path = url.toLowerCase().split("?")[0] ?? "";
return path.endsWith(".m3u8");
}
/** Seconds of audio buffered ahead of the playhead. */
function bufferAhead(el: HTMLAudioElement): number {
const { buffered, currentTime } = el;
for (let i = 0; i < buffered.length; i++) {
if (currentTime >= buffered.start(i) && currentTime <= buffered.end(i)) {
return buffered.end(i) - currentTime;
}
}
return 0;
}
/**
* A `<source>` child rather than `el.src`: it lets the browser pick the MIME
* type from the response instead of guessing from the extension, which live
* mounts frequently lack.
*/
function setNativeSource(el: HTMLAudioElement, url: string): void {
el.removeAttribute("src");
while (el.firstChild) el.removeChild(el.firstChild);
const source = document.createElement("source");
source.src = url;
el.appendChild(source);
el.load();
}
function waitForData(el: HTMLAudioElement, timeoutMs: number): Promise<void> {
if (el.readyState >= HTMLMediaElement.HAVE_FUTURE_DATA) return Promise.resolve();
return new Promise((resolve, reject) => {
const cleanup = (): void => {
window.clearTimeout(timer);
el.removeEventListener("canplay", onReady);
el.removeEventListener("loadeddata", onReady);
el.removeEventListener("error", onFailed);
};
const onReady = (): void => {
cleanup();
resolve();
};
const onFailed = (): void => {
cleanup();
reject(new Error(mediaErrorLabel(el)));
};
const timer = window.setTimeout(() => {
cleanup();
reject(new Error("timeout"));
}, timeoutMs);
el.addEventListener("canplay", onReady);
el.addEventListener("loadeddata", onReady);
el.addEventListener("error", onFailed);
});
}
async function play(el: HTMLAudioElement): Promise<void> {
try {
await el.play();
} catch (err) {
// `play()` rejects when interrupted by a new load; only a still-paused
// element means it genuinely failed.
if (!el.paused) return;
throw err;
}
}
function cacheBust(url: string): string {
return `${url}${url.includes("?") ? "&" : "?"}_r=${Date.now()}`;
}
function describeError(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
function mediaErrorLabel(el: HTMLAudioElement): string {
switch (el.error?.code) {
case MediaError.MEDIA_ERR_ABORTED:
return "aborted";
case MediaError.MEDIA_ERR_NETWORK:
return "network";
case MediaError.MEDIA_ERR_DECODE:
return "decode";
case MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED:
return "not supported";
default:
return "unknown";
}
}