Monorepo for Aesthetic.Computer aesthetic.computer
4
fork

Configure Feed

Select the types of activity you want to include in your feed.

stream: fast-path no-CORS stations + lifecycle telemetry + chat commands

Three improvements to the radio mini-player (chat.mjs) and standalone
kpbj.mjs piece, all driven by the same BIOS stream:play handler.

1. Speed — per-station `cors` flag.
KPBJ's Icecast 2.4.4 never sends Access-Control-Allow-Origin, so the
crossOrigin="anonymous" probe always failed and we waited ~10s for the
fallback retry. Each station config now declares whether the server
supports CORS (r8dio via radio.co → true; KPBJ → false), and BIOS
skips the probe entirely when cors:false. Single round-trip
connection. The auto-fallback path still exists for unknown servers.

2. Telemetry — stream:state lifecycle events.
BIOS now listens to the <audio> element's loadstart, progress,
canplay, waiting, playing, stalled, and error events and forwards
each as `stream:state` with a named phase. The chat mini-player's
status line shows "connect…" → "buffer…" → "● LIVE" with animated
ellipsis so the user can see real progress instead of a static "…".
lib/radio.mjs exposes the same streamState on RadioState for the
standalone kpbj.mjs player, which now displays "Connecting",
"Buffering", "Reconnecting", etc.

3. Word-to-start — chat input commands.
Type `bj` / `r8dio` to switch to that station and start playing,
`radio` to toggle the active station, or `radio off` / `hush` /
`mute radio` / `radio stop` to pause. Commands are intercepted
before the chat-message send path, so they never leak into the
room. Switching stations pauses the previous one first.

+198 -12
+47 -6
system/public/aesthetic.computer/bios.mjs
··· 16539 16539 // 🎵 Streaming Audio (Radio, etc.) 16540 16540 // Start playing a streaming audio URL 16541 16541 if (type === "stream:play") { 16542 - const { id, url, volume } = content; 16542 + const { id, url, volume, cors } = content; 16543 16543 16544 16544 // Stop any existing stream with this id 16545 16545 if (streamAudio[id]) { ··· 16554 16554 // buffered data. Matches the approach used by kpbj.fm's own navbar player. 16555 16555 const bustedUrl = url + (url.includes("?") ? "&" : "?") + "t=" + Date.now(); 16556 16556 16557 - // Try with CORS first so we can hook up the analyser (for r8dio/radio.co 16558 - // which sends Access-Control-Allow-Origin). If the server doesn't support 16559 - // CORS (e.g. vanilla Icecast like stream.kpbj.fm), the browser blocks the 16560 - // load — retry once without crossOrigin so the stream still plays (sans 16561 - // frequency analyser; the visualizer falls back to a synthetic waveform). 16557 + // Per-station CORS policy: 16558 + // cors === false → Icecast/no-CORS server (e.g. stream.kpbj.fm); skip 16559 + // crossOrigin entirely so we connect in one round-trip (no analyser). 16560 + // cors !== false → default; try crossOrigin="anonymous" so we can hook 16561 + // up the frequency analyser (e.g. radio.co supports CORS). If the 16562 + // load is blocked, fall back to a no-CORS retry automatically. 16563 + const corsAllowed = cors !== false; 16564 + 16565 + // Wire audio lifecycle events to rich telemetry so the UI can show 16566 + // "Connecting…" → "Buffering…" → "● LIVE" with real detail. 16567 + const attachTelemetry = (audio) => { 16568 + const emitState = (state, extra) => 16569 + send({ 16570 + type: "stream:state", 16571 + content: { id, state, ...(extra || {}) }, 16572 + }); 16573 + audio.addEventListener("loadstart", () => emitState("connecting")); 16574 + audio.addEventListener("progress", () => emitState("loading")); 16575 + audio.addEventListener("canplay", () => emitState("ready")); 16576 + audio.addEventListener("waiting", () => emitState("buffering")); 16577 + audio.addEventListener("playing", () => emitState("playing")); 16578 + audio.addEventListener("stalled", () => emitState("stalled")); 16579 + audio.addEventListener("error", () => { 16580 + const err = audio.error; 16581 + emitState("error", { 16582 + code: err?.code, 16583 + message: err?.message || "audio error", 16584 + }); 16585 + }); 16586 + }; 16587 + 16562 16588 const tryPlay = (withCors) => { 16563 16589 const audio = new Audio(); 16564 16590 if (withCors) audio.crossOrigin = "anonymous"; 16565 16591 audio.src = bustedUrl; 16566 16592 audio.volume = baseVolume * masterVolume; 16593 + 16594 + attachTelemetry(audio); 16567 16595 16568 16596 const entry = { audio, baseVolume }; 16569 16597 ··· 16583 16611 } 16584 16612 16585 16613 streamAudio[id] = entry; 16614 + send({ type: "stream:state", content: { id, state: "connecting" } }); 16586 16615 16587 16616 return audio.play().then(() => { 16588 16617 send({ type: "stream:playing", content: { id } }); 16589 16618 }); 16590 16619 }; 16620 + 16621 + if (!corsAllowed) { 16622 + // Skip the CORS probe entirely for known non-CORS servers. 16623 + tryPlay(false).catch((err) => { 16624 + console.warn("🎵 Stream play failed:", err); 16625 + send({ 16626 + type: "stream:error", 16627 + content: { id, error: err.message }, 16628 + }); 16629 + }); 16630 + return; 16631 + } 16591 16632 16592 16633 tryPlay(true).catch((err) => { 16593 16634 // CORS rejection surfaces as MediaError code 4 / NotSupportedError.
+102 -1
system/public/aesthetic.computer/disks/chat.mjs
··· 263 263 label: "r8Dio", 264 264 streamUrl: "https://s3.radio.co/s7cd1ffe2f/listen", 265 265 streamId: "chat-r8dio-stream", 266 + // radio.co sends Access-Control-Allow-Origin, so we can use crossOrigin 267 + // and get a real frequency analyser for the visualizer bars. 268 + cors: true, 266 269 metadataUrl: "https://public.radio.co/stations/s7cd1ffe2f/status", 267 270 parseTrack: (data) => data?.current_track?.title || "", 268 271 labelBg: [35, 25, 18], ··· 295 298 label: "KPBJ", 296 299 streamUrl: "https://stream.kpbj.fm/", 297 300 streamId: "chat-kpbj-stream", 301 + // KPBJ runs vanilla Icecast 2.4.4 without CORS headers — skip crossOrigin 302 + // so we connect in one round-trip. Visualizer uses the synthetic waveform 303 + // fallback since we can't hook up createMediaElementSource. 304 + cors: false, 298 305 metadataUrl: "https://www.kpbj.fm/api/stream/metadata", 299 306 parseTrack: (data) => { 300 307 const source = data?.icestats?.source; ··· 335 342 let r8dioPlaying = false; 336 343 let r8dioLoading = false; 337 344 let r8dioError = null; 345 + let r8dioState = "idle"; // idle | connecting | loading | buffering | ready | playing | stalled | error 338 346 let r8dioVolume = 0.5; 339 347 let r8dioTrack = ""; // Current track/program title 340 348 let r8dioLastMetadataFetch = 0; ··· 559 567 send({ type: "keyboard:text:replace", content: { text: "" } }); 560 568 console.log("⌨️🔴 [chat.mjs] sending keyboard:close - reason: message sent"); 561 569 send({ type: "keyboard:close" }); 570 + 571 + // 📻 Radio command words — type `bj`, `r8dio`, `radio`, or `radio off` 572 + // to start/stop the mini-player without reaching for the button. 573 + const radioCmd = parseRadioCommand(text); 574 + if (radioCmd) { 575 + applyRadioCommand(radioCmd, send); 576 + notice(radioCmd.toast || "", ["orange", 0]); 577 + return; 578 + } 562 579 563 580 // Pieces inheriting chat.mjs (e.g. aa.mjs) may pass a custom submit 564 581 // handler so they can route the typed text somewhere other than the ··· 3762 3779 r8dioPlaying = true; 3763 3780 r8dioLoading = false; 3764 3781 r8dioError = null; 3782 + r8dioState = "playing"; 3765 3783 } 3766 3784 3767 3785 if (type === "stream:paused") { 3768 3786 r8dioPlaying = false; 3787 + r8dioState = "idle"; 3769 3788 } 3770 3789 3771 3790 if (type === "stream:stopped") { 3772 3791 r8dioPlaying = false; 3773 3792 r8dioLoading = false; 3793 + r8dioState = "idle"; 3774 3794 } 3775 3795 3776 3796 if (type === "stream:error") { 3777 3797 r8dioPlaying = false; 3778 3798 r8dioLoading = false; 3779 3799 r8dioError = content.error; 3800 + r8dioState = "error"; 3801 + } 3802 + 3803 + // Rich lifecycle telemetry: connecting → loading → buffering → playing 3804 + if (type === "stream:state") { 3805 + r8dioState = content.state; 3806 + if (content.state === "playing") { 3807 + r8dioPlaying = true; 3808 + r8dioLoading = false; 3809 + r8dioError = null; 3810 + } else if (content.state === "error") { 3811 + r8dioError = content.message || "stream error"; 3812 + r8dioLoading = false; 3813 + } else if ( 3814 + content.state === "connecting" || 3815 + content.state === "loading" || 3816 + content.state === "buffering" || 3817 + content.state === "stalled" 3818 + ) { 3819 + // Keep the loading spinner going for any pre-play state. 3820 + r8dioLoading = true; 3821 + r8dioError = null; 3822 + } 3780 3823 } 3781 3824 3782 3825 if (type === "stream:frequencies-data") { ··· 3831 3874 } else { 3832 3875 r8dioLoading = true; 3833 3876 r8dioError = null; 3877 + r8dioState = "connecting"; 3834 3878 send({ 3835 3879 type: "stream:play", 3836 3880 content: { 3837 3881 id: cfg.streamId, 3838 3882 url: cfg.streamUrl, 3839 3883 volume: r8dioVolume, 3884 + cors: cfg.cors !== false, // default true; false for Icecast/no-CORS 3840 3885 }, 3841 3886 }); 3887 + } 3888 + } 3889 + 3890 + // 📻 Parse a chat input string into a radio command, or null if it's not one. 3891 + // `bj` → switch to KPBJ and play 3892 + // `r8dio` → switch to r8dio and play 3893 + // `radio` → toggle the active station 3894 + // `radio off` / `hush` / `mute radio` → pause 3895 + function parseRadioCommand(text) { 3896 + const t = (text || "").trim().toLowerCase(); 3897 + if (!t) return null; 3898 + if (t === "radio off" || t === "hush" || t === "mute radio" || t === "radio stop") { 3899 + return { action: "stop", toast: "RADIO OFF" }; 3900 + } 3901 + if (t === "radio") return { action: "toggle", toast: "RADIO" }; 3902 + if (RADIO_STATIONS[t]) { 3903 + return { action: "play", station: t, toast: RADIO_STATIONS[t].label.toUpperCase() }; 3904 + } 3905 + return null; 3906 + } 3907 + 3908 + // 📻 Apply a parsed radio command. 3909 + function applyRadioCommand(cmd, send) { 3910 + if (cmd.action === "stop") { 3911 + if (r8dioPlaying || r8dioLoading) { 3912 + send({ type: "stream:pause", content: { id: radioConfig().streamId } }); 3913 + } 3914 + return; 3915 + } 3916 + if (cmd.action === "toggle") { 3917 + toggleR8dioPlayback(send); 3918 + return; 3919 + } 3920 + if (cmd.action === "play" && cmd.station) { 3921 + // Switching stations? Pause the current one first so there's no overlap. 3922 + if (r8dioPlaying && activeRadioStation !== cmd.station) { 3923 + send({ type: "stream:pause", content: { id: radioConfig().streamId } }); 3924 + r8dioPlaying = false; 3925 + } 3926 + activeRadioStation = cmd.station; 3927 + if (!r8dioPlaying) toggleR8dioPlayback(send); 3842 3928 } 3843 3929 } 3844 3930 ··· 4957 5043 if (r8dioError) { 4958 5044 ink(255, 100, 100).write("err", { x: statusX, y: tickerY }, undefined, undefined, false, "MatrixChunky8"); 4959 5045 } else if (r8dioLoading) { 4960 - ink(...cfg.statusColor).write("...", { x: statusX, y: tickerY }, undefined, undefined, false, "MatrixChunky8"); 5046 + // Rich lifecycle labels so the user can see the stream actually making 5047 + // progress (connecting → buffering → ready), not just a static "...". 5048 + const dots = ellipsisTicker?.text?.(help?.repeat || 0) || ""; 5049 + let label; 5050 + switch (r8dioState) { 5051 + case "connecting": label = "connect" + dots; break; 5052 + case "loading": label = "load" + dots; break; 5053 + case "buffering": label = "buffer" + dots; break; 5054 + case "stalled": label = "stall" + dots; break; 5055 + case "ready": label = "ready" + dots; break; 5056 + default: label = "..." + dots; break; 5057 + } 5058 + // Truncate to fit 5059 + const maxLen = Math.max(1, Math.floor((statusEndX - statusX) / tickerCharWidth)); 5060 + if (label.length > maxLen) label = label.substring(0, maxLen); 5061 + ink(...cfg.statusColor).write(label, { x: statusX, y: tickerY }, undefined, undefined, false, "MatrixChunky8"); 4961 5062 } else if (r8dioPlaying) { 4962 5063 const maxLen = Math.floor((statusEndX - statusX) / tickerCharWidth); 4963 5064 const text = r8dioTrack
+15 -2
system/public/aesthetic.computer/disks/kpbj.mjs
··· 29 29 const CONFIG = { 30 30 streamUrl: "https://stream.kpbj.fm/", 31 31 streamId: "kpbj-stream", 32 + // Vanilla Icecast 2.4.4 — no Access-Control-Allow-Origin. Skip crossOrigin 33 + // so the stream connects in one round-trip (no analyser; visualizer uses 34 + // the synthetic waveform fallback). 35 + cors: false, 32 36 metadataUrl: "https://www.kpbj.fm/api/stream/metadata", 33 37 playoutNowUrl: "https://kpbj.fm/api/playout/now", 34 38 playoutFallbackUrl: "https://kpbj.fm/api/playout/fallback", ··· 345 349 const websiteColor = isWebsiteHovered ? [255, 220, 170] : THEME.qrLabel; 346 350 ink(...websiteColor).write(websiteText, { x: websiteLinkX, y: websiteLinkY }, undefined, undefined, false, "MatrixChunky8"); 347 351 348 - // 3. Status (● LIVE / Paused / Connecting...) 352 + // 3. Status (● LIVE / Paused / Connecting.../Buffering.../etc.) 349 353 let statusText, statusColor; 354 + const dots = ".".repeat(Math.floor(help.repeat / 20) % 4); 350 355 if (state.loadError) { 351 356 statusText = "Connection error"; 352 357 statusColor = THEME.statusError; 353 358 } else if (state.isLoading) { 354 - statusText = "Connecting" + ".".repeat(Math.floor(help.repeat / 20) % 4); 359 + // Use streamState for richer per-phase telemetry when available. 360 + switch (state.streamState) { 361 + case "connecting": statusText = "Connecting" + dots; break; 362 + case "loading": statusText = "Loading stream" + dots; break; 363 + case "buffering": statusText = "Buffering" + dots; break; 364 + case "stalled": statusText = "Reconnecting" + dots; break; 365 + case "ready": statusText = "Ready" + dots; break; 366 + default: statusText = "Connecting" + dots; break; 367 + } 355 368 statusColor = THEME.statusLoading; 356 369 } else if (state.isPlaying) { 357 370 statusText = "● LIVE";
+34 -3
system/public/aesthetic.computer/lib/radio.mjs
··· 14 14 // Config 15 15 streamUrl: config.streamUrl, 16 16 streamId: config.streamId, 17 + // cors: false → skip crossOrigin on the <audio> element (for Icecast 18 + // servers without Access-Control-Allow-Origin, e.g. stream.kpbj.fm). The 19 + // visualizer falls back to a synthetic waveform instead of the analyser. 20 + cors: config.cors !== false, 17 21 metadataUrl: config.metadataUrl || null, 18 22 qrUrl: config.qrUrl, 19 23 qrLabel: config.qrLabel || config.qrUrl.replace("https://", ""), ··· 24 28 isPlaying: false, 25 29 isLoading: false, 26 30 loadError: null, 31 + streamState: "idle", // idle | connecting | loading | buffering | ready | playing | stalled | error 27 32 volume: 0.5, 28 33 29 34 // Visualization ··· 156 161 id: state.streamId, 157 162 url: state.streamUrl, 158 163 volume: state.volume, 164 + cors: state.cors, 159 165 }, 160 166 }); 161 167 } ··· 194 200 state.isPlaying = true; 195 201 state.isLoading = false; 196 202 state.loadError = null; 203 + state.streamState = "playing"; 197 204 } 198 - 205 + 199 206 if (type === "stream:paused" && content.id === state.streamId) { 200 207 state.isPlaying = false; 208 + state.streamState = "idle"; 201 209 } 202 - 210 + 203 211 if (type === "stream:stopped" && content.id === state.streamId) { 204 212 state.isPlaying = false; 205 213 state.isLoading = false; 214 + state.streamState = "idle"; 206 215 } 207 - 216 + 208 217 if (type === "stream:error" && content.id === state.streamId) { 209 218 state.isPlaying = false; 210 219 state.isLoading = false; 211 220 state.loadError = content.error; 221 + state.streamState = "error"; 222 + } 223 + 224 + // Lifecycle telemetry (connecting → loading → buffering → ready → playing). 225 + if (type === "stream:state" && content.id === state.streamId) { 226 + state.streamState = content.state; 227 + if (content.state === "playing") { 228 + state.isPlaying = true; 229 + state.isLoading = false; 230 + state.loadError = null; 231 + } else if (content.state === "error") { 232 + state.loadError = content.message || "stream error"; 233 + state.isLoading = false; 234 + } else if ( 235 + content.state === "connecting" || 236 + content.state === "loading" || 237 + content.state === "buffering" || 238 + content.state === "stalled" 239 + ) { 240 + state.isLoading = true; 241 + state.loadError = null; 242 + } 212 243 } 213 244 214 245 if (type === "stream:frequencies-data" && content.id === state.streamId) {