Airshift is personal web radio in the tab. The crate is the hour. The product is the space in between the music — the ID, the backsell, the duck under the bed. I wrote about the product and how it is built. This is the week after: the board was hitching when the DJ got ready, and that is the opposite of a station.

The fake visualizer is supposed to bounce the whole time. It looks like Winamp. Pausing it to “save” a YouTube hour was a non-starter. The work was: keep the bars moving, keep the buttons snappy, and still have a break ready when the song ends.

The freeze

Kokoro runs in the page, ONNX, same origin as the iframe. CPU inference on the UI thread is a freeze. The board stops. FPS drops. You click Skip and nothing happens until the model is done talking to itself.

WebGPU already leaves JavaScript. WASM does not, unless you ask ONNX Runtime to proxy the session into a worker. That is now the CPU path. Generate still takes a while on a laptop; the page no longer goes dead while it does.

On Air also warms the model instead of waiting for the first break to download it. Slow generates (about 400ms, or every generate with ?debug=1) show up in the booth log so I can see which device is actually winning.

FPS lives in the mast all the time. The meter pauses when the tab is hidden. That was the cheap way to stop arguing about whether the board felt stuck.

Breaks that do not wait on the internet

A full break used to wait on Genius, Wikipedia, Ticketmaster, weather, news. If a helper was slow, the song ended and the DJ had nothing.

The skeleton is local: backsell, station ID, frontsell. Those lines synthesize as soon as the current song starts. Facts, headlines, weather, and tour dates clip in only if they are already ready. If they are not, the break still happens. joinBreak is the whole policy: head, optional extra, tail.

Talk is the same idea. Punch T and you get the booth, not a spinner behind a fetch.

First paint and the next song

stations.json is not small. It used to load with the main module, so the first paint waited on every crate. Catalog is a dynamic import now. Presets hydrate when the JSON lands. The JSON files are still the source of truth for lookup-ids; they are just off the critical path.

YouTube was the bigger hitch. loadVideoById on the live iframe means the next track starts cold. There is a second mount, #player-next, sitting at opacity: 0 (not display: none, or YouTube will not buffer). When the current song starts, we cueVideoById the next id in the queue. When that id is due, we swap which iframe is live and hit play. Same-id play still resumes, so talk-with-bed does not jump to the standby video.

GPU when the browser has it

The architecture post said CPU was the default. That was the safe story. It was also leaving speed on the table.

If nothing is saved and navigator.gpu exists, Kokoro starts on WebGPU. A stored CPU choice still wins. We do not write localStorage until you touch the select, so a machine that cannot keep WebGPU honest is one click from WASM, and we will not overwrite that.

What it feels like

The bars keep going. The next YouTube hour is often already cued. The DJ still talks; it just does not freeze the board to get there. 91.3 Indie showed up on the dial the same night, which is unrelated and also the point of a station you leave on: new crate, same clock.

Try Airshift at airshift.fly.dev — pick a frequency, hit On Air, watch the mast.