A newer version of the Gradio SDK is available: 6.26.0
title: Lost Frequency Radio
emoji: π»
colorFrom: yellow
colorTo: green
sdk: gradio
sdk_version: 6.17.3
app_file: app.py
pinned: true
license: apache-2.0
short_description: An old radio that picks up parallel universes, live
models:
- openbmb/MiniCPM5-1B
- build-small-hackathon/MiniCPM5-1B-lost-frequency-radio-GGUF
datasets:
- build-small-hackathon/lost-frequency-radio-transmissions
tags:
- build-small-hackathon
- thousand-token-wood
- llama-cpp
- minicpm
- openbmb
- off-the-grid
- off-brand
- well-tuned
- tiny-titan
- track:wood
- sponsor:openbmb
- achievement:offgrid
- achievement:welltuned
- achievement:offbrand
- achievement:llama
- achievement:sharing
- achievement:fieldnotes
π» Lost Frequency Radio
An old radio that picks up broadcasts from parallel universes, written live by a 1-billion-parameter model running 100% on CPU, with no cloud APIs anywhere in the loop. Turn the dial. The voices are real-time. The wifi can be off.
βΆ Live demo Β· π¬ Demo video Β· π£ Social post Β· π§ Fine-tuned model Β· π Dataset Β· π Field Notes write-up Β· π°οΈ Agent trace
Hi, I'm Mariana, and I want to tell you about this radio
It's 2 a.m. and you turn the dial. Static, static, then a 1950s announcer calling a chess match between cats. Keep turning: a weather report for Jupiter, a commercial for renting clouds, a chef serving a dish named after you. Around 104.7 the warmth disappears and a flat voice starts reading numbers in Morse. Break its cipher and the dial does something it shouldn't be able to do. It grows, and every station you found turns out to be a room in the same collapsing universe.
I started this with a small, slightly stubborn belief: that a tiny model, the kind you can run on the laptop in front of you, could still make someone feel something. Most of the AI news lately is about models getting bigger and a little scarier. I wanted to go the other way and build something that was small, local, and just plain joyful. No cloud, no API keys, nothing phoning home. Just a 1B model, a weird idea, and an old radio.
I did not expect to fall this hard for the project. Every word you hear was written by a model living on the machine in front of you, no cloud, no script bought off a shelf. And if you flip the MODE switch to LIVE, you can watch it write a fresh broadcast token by token, right there. That is the part I still find a little magical, and it is the part I most want you to feel when you turn the dial.
ποΈ Merit badges this entry earns
Here is the honest scorecard. Everything below is verifiable from the links in this README, I have tried not to claim a single thing I cannot point to.
| Badge | What it requires | How Lost Frequency Radio earns it |
|---|---|---|
| π°οΈ Off the Grid | No cloud APIs; the whole thing runs on the model in front of you | Inference is 100% local through llama.cpp. No API keys, no hosted inference, no network calls at generation time. Unplug the ethernet mid-broadcast and nothing changes. See model.py. |
| π― Well-Tuned | Uses a fine-tuned model you published on Hugging Face | A LoRA fine-tune of MiniCPM5-1B, trained on a hand-built dataset, published as GGUF on the Hub. Training is fully reproducible from this repo. |
| π¨ Off-Brand | A custom frontend that pushes past the default Gradio look (hint: gr.Server) |
Built on gradio.Server: a real FastAPI app under Gradio. A hand-written skeuomorphic CRT radio (wooden bezel, phosphor glow, live oscilloscope, synthesized voices, Morse). Zero default Gradio UI is visible. |
| π¦ Llama Champion | Your model runs through the llama.cpp runtime | The published model is GGUF Q4_K_M, served with llama-cpp-python. See requirements.txt and model.py. |
| π Field Notes | A blog post / report about what you built and learned | Published: Field Notes: I Built a Radio That Picks Up Lost Universes. |
| π‘ Sharing is Caring | You shared your agent trace on the Hub for everyone to learn from | The full, scrubbed agent build trace is published on the Hub: ~4,500 turns of how the project was actually built, decision by decision and bug by bug. The whole pipeline (dataset, LoRA training, GGUF export) is reproducible from this repo too. |
Also competing for: π Tiny Titan (the model is 1B, far under the β€4B bar), the OpenBMB Awards (built on and fine-tuned from OpenBMB's MiniCPM5-1B), and ποΈ Bonus Quest Champion (the badges above, stacked on one sash).
β Hackathon rules, checked
- Small Models Only (β€32B): MiniCPM5-1B. It fits on a laptop, and so did the ambition.
- Built on Gradio, hosted as a Hugging Face Space: yes,
sdk: gradioviagradio.Server. You are reading its Space right now. - Show, Don't Tell: demo video and social post are done.
π Why it fits An Adventure in Thousand Token Wood
The track asks for something delightful that wouldn't exist without AI, where the AI is doing the fun thing. Here is how that maps to the judging criteria:
- Genuinely delightful (would you show a friend?) I did show friends, more than once, and that is half of why it is the way it is. It's a physical-feeling radio you tune, not a chat box. You hunt for voices in the static and get rewarded with something weird and funny. People lean in.
- AI is load-bearing for the experience There is no human script. Every station is written by the fine-tuned model, and you can watch it happen live. Remove the model and there is no content at all, just an empty dial. The fiction is the generation.
- Originality of concept A tuning-dial interface over a fine-tuned model, a hidden number-station cipher you actually solve, and a band that physically grows when you win.
- Polish Custom CRT UI, precise tuning that stays where you leave it, a live "capturing signal" loading bar, per-station synthesized voices, Morse, an oscilloscope, and a scripted finale with credits.
β‘ Two ways to listen: instant, or live
Running a 1B model on a free CPU Space means a fresh broadcast takes a while to generate. To keep the experience snappy and honest, the radio has a MODE switch on the front panel, right next to STATIC:
- FAST (default): broadcasts are served instantly from a pre-baked pool. The
model wrote every one of them ahead of time (see
prebake.py), six editions per station, and each listener's session picks one, so different people still hear different wordings. Nothing is hand-written, it is all real output from the fine-tuned model, just generated in advance so you never wait. - LIVE: flip the switch and the model writes the broadcast for you right now, token by token. Slower (you watch the "capturing signal" bar while it thinks), but you see the AI doing its job in real time.
Either way it is the same fine-tuned model running locally through llama.cpp. And the number-station operator, the part that answers what you type, is always live, because it has to respond to your exact message.
The people who made it better
I am proud of the engineering, but I'd be lying if I said I figured it all out alone in a room. The biggest jumps in quality came from sitting a friend down, handing them the dial, and just watching their hands and their face.
- The first time someone played it, they grabbed the tuning knob with the mouse and it spun wildly across the whole band, like a slot machine. They laughed, but it was the bad kind of laugh. I had built physical momentum into the dial because it felt clever, and it turned out to feel broken. I tore the free-spin out so the dial now stops exactly where you let go. A radio should feel precise, not slippery. I would never have caught how wrong it felt from the inside.
- Another friend kept tuning to a station and then asking "is it frozen? is it working?" during the second or two the model needed to start generating. From their seat, silence reads as broken. So I added the "capturing signal" loading bar that fills while the model thinks and fades the instant the first words arrive. One small bar, and suddenly the wait felt intentional.
- Someone played all the way to the end, solved the cipher, collected every fragment, and then the finale screen never appeared. The most important forty seconds of the whole experience, and I had quietly broken it. They told me, I traced it, and now the ending lands the way it was always supposed to.
None of those were in my head. They came from real people playing, getting confused or annoyed or delighted at the wrong moments, and being kind enough to tell me. If this app feels good to use, a lot of the credit goes to them.
How I built this, and why
Everything here is something I did and can explain end to end. The hard constraint was: the model has to run on the machine it ships on, on CPU, with nothing calling out to the internet. Every decision follows from that.
1. The model: a LoRA fine-tune of MiniCPM5-1B
A 1B base model asked for "a 1950s radio broadcast" writes about a broadcast:
stage directions, "Sure, here's a transmission!", meta-commentary. That's a
chatbot in a costume. I picked openbmb/MiniCPM5-1B
because it's small enough to run comfortably on CPU once quantized, and its chat
template lets me disable "thinking" mode with a <think>\n\n</think>\n\n
prefill, which matters for a model that has to answer instantly on air.
The thing I learned the hard way: I wrote my own dataset
(dataset/build_dataset.py, ~786 short broadcasts in Spanish and
English), and the single most important decision was what the system prompts
do not contain. There is no instruction like "write only the on-air script,
60-90 words." At this size a model parrots that straight back on air, you
literally hear "[60-90 words]" broadcast at you. So I never wrote the rule. I
taught the format by example until it was the only thing the model knew how to
do. That one realization changed the whole project.
Training (train_lora.py): LoRA rank 16, alpha 32, dropout 0.05, on every
projection (q/k/v/o/gate/up/down), 3 epochs, bf16, cosine schedule, gradient
checkpointing, on a single laptop RTX 4050 (6 GB VRAM). Final loss ~0.36-0.42,
token accuracy ~0.92. Then merge, export, and quantize to GGUF Q4_K_M
(export_gguf.py, quantize_gguf.py), published
on the Hub.
The whole run repeats on a laptop in an evening, which is most of the reason I
think more people should be doing this.
2. The runtime: llama.cpp, determinism, and a "no thinking" model
llama.cpp is the obvious choice for CPU-only GGUF inference. The engineering
is in what wraps it. model.py builds the ChatML prompt by hand, tokenizes with
special=True, and prefixes the assistant turn so MiniCPM5 skips reasoning and
answers immediately. A streaming _ThinkingFilter is a safety net: if a
<think> block ever slips through, it's stripped token by token before any
listener sees it.
Determinism is the subtle part, and the part I'm quietly proud of. Tuning to
96.0 MHz has to give the same announcer for everyone, every time, so each
broadcast is seeded from (frequency, language, per-session variant), and I
call _llm.reset() before every generation to force a full prompt
re-evaluation. Without that, cached-prefix divergence quietly breaks the promise
that you can share your favorite frequency with a friend and they hear the same
voice you did.
3. The backend: gr.Server, not the default Gradio UI
Spaces means sdk: gradio, but I wanted a radio, not sliders.
gradio.Server gives you a real FastAPI app underneath Gradio: you
keep the SDK requirement (and the Off-Brand badge), but you own routing, static
files, and streaming. app.py mounts static/, serves a hand-built
index.html, and exposes SSE endpoints (/tune, /finale, /transmit) read
token by token by the Gradio JS client. Broadcasts are cached per
(frequency, language, variant), so revisiting a station replays instantly,
which matters when your whole inference budget is one CPU.
The bug that taught me the most lived here. Spinning the dial fast used to lock the radio in permanent static, because each abandoned generation kept holding the model lock and every newer tune queued up behind a broadcast nobody would ever hear. The fix is a generation counter: every in-flight stream checks on each token whether it's still the current one, and quits the moment it isn't. Three lines of code, and the difference between a radio that feels alive when you spin it and one that just breaks.
4. The frontend: a radio that looks and feels like a radio
Nothing about a slider-and-textbox UI says "old radio picking up dying
universes," so I built it from scratch. static/js/radio.js and
static/css/radio.css are hand-written, no framework. There is a precise tuning
dial that stays exactly where you leave it, an oscilloscope drawn from a Web
Audio AnalyserNode, synthesized per-station voices, real static, Morse for the
number station, a CRT look (scanlines, phosphor glow, vignette), and the
"capturing signal" bar. Text streams in as the model generates it, so the
typewriter effect on screen is the actual model output, not a fake animation.
5. The game design: a hardcoded answer key, a model that only performs
I wanted the model to be expressive where it's safe to be wrong, and never the
thing that decides whether you win. The number station at 104.7 MHz is an
operator that resists you, but the win condition lives in plain deterministic
Python (decrypt.py), seeded per session so every listener gets their own
cipher. The model only role-plays the resistance. Crack it and the dial grows:
game.py unlocks a hidden band (108-112 MHz) with five fragments that reveal
every station was a room in the same collapsing world, ending in a scripted
finale with credits and a "play again."
What I actually learned
I came into this bracing to apologize for a small model's limits, and I never hit the ceiling I expected. Once it knew what I wanted, 1B was plenty to carry a dozen voices, two languages, a running joke, and a cipher game. The hard part was never capability, it was direction. I also learned that the win condition should never live inside the model (any model can be talked into letting you win), that silence reads as "broken" to a first-time player, and that the fastest way to find the flaw in your own work is to hand it to a friend and stay quiet. I'll carry all three into whatever I build next.
How to play
- Turn the dial (drag it, the bezel knob, or arrow keys). Every exact frequency is a station, and the same frequency gives the same broadcast to everyone, so share your favorites.
- Near a station the signal arrives corrupted and clears as you fine-tune.
- One frequency isn't a story. It's a number station with its own console. The operator doesn't want to talk to you... but it dictates things.
- Whoever breaks the cipher discovers the dial can grow, and that every station was a fragment of the same world.
- One frequency never clears. Nobody knows what it says. Maybe you do.
Lost? Hit the ? button on the panel for a quick how-to, and keep an eye on the FIELD LOG tab (right edge): it remembers every station, the cipher, and the clues you find.
Pick your experience: the MODE switch on the panel (next to STATIC) flips between FAST (broadcasts instantly from the pre-baked pool) and LIVE (watch the model write each one token by token). See Two ways to listen above.
Stack
- Model: MiniCPM5-1B (OpenBMB), LoRA fine-tuned on a custom ~786-example dataset (Spanish + English)
- Runtime:
llama-cpp-python, GGUF Q4_K_M, 100% local, CPU only - Backend:
gradio.Server(FastAPI) with token-by-token SSE streaming - Frontend: hand-written HTML/CSS/JS, no framework
- Determinism: frequency + session seed β station; win conditions validated server-side, never by the model
Run locally (Windows)
python -m venv .venv
.\.venv\Scripts\Activate.ps1
pip install -r requirements.txt
python download.py # fetches the base GGUF into models/minicpm/
python app.py # http://127.0.0.1:7860
Reproduce the fine-tune
python dataset\build_dataset.py # generates dataset/transmissions.jsonl
python train_lora.py # LoRA, bf16, on GPU
python export_gguf.py # merge -> GGUF
python quantize_gguf.py # -> Q4_K_M
Made with a lot of love (and a lot of static) by Mariana Sinisterra for the Hugging Face Build Small Hackathon 2026, track π An Adventure in Thousand Token Wood. Thank you for turning the dial.