Download test/stream-engine.html from tfrere/radio: direct link, hf CLI and curl.
- Browser
- Download file 10.6 kB
-
https://huggingface.co/spaces/tfrere/radio/resolve/main/test/stream-engine.html
- Command line
-
hf download hf://spaces/tfrere/radio/test/stream-engine.html
-
curl -L -o stream-engine.html https://huggingface.co/spaces/tfrere/radio/resolve/main/test/stream-engine.html
10.6 kB
| <html lang="en"> | |
| <head> | |
| <meta charset="utf-8" /> | |
| <meta name="viewport" content="width=device-width, initial-scale=1" /> | |
| <title>StreamEngine bench</title> | |
| <style> | |
| :root { | |
| color-scheme: dark; | |
| --bg: #12131a; | |
| --card: #1b1d27; | |
| --line: #2c2f3d; | |
| --text: #e6e8f0; | |
| --dim: #8b90a5; | |
| --ok: #4ade80; | |
| --warn: #fbbf24; | |
| --bad: #f87171; | |
| } | |
| * { box-sizing: border-box; } | |
| body { | |
| margin: 0; | |
| padding: 24px; | |
| background: var(--bg); | |
| color: var(--text); | |
| font: 14px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; | |
| } | |
| h1 { font-size: 17px; margin: 0 0 4px; } | |
| p.lede { color: var(--dim); margin: 0 0 20px; max-width: 70ch; } | |
| .controls { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 20px; } | |
| input, button, select { | |
| font: inherit; | |
| padding: 7px 11px; | |
| border-radius: 6px; | |
| border: 1px solid var(--line); | |
| background: var(--card); | |
| color: var(--text); | |
| } | |
| input { min-width: 340px; flex: 1; } | |
| button { cursor: pointer; } | |
| button:hover { border-color: var(--dim); } | |
| button.primary { background: #2f3550; border-color: #3d456b; } | |
| .grid { display: grid; gap: 14px; grid-template-columns: repeat(auto-fill, minmax(390px, 1fr)); } | |
| .card { background: var(--card); border: 1px solid var(--line); border-radius: 10px; padding: 14px; } | |
| .card header { display: flex; align-items: baseline; gap: 8px; margin-bottom: 10px; } | |
| .card h2 { font-size: 13px; margin: 0; flex: 1; overflow-wrap: anywhere; } | |
| .tag { font-size: 11px; padding: 2px 7px; border-radius: 999px; border: 1px solid var(--line); color: var(--dim); } | |
| .stats { display: grid; grid-template-columns: repeat(2, 1fr); gap: 6px 14px; margin-bottom: 10px; } | |
| .stat { display: flex; justify-content: space-between; gap: 8px; } | |
| .stat span:first-child { color: var(--dim); } | |
| .state-playing { color: var(--ok); } | |
| .state-waiting { color: var(--warn); } | |
| .state-error { color: var(--bad); } | |
| .meter { height: 4px; border-radius: 2px; background: #0d0e14; overflow: hidden; margin-bottom: 10px; } | |
| .meter i { display: block; height: 100%; background: var(--ok); width: 0; transition: width 90ms linear; } | |
| .log { max-height: 150px; overflow: auto; font-size: 12px; color: var(--dim); border-top: 1px solid var(--line); padding-top: 8px; } | |
| .log div { white-space: pre; } | |
| .log .gap { color: var(--bad); } | |
| </style> | |
| </head> | |
| <body> | |
| <h1>StreamEngine bench</h1> | |
| <p class="lede"> | |
| Measures how long the sound actually stops when a stream drops. Add one engine per URL, | |
| then cut the network (DevTools → Network → Offline, or toggle Wi-Fi) and read the | |
| gap durations. Silence is detected on the decoded output, so it counts what you would hear, | |
| not what the network did. Point two engines at the same station over different transports | |
| (the HLS and Icecast presets below are both FIP) to compare them under the same outage. | |
| </p> | |
| <div class="controls"> | |
| <select id="preset"> | |
| <option value="https://stream.radiofrance.fr/fip/fip_hifi.m3u8">FIP (HLS)</option> | |
| <option value="https://icecast.radiofrance.fr/fip-hifi.aac">FIP (Icecast AAC)</option> | |
| <option value="https://streams.radiomast.io/nts2">NTS 2 (Icecast MP3)</option> | |
| <option value="https://stream.radioparadise.com/aac-320">Radio Paradise (Icecast AAC)</option> | |
| <option value="https://orf-live.ors-shoutcast.at/fm4-q2a">FM4 (Icecast MP3)</option> | |
| <option value="https://play.streamafrica.net/lofiradio">Lofi Radio (Icecast MP3)</option> | |
| </select> | |
| <input id="url" placeholder="stream URL" /> | |
| <button class="primary" id="add">Add engine</button> | |
| <button id="stop-all">Stop all</button> | |
| </div> | |
| <div class="grid" id="grid"></div> | |
| <script type="module"> | |
| import { StreamEngine } from "../src/stream-engine.ts"; | |
| /** Output below this RMS for longer than SILENCE_HOLD_MS counts as a gap. */ | |
| const SILENCE_RMS = 0.004; | |
| const SILENCE_HOLD_MS = 250; | |
| const presetEl = document.getElementById("preset"); | |
| const urlEl = document.getElementById("url"); | |
| const gridEl = document.getElementById("grid"); | |
| urlEl.value = presetEl.value; | |
| presetEl.addEventListener("change", () => (urlEl.value = presetEl.value)); | |
| /** Created on the first click so the autoplay policy is satisfied. */ | |
| let ctx = null; | |
| const benches = []; | |
| document.getElementById("add").addEventListener("click", () => { | |
| const url = urlEl.value.trim(); | |
| if (url) benches.push(createBench(url)); | |
| }); | |
| document.getElementById("stop-all").addEventListener("click", () => { | |
| for (const bench of benches) bench.engine.close(); | |
| }); | |
| function createBench(url) { | |
| ctx ??= new AudioContext(); | |
| const el = new Audio(); | |
| const analyser = ctx.createAnalyser(); | |
| analyser.fftSize = 1024; | |
| ctx.createMediaElementSource(el).connect(analyser).connect(ctx.destination); | |
| const ui = renderCard(url); | |
| const engine = new StreamEngine(el, { | |
| label: hostOf(url), | |
| events: { | |
| onPlaying: () => ui.setState("playing"), | |
| onWaiting: () => ui.setState("waiting"), | |
| onError: (message) => ui.setState("error", message), | |
| }, | |
| }); | |
| void ctx.resume(); | |
| void engine.open(url); | |
| const bench = { engine, el, analyser, ui, silentSince: null, everPlayed: false, gaps: [] }; | |
| ui.onStop = () => engine.close(); | |
| ui.onReopen = () => void engine.open(url, true); | |
| return bench; | |
| } | |
| // One loop drives every card: read the output level, track silence, refresh stats. | |
| const samples = new Float32Array(1024); | |
| setInterval(() => { | |
| const now = performance.now(); | |
| for (const bench of benches) { | |
| bench.analyser.getFloatTimeDomainData(samples); | |
| let sumSquares = 0; | |
| for (const sample of samples) sumSquares += sample * sample; | |
| const rms = Math.sqrt(sumSquares / samples.length); | |
| if (rms < SILENCE_RMS) { | |
| bench.silentSince ??= now; | |
| } else if (bench.silentSince !== null) { | |
| const duration = now - bench.silentSince; | |
| bench.silentSince = null; | |
| if (duration < SILENCE_HOLD_MS) { | |
| // Sub-threshold flicker, or just a quiet passage in the music. | |
| } else if (!bench.everPlayed) { | |
| // The very first silence is the initial connect, not a gap. | |
| bench.everPlayed = true; | |
| bench.ui.log(`first sound after ${(duration / 1000).toFixed(1)}s`); | |
| } else { | |
| bench.gaps.push(duration); | |
| bench.ui.logGap(duration); | |
| } | |
| } | |
| const ongoing = bench.silentSince === null ? 0 : now - bench.silentSince; | |
| bench.ui.update({ | |
| transport: bench.engine.getTransport(), | |
| level: rms, | |
| buffered: bufferAhead(bench.el), | |
| silence: ongoing >= SILENCE_HOLD_MS ? ongoing : 0, | |
| worstGap: bench.gaps.length ? Math.max(...bench.gaps) : 0, | |
| }); | |
| } | |
| }, 100); | |
| function bufferAhead(el) { | |
| 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; | |
| } | |
| function hostOf(url) { | |
| try { | |
| return new URL(url).hostname; | |
| } catch { | |
| return url; | |
| } | |
| } | |
| function renderCard(url) { | |
| const card = document.createElement("section"); | |
| card.className = "card"; | |
| card.innerHTML = ` | |
| <header> | |
| <h2>${hostOf(url)}</h2> | |
| <span class="tag" data-transport>…</span> | |
| <span class="tag" data-state>opening</span> | |
| </header> | |
| <div class="meter"><i data-level></i></div> | |
| <div class="stats"> | |
| <div class="stat"><span>buffer ahead</span><b data-buffered>–</b></div> | |
| <div class="stat"><span>silent now</span><b data-silence>–</b></div> | |
| <div class="stat"><span>gaps</span><b data-count>0</b></div> | |
| <div class="stat"><span>worst gap</span><b data-worst>–</b></div> | |
| </div> | |
| <div class="controls"> | |
| <button data-reopen>Force reopen</button> | |
| <button data-stop>Stop</button> | |
| </div> | |
| <div class="log" data-log></div> | |
| `; | |
| gridEl.appendChild(card); | |
| const find = (name) => card.querySelector(`[data-${name}]`); | |
| const logEl = find("log"); | |
| const stateEl = find("state"); | |
| let gapCount = 0; | |
| const api = { | |
| onStop: () => {}, | |
| onReopen: () => {}, | |
| setState(state, detail = "") { | |
| stateEl.textContent = state; | |
| stateEl.className = `tag state-${state}`; | |
| if (detail) api.log(detail); | |
| }, | |
| log(message) { | |
| const line = document.createElement("div"); | |
| line.textContent = `${clock()} ${message}`; | |
| logEl.prepend(line); | |
| }, | |
| logGap(ms) { | |
| gapCount++; | |
| find("count").textContent = String(gapCount); | |
| const line = document.createElement("div"); | |
| line.className = "gap"; | |
| line.textContent = `${clock()} gap ${(ms / 1000).toFixed(2)}s`; | |
| logEl.prepend(line); | |
| }, | |
| update({ transport, level, buffered, silence, worstGap }) { | |
| find("transport").textContent = transport; | |
| find("level").style.width = `${Math.min(100, level * 600)}%`; | |
| find("buffered").textContent = `${buffered.toFixed(1)}s`; | |
| find("silence").textContent = silence ? `${(silence / 1000).toFixed(1)}s` : "–"; | |
| find("worst").textContent = worstGap ? `${(worstGap / 1000).toFixed(2)}s` : "–"; | |
| }, | |
| }; | |
| find("stop").addEventListener("click", () => api.onStop()); | |
| find("reopen").addEventListener("click", () => api.onReopen()); | |
| return api; | |
| } | |
| function clock() { | |
| return new Date().toLocaleTimeString("en-GB"); | |
| } | |
| </script> | |
| </body> | |
| </html> | |