NIGHTWAVE
A late-night radio station with a live AI DJ + call-ins
You power on a walnut radio in your browser. A tube warms up — the glass behind the dial blooms amber, a needle sweeps the band and settles on 98.6, and an unhurried baritone fades in:
"You're back with Sam Dusk on NIGHTWAVE, ninety-eight point six — the last station on the dial."
Sam spins a record that has never existed. He reads the weather for your town, tonight, real — then for a town that isn't on any map. You pick up the handset, say you're driving home after a double shift, and he answers you, live, on the air. A few minutes later, between songs, he sends the next record out to you — and presses a brand-new one, "pressed just tonight, never heard before," with a title and an artist that didn't exist before you called.
All of that — the host, the music, the memory, the records, the weather, the ghosts on the empty parts of the dial — is run by one ~1.08-billion-parameter language model on a single GPU, with a free CPU box doing the directing. This is the story of how it was built, and of the one decision that made all of it possible.
We wanted one thing above all: an entry a judge could review with their eyes closed. So we built a radio you actually operate, and a late-night host worth staying up for.
The first version had a gimmick: a DJ who, over a session, slowly realizes he's an AI — a five-stage "realization arc" driven by an app-side meter. It demoed fine. But it was a one-trick story: once the twist lands, there's nowhere to go, and it leans on melancholy instead of warmth. So we pivoted — deliberately — to something we found stronger and far more re-playable: a real all-night station with an intelligent showrunner. No twist. Just a warm, witty host named Sam Dusk running a continuous show, who listens when you call and remembers what you said.
(The arc isn't gone, exactly — it's a fossil. The whole five-stage state machine still sits dormant in arc.py, neutralized but intact, the way an old transmitter sits in the corner of a studio. More than once it tried to claw its way back into a fallback path, and catching that became its own small saga — see the war stories below.)
The thesis that replaced the gimmick became the whole engineering philosophy:
The AI should not be a chatbot bolted onto a radio UI. It should be the station brain. A judge should think: this is not a web app playing clips — this is a tiny live radio station being run for me.
A 1B model on a shared GPU is slow and occasionally incoherent. If the broadcast waits on it, the illusion dies in the first ten seconds. So we drew a hard line through the entire system:
The model writes creative words. The app owns structure, timing, taste, and state. And the broadcast never, ever blocks on the model — there is a deterministic fallback behind every single thing the model is asked to do.
This split shows up everywhere:
Once you commit to that line, a tiny flaky model stops being a liability and becomes exactly what the brief rewards: the load-bearing creative core, with guardrails strong enough to put it on live radio.
Browser (free CPU Hugging Face Gradio Space) Modal (one GPU app, scale-to-zero)
─────────────────────────────────────────── ─────────────────────────────────────
iframe srcdoc = THE WAKING SET (the radio) @modal.asgi_app(requires_proxy_auth)
· HTML/CSS/canvas/Web Audio on a class with @modal.enter():
· backlit dial-glass / VU needle / handset ├─ /asr faster-whisper small (CPU)
· in-browser GENERATIVE MUSIC engine ├─ /brain MiniCPM5-1B GGUF (llama.cpp, GPU)
· the deterministic SHOWRUNNER + session memory └─ /speak Kokoro-82M am_michael (+word times)
│ fetch (same-origin) ▲
│ POST /api/segment {kind, ctx} │ Modal-Key / Modal-Secret
│ POST /api/call {audio_b64} │ (proxy-auth, server-side only)
│ POST /api/song_card{ctx} · /api/locale · /api/songs │
▼ │
Space server (FastAPI + Gradio) ──────────────────────────────────────┘
· builds the persona prompt + the per-segment USER directive
· calls Modal /brain → /speak; sanitizes the text; deterministic fallback
· resolves real local weather server-side; never logs raw coordinates
Two boxes, each chosen for what it's good at:
<iframe srcdoc="…"> — because (gotcha #1, the one that decides your architecture) gr.HTML does not execute <script>. If you want a real Web Audio instrument instead of a picture of one, it has to live in an iframe from line one.@modal.asgi_app() GPU container, exposing /asr, /brain, /speak. All three models load once per container in @modal.enter() from a Modal Volume. min_containers=0 keeps the bill at zero between sessions; @modal.concurrent(max_inputs=8) lets a single warm container handle a small demo crowd./api/* proxy. It never sees the Modal URL or the auth token — those live in Space Secrets and are attached server-side as Modal-Key / Modal-Secret headers.The Modal engine is deliberately generic and stateless: /brain takes {system, messages} and returns {text, mood, arc_cue}. It has no idea there's a station, a showrunner, or a Sam Dusk. All of the intelligence is in the prompts, the orchestration, and the deterministic policy — none of it in the GPU container. That's why we could ship four "AI features" on top of it without redeploying the engine a single time (more on that below).
The brief rewards a model that genuinely is the experience and is genuinely small. MiniCPM5-1B (~1.08B params) lands squarely in the Tiny Titan (≤4B) bracket and the OpenBMB pool, and at Q4_K_M as a GGUF it cold-starts fast and runs cheaply. We serve it through llama-cpp-python with full GPU offload (n_gpu_layers=-1, n_ctx=4096) on a single Modal T4.
Two things bit hard enough to be worth writing down:
1. Don't force a chat format MiniCPM5 doesn't speak. Our first instinct was chat_format="chatml". The model promptly went insane — it echoed our prompt's example string and looped (}, 1, 0, 2, … **** F **** F …). MiniCPM5 is not a ChatML model. The fix was to let llama.cpp use the model's own embedded chat template (it auto-detects it) and never override it. With the embedded template it produces proper, coherent DJ patter. One line of config; one wasted evening.
2. The CUDA image ships gcc, but Modal's Python ships clang. Building llama-cpp-python with CUDA support from source kept failing with "Could not find the compiler specified in CC: clang." Modal's add_python standalone interpreter bakes CC=clang into its sysconfig, but the nvidia/cuda:12.4.1-devel image only ships gcc (via build-essential). The fix is two env vars — CC=gcc, CXX=g++ — that have to stay byte-identical between the build image and the weights-download image. We also pin CMAKE_ARGS=-DGGML_CUDA=on -DCMAKE_CUDA_ARCHITECTURES=75 to build only for the T4's arch (sm_75): faster, more reliable one-shot builds.
The reliability worry on a 1B/Q4 brain is structured output. We don't hope — we constrain. Every /brain call passes a LlamaGrammar.from_json_schema(...) built from the exact {text, mood, arc_cue} contract, so the sampler can only produce that shape. If parsing still fails, there's one repair retry (re-asserting "Respond ONLY with the JSON object"), and if that fails, a CANNED_FALLBACK so the radio never goes silent. We also keep max_tokens=128 (it's 1–3 spoken sentences, not an essay) and repeat_penalty=1.3 — the grammar forces the model straight into the JSON string without its usual <think> step, which makes it prone to looping a phrase; the penalty curbs that.
ASR is faster-whisper small, on CPU (the T4 is reserved for the brain), compute_type="int8", beam_size=5, English. The base64 audio blob from the browser is decoded by faster-whisper's bundled PyAV — no system ffmpeg, no manual resampling — straight to 16 kHz mono.
TTS is Kokoro-82M, voice am_michael — a deep, kind male voice that suits a 3 a.m. desk. And here was the happy discovery of the whole build: Kokoro hands you per-word timestamps for free. We'd budgeted time to bolt forced alignment onto the audio to drive the captions. We didn't need to — Kokoro's pipeline exposes start_ts/end_ts per token, so we map them straight to words / wtimes / wdurations and ship them out of /speak. That's what makes the captions develop in time with Sam's voice, word by word. The only gotchas: guard against None on punctuation/whitespace tokens (they carry no timing), and when Kokoro yields multiple chunks for a long line, offset each chunk's timestamps by the running audio duration so they line up with the concatenated WAV.
One subtle 1B-model trick lives on the caller path. A small model under a JSON grammar loves to echo a question that appears verbatim in its context. So when you call in, we don't paste your words into the prompt as a quote. We deliver them as an answer-directive:
"The caller is asking about this:
<your words>. Respond by speaking ONLY your warm reply in your own words — a fresh sentence that answers them. Begin your reply with a word other than the caller's. Do not quote or restate their words…"
That single reframing is the difference between Sam answering you and Sam parroting you.
While the real reply generates underneath, the call is covered by pre-synthesized "stall" clips — "Mm. Good question, friend. Let me sit with that a second." — cached once per container so there's never dead air on the line.
There are no audio files in NIGHTWAVE. Not one. Every song you hear is synthesized live in your browser from a handful of musical parameters, using Web Audio oscillators. This is both an aesthetic choice and a copyright firewall: the station can play "records" all night and never touch a single real recording.
The catalog is two dozen fictional tracks — "Neon Rain" by The Tuesday Ghosts, "The Diner at 3 A.M." by Edith & the Empty Booths, "A Town That Isn't There" by The Tuesday Ghosts — each carrying four fields the engine understands:
key : a root note name → C, D, Eb, Ab, Bb …
scale : major | minor | dorian | lydian | pentatonic_minor
tempo : BPM (a late-night 60–90)
timbre : rhodes | sine_pad | triangle_pluck | soft_saw | music_box
renderSong() turns those into roughly 58 seconds of music: it picks a chord progression ([1,5,6,4], [6,4,1,5], …), builds chords and a walking-ish bass off the scale degrees, sprinkles a melodic line with a little probabilistic swing, and lays a soft kick + brushed hat underneath. Each timbre is a small synth recipe (oscillator type, lowpass cutoff, attack/release, gain). It's not going to chart — but it sounds like a warm late-night record, and it's different every time.
The voice runs through a real broadcast chain so it sounds transmitted, not pasted in:
<audio> → highpass 300 Hz → lowpass 3400 Hz (the boxed-in AM band)
→ compressor (-24 dB, 3.5:1) (radio "loudness")
→ tape-saturation waveshaper (warmth)
→ voice gain → analyser → out
⤷ parallel plate-reverb send (subtle)
Around it sits a living air: a looping vinyl-crackle/room-tone bed, a tape-hiss bed that swells as you tune off-station and ducks under Sam's voice, a slow synthesized ambient pad (a low open-fifth through a breathing lowpass) that only comes up when you're powered and locked on the station, and a sparse lo-fi kick/hat at 76 BPM. The VU needle isn't decorative — it's driven by the real RMS off the AnalyserNode, so it bounces to whatever is actually playing.
This is the part we're proudest of. On top of the base engine we shipped a four-feature pack that turns the model from "a voice" into "a showrunner." Every one of them follows the principle — model writes the words, app owns the structure — and the Modal /brain container was left byte-for-byte unchanged across all four.
When you call in, we extract a tiny session memory from your words — deterministically, with no extra model call. The mood comes free from the answer we already generated; the topic is your gist; your name and place are best-effort regex ("I'm Mira" → Mira, "out in Queens" → Queens). A break or two later, Sam reads a dedication built from that memory:
"This next one's for Mira, still up in Queens — you're not as alone as it feels tonight."
Continuity is the thing judges feel immediately. It turns a call-in from a one-off Q&A into a living broadcast — without accounts, persistence, or any privacy footprint beyond the current tab.
The most tempting design here is a model call: "given the last few minutes, plan the next segment." We tried it; we removed it. A 1B model's "plan" is slow, occasionally nonsensical, and — fatally — untestable. So the showrunner is a client-side deterministic policy (runShowrunner()):
MOOD_VIBES table maps tender → [tender, gentle, lonely, romantic, …], weighted toward the best fit and away from the last song's vibe).The model still writes every spoken word. But pacing, taste, and continuity — the things that make it feel directed — are deterministic, fast, and covered by tests. That's the trade we'd make again.
Occasionally — biased to right after you call — the station invents a brand-new record. Here the model/app split is at its sharpest:
"<title> by <artist>" (e.g. "Tail Lights in the Rain by The Sleeping Overpass"), under a tight grammar.vibe → (scale, timbre, tempo, key) table — every value checked against the engine's own SCALES/TIMBRES enums, so a generated card can never feed the synth something it can't play.<>{}[] or stray colons is rejected and a procedural title/artist is used instead.Cards are capped at four per session and injected into the live song bank. When one comes up, the intro mentions, warmly, that it was pressed just tonight, never heard before. The music is generated; now the catalog is too — and it's personalized to your call without ever leaving copyright-safe ground.
The dial is an interaction, so we made tuning off 98.6 feel like something to discover. Drift off-station and rare ghost fragments bleed in through the static — half-heard dedications, a numbers-station weather reading, a fictional station ID, a stray line like a dream someone else is having out loud, a garbled song title dissolving into noise. There are five flavors, and the prompt for them is persona-free — explicitly not Sam Dusk, not NIGHTWAVE, just "a faint voice from some other station on the dial."
The engineering detail that matters: a fragment plays through a dedicated, low-gain audio element that is completely independent of the DJ-voice chain. So a ghost can never touch the live show — no ON-AIR light, no static-duck, no caption hijack — and the static stays loud over it. It fires on a 600 ms dwell (you have to actually rest off-band), with an ~8 s cooldown, and renders as a half-heard caption: … calling all night nurses —kssht— near mile marker nine …. The dial stops being a slider and becomes a place.
The single most "AI-tell" failure mode for a small model under a JSON contract is leaking the contract into the speech — saying "mood: warm" or "title: Neon Rain, artist: The Tuesday Ghosts" out loud. We fight this on three fronts:
label: value.label: value runs and <placeholder>/[bracket] junk, unwraps a fully-quoted line, and rejects anything still carrying brackets, two-or-more colons, an echoed instruction ("one or two sentences…"), or too few real letters — falling back to a clean canned line when it does._speakable) that rewrites all-caps "NIGHTWAVE" → "Nightwave" for the TTS only, because Kokoro spells all-caps words out letter-by-letter. (Captions keep the branded all-caps.)This wasn't theoretical. A "title: …, artist: …" leak made it to the live station before an audit caught it; hardening the sanitizer's regex and rewriting every prompt to hand facts conversationally is what finally killed it. On a 1B model, the prompt is only half the battle — the post-filter is the other half.
Sam can tell you about your own sky tonight. The browser asks for geolocation; the coordinates go to a same-origin POST /api/locale; the server resolves them through Open-Meteo (current temp, sky, local time, sunrise) and BigDataCloud (city name) — both free, key-less, and called server-side. The facts are handed to the model in the user turn — "out Queens way, it is about 54 degrees, the sky is overcast, the clock just read 2:14 AM" — with an instruction to weave them into one or two warm sentences, never read them like a forecast.
Two disciplines here. Privacy: the resolver never logs raw latitude/longitude. Graceful degradation: deny the prompt, or let it fail, and the station quietly falls back to the weather for a town that doesn't exist — "Out in Old Ferris tonight: low fog rolling off a river that isn't on any map, sixty-one degrees and a moon you could read by." The show never notices the difference.
A great backend deserves an object worth touching. The final visual design — codenamed THE WAKING SET — came from merging a fresh design pass with a detailed hardware-polish review, and it threw out the modern-app tells in favor of a heavy 1970s tabletop radio:
AudioContext.resume() or the first fetch, and reduced-motion collapses it to an instant-on.#3FB8C4, used only at the moment the dial locks on 98.6. One color, earned once.The previous skeuomorphic design is preserved verbatim in the repo, and the engine was confirmed byte-identical across the redesign — the new face is paint on the same machine.
The whole thing was built test-first, with the implementation driven by adversarially-reviewed multi-agent workflows and a suite that grew to 66 passing tests. Two episodes are worth keeping as field lessons:
The regression that shipped, because the baseline was wrong. During one workflow, an agent tasked with editing proxy.py rebuilt it from the last committed version — silently dropping a pile of straight-station code that was still sitting uncommitted in the working tree. The tests passed (they tested the old shape), and bugs surfaced live. The fix was straightforward; the lesson is permanent: when you let an automated agent edit files that have uncommitted changes, the review baseline must be the working tree, not a prior commit.
The guard that paid for itself. A different orchestration workflow stopped itself, correctly, twice — once on a stale state-anchor in the plan, once on a missing case-insensitive flag in a regex — before either defect could become code. A "STOP if the plan doesn't match the current code" check feels paranoid right up until the moment it saves you from confidently shipping the wrong thing.
And the structural decision that made the feature pack possible: because the GPU engine was a fixed, generic, stateless primitive, all four AI features lived entirely in prompts, orchestration, and deterministic client policy. We shipped session memory, a showrunner, generative records, and a magical dial without redeploying the model container once. Keep your expensive, slow, hard-to-test layer small and dumb; put the cleverness where it's cheap to change and easy to test.
Modal runs scale-to-zero by default (min_containers=0, scaledown_window=120) — between sessions it costs nothing. The one intentional exception: for the judging window we pinned min_containers=1 (≈ $0.59/hr, ~$14/day) so judges never hit a cold start, with a standing reminder to revert it afterward. On top of that, the pre-warm, the cached stall clips, and the "warming up the transmitter…" framing mean that even a cold first request reads as flavor, not failure.
/extract endpoint (an engine redeploy), so the named callback stays best-effort for now.▶ Watch the demo: https://youtu.be/lA46z2mYjF0 🎙️ Tune in: https://huggingface.co/spaces/build-small-hackathon/nightwave
Power it on. Let the tube warm up. Wait for Sam to say his name, call in and tell him something true — then turn the dial off 98.6 and listen to what's hiding in the static.
One small model, on one small GPU, dreaming up the whole night as it goes.
Thanks for tuning in.
A late-night radio station with a live AI DJ + call-ins
More from this author