Download src/stream-engine.ts from tfrere/radio: direct link, hf CLI and curl.
- Browser
- Download file 16.2 kB
-
https://huggingface.co/spaces/tfrere/radio/resolve/main/src/stream-engine.ts
- Command line
-
hf download hf://spaces/tfrere/radio/src/stream-engine.ts
-
curl -L -o stream-engine.ts https://huggingface.co/spaces/tfrere/radio/resolve/main/src/stream-engine.ts
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"; | |
| } | |
| } | |