Airshift is personal web radio in the browser. I wrote about the product separately. This post is the architecture: what runs where, why it is not Phoenix, and the seams that keep a YouTube hour, a live Icecast mount, and a talking DJ in the same tab.

Why not LiveView

OnRotation is Phoenix LiveView. Migrainular is a Svelte PWA talking to a Phoenix API, because a migraine log has to save with no network.

Airshift is a third shape. Playback is client-owned. YouTube’s IFrame API, HTML5 audio for live mounts, Web Speech or Kokoro, Media Session, and a service worker all want to live next to the board. A LiveView socket cannot own that hour. Fly can sleep; the tab cannot.

So the app is a Vite + TypeScript SPA with vanilla DOM—no React, no Svelte. One main.ts drives the board. CSS is a single file with station themes as variables (Studio, Midnight, After Hours, and the rest). Tests are node:test with --experimental-strip-types.

Tech stack

  • TypeScript ~7 and Vite 8
  • vite-plugin-pwa (Workbox, Add to Home Screen)
  • YouTube IFrame API for format hours
  • HTML5 <audio> for The Wire and OC ReMix
  • kokoro-js (Kokoro-82M ONNX in the tab) or the browser voice
  • Node http server (scripts/serve.mjs) for dist/ plus a sidecar
  • Fly.io (iad, shared-cpu-1x, 256 MB, scale-to-zero, force HTTPS)
  • Simple Analytics for pageviews and station_play (no cookies, no song titles)
  • Open-Meteo, Wikipedia, Genius (token on the server), RSS news

youtubei.js is a dev dependency for playlist-URL expand and lookup-ids. It is not on the Fly image.

Two transports

player.ts exposes one Transport: play, pause, seek, volume, times. Behind it:

  1. YouTube — load the IFrame API, loadVideoById, skip on error (uploads that refuse to embed). The studio monitor has to stay visible; that is a YouTube constraint, not a UI flourish.
  2. Audioaudio-player.ts wraps a single <audio> element. Live mounts get mirror URLs, a stall watchdog (~15s without a progress beat), and failover. Breaks duck volume instead of calling pause, so Icecast does not drop.

playSourceOf picks youtube vs audio from the track. The Wire and OC ReMix are kind: "audio" stations: channel strips, not song crates. You pick KEXP or Rainwave Chill and stay there. Now-playing for Rainwave and Nightwave Plaza is polled over CORS; no auth.

The clock

Stations live in JSON (stations.json, deep-cuts.json) plus a couple of code-defined strips (wire.ts, ocremix.ts). A track is a title, artist, optional year, and either a videoId or an audioUrl.

clock.ts is the crate logic:

  • Subcrates on Rock (Classic, Grunge, Britpop, …) so one frequency can still park on a flavor
  • Library — hits, deep cuts, or mix
  • Order — shuffle or in-order
  • Don’t-repeat — next YouTube hour avoids the last 8 / 12 / 16 on that station
  • Legacy tuner ids migrated into Rock subcrates via localStorage

Yours 104.7 is a station whose crate you paste in (video links or JSON). Expanding a YouTube Music playlist URL needs youtubei.js. That stays off Fly on purpose.

The DJ

Liners are functions, not a prompt: station ID, frontsell, backsell, skip, time check, weather, news, a Genius or wiki fact. Copy is packed into baked-liners.json as WAV so the first ID does not wait on the model. npm run bake-liners rebuilds that after copy changes.

Kokoro runs in the page (onnx-community/Kokoro-82M-v1.0-ONNX). CPU/WASM is the stable default; WebGPU is faster on some machines and garbage on others. The voice files download from Hugging Face once and cache in the browser. Uncached lines (facts, weather, news) generate during the song, then play at the break. If the model fails, Web Speech takes over and the hour continues.

Duck the bed, talk, restore. Mute is a setting. Sleep timer (sleep.ts) fades gain into Standby.

Sidecar, not a backend

Production is still a static SPA. serve.mjs maps files from dist/, SPA-fallbacks unknown paths, and hands a few routes to sidecar.mjs:

  • GET /airshift/news — allowlisted feeds (NPR / BBC / NPR Music / local place)
  • GET /airshift/genius — token from Fly secrets
  • POST /airshift/feedback — in-app note to the booth
  • POST /airshift/error — client/server errors
  • GET /airshift/playlist404 on Fly

The public VM is not an open proxy. Rate limit is 40 requests / minute / IP. Genius never ships the token to the tab. If news or Genius is down, the hour continues without that line.

PWA and the car

Workbox precaches the shell, skips WASM/ONNX blobs (those are huge), and treats /airshift/* as NetworkOnly. display: standalone, Apple meta tags, Media Session for lock-screen skip/pause. Chrome can Add to Home Screen from the HTTPS origin; iPhone is Share → Add to Home Screen.

Fly config is boring on purpose: auto_stop_machines = "stop", min_machines_running = 0. The first hit after sleep pays a wake; then you are in the tab and the VM can nap again.

What stays out of the tab

  • Genius token
  • Discord for feedback and crash notes (no crate)
  • Playlist-URL expand (not on the public origin)

Analytics is Simple Analytics with Do Not Track respected. sa_event("station_play", { station }) is the only custom event. Song titles stay off the wire.

Still open: home

The board wants to live on the LAN too: always on, paste a playlist URL, maybe a custom RSS feed. The constraints fight each other. Fly cannot grow an open proxy. A self-signed cert on a home box breaks YouTube embeds. Plain HTTP on the LAN works for embeds and dies the moment you want a real PWA, geolocation, or Kokoro on a phone that is not on that Wi‑Fi.

There is Docker and a playlist flag in the repo. I would not call that a product yet. Next is a home origin that is trusted enough for YouTube and private enough for expand—without teaching the Fly VM to fetch arbitrary lists.

Summary

Airshift is a client radio with a tiny same-origin helper:

  • Vite TypeScript SPA, vanilla DOM, CSS variables for the board
  • YouTube iframe hours and HTML5 live mounts behind one transport
  • Crate logic in clock.ts, liners in liners.ts, speech in speech.ts
  • Kokoro in-tab with baked WAVs and a browser-voice fallback
  • Fly for HTTPS / PWA; home still TBD

The lesson I keep relearning: put the process where the constraint is. For a meal plan, that is the server. For a diary during an attack, that is IndexedDB. For a station you leave on, that is the tab.

Try Airshift at airshift.fly.dev — pick a frequency and hit On Air.