---
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: A radio that picks up broadcasts from lost universes
models:
- openbmb/MiniCPM5-1B
- MarianaCodebase/MiniCPM5-1B-lost-frequency-radio-GGUF
datasets:
- MarianaCodebase/lost-frequency-radio-transmissions
tags:
- build-small-hackathon
- thousand-token-wood
- minicpm
- llama-cpp
- off-the-grid
- off-brand
- well-tuned
- field-notes
- open-trace
- tiny-titan
field_notes: https://huggingface.co/blog/build-small-hackathon/lost-frequency-radio
merit_badges:
- off-the-grid # Off the Grid: no cloud APIs, 100% local CPU inference
- well-tuned # Well-Tuned: published LoRA fine-tune on the Hub
- off-brand # Off-Brand: custom gr.Server frontend, no default Gradio UI
- llama-cpp # Llama Champion: runs through the llama.cpp runtime
- open-trace # Sharing is Caring: dataset + build trace published on the Hub
- field-notes # Field Notes: write-up published on the hackathon blog
thumbnail: >-
https://cdn-uploads.huggingface.co/production/uploads/6a1e35984859bfce7e5e0dc3/_TeD2YxGOVjFE3_7JuzbR.png
---
# π» Lost Frequency Radio
**An interactive old radio that picks up broadcasts from parallel universes, written live by a 1-billion-parameter language model running entirely on CPU, with no cloud APIs anywhere in the loop.**
Turn the dial. Between the static there are voices. A 1950s announcer narrating a chess match between cats. A weather report for Jupiter. Commercials for renting clouds. A tropical station broadcasting from a MedellΓn that exists on the Moon. A late-night call-in show where every caller turns out to be the same person.
And if you listen closely, there is one frequency that only broadcasts numbers in Morse. It does not want to talk to you. But it tells you how to get past it anyway, twice, without realizing it is doing it. Get past it and the dial does something it should not be able to do: it grows. New frequencies appear above 108, and what is broadcasting from them explains why every voice you found was a room in the same house. One universe among many, and it is the one quietly losing power tonight while you listen.
**Track:** π An Adventure in Thousand Token Wood
**Demo video:** [watch the radio in action](ADD_YOUR_DEMO_VIDEO_LINK)
**Social post:** [the launch post](ADD_YOUR_SOCIAL_POST_LINK)
---
## What I found out building this
I went in expecting to fight a billion-parameter model the whole way. I figured this write-up would be me apologizing for rough edges. It did not go like that.
This model holds a dozen distinct characters and keeps them straight across a session. It writes in Spanish, English, and French without bleeding one into the other. It does deadpan comedy, it nails the cadence of a 1950s sports broadcast, it plays a cipher operator that withholds information and leaks the way past it anyway, and it improvises surreal premises that stay internally consistent for a whole transmission. All of it runs with the wifi off.
The capability was already in there. With no direction the model gives you the average of everything it ever read, and that is the dull version everyone remembers. The work was never about size. It was about aiming it at one specific thing and training it properly. Once I did, the ceiling was much higher than I had set my expectations.
The hard constraint for this track was simple: the model has to run on the machine it ships on, on CPU, with nothing calling out to the internet. Every decision below comes from taking that seriously.
---
## How I built it, end to end
### 1. The model: a LoRA fine-tune of MiniCPM5-1B
A 1B base model asked to "write a 1950s radio broadcast" does not write a broadcast. It writes *about* one: stage directions, "Sure, here's a transmission!", meta-commentary. That is a chatbot in a costume. I picked `openbmb/MiniCPM5-1B` because it runs comfortably on CPU once quantized, and because its GGUF chat template lets me disable the model's thinking mode with a `\n\n\n\n` prefill, which matters for something that has to answer instantly on air instead of reasoning first.
I built my own dataset. `dataset/build_dataset.py` generates `transmissions.jsonl`, about 786 short structured broadcasts in Spanish, English, and French, in chat format. The single most important decision in that dataset is what it leaves out: there is no instruction block in the system prompts, no "write only the on-air script, 60 to 90 words, stay in character." I learned the hard way that a 1B model parrots that language straight back onto the air, and you will literally hear "[60-90 words]" broadcast at your listener. So the format, the markers, the length, the sign-off, is taught purely by example. By the end of training the model has never seen anything instruction-shaped, so it has nothing instruction-shaped left to leak.
Training was LoRA, rank 16, alpha 32, dropout 0.05, applied to every projection (q/k/v/o/gate/up/down), three epochs, bf16, cosine schedule, gradient checkpointing, on a single laptop RTX 4050 with 6 GB of VRAM. Final loss landed around 0.36 to 0.42 with token accuracy near 0.92. I merged the adapter and exported to GGUF Q4_K_M (`export_gguf.py`, `quantize_gguf.py`). The fine-tune is on the Hub as [`MarianaCodebase/MiniCPM5-1B-lost-frequency-radio-GGUF`](https://huggingface.co/MarianaCodebase/MiniCPM5-1B-lost-frequency-radio-GGUF) and the dataset as [`MarianaCodebase/lost-frequency-radio-transmissions`](https://huggingface.co/datasets/MarianaCodebase/lost-frequency-radio-transmissions). `model.py` downloads the fine-tune on first run and falls back to the base MiniCPM5 GGUF if it is ever missing. The whole training run is something you could repeat on a gaming laptop in an evening.
### 2. The runtime: llama.cpp, determinism, and a model that never thinks out loud
llama.cpp is the obvious choice for CPU-only GGUF inference. The part I had to engineer was around it.
`model.py` builds the ChatML prompt by hand instead of relying on the default template, tokenizes with `special=True`, and prefixes the assistant turn with `\n\n\n\n` so MiniCPM5 skips its reasoning block and answers immediately. As a safety net I wrote a streaming `_ThinkingFilter`: if a `` block ever slips through, it is buffered and stripped token by token before it reaches the listener, so it can never be seen, not even mid-stream.
The harder problem was determinism. The whole game depends on "tune to 96.0 MHz and you get the 1950s announcer, every time." That means the same `(frequency, language, session seed)` has to produce the same broadcast. llama.cpp's first generation (full prompt eval) and its later generations (cached prefix) behave slightly differently even with the same seed, so before every generation I call `_llm.reset()` to force a full re-evaluation. It costs a little speed, and it is what makes sharing your favorite frequency with a friend actually work.
### 3. The backend: gr.Server, not the default Gradio UI
The track runs on Spaces, and Spaces means `sdk: gradio`. I did not want sliders and a chat box, I wanted a radio. `gr.Server` hands you a real FastAPI app underneath Gradio, so you keep the SDK requirement but own routing, static files, and streaming.
`app.py` mounts my own `static/` folder, serves a hand-built `index.html`, and exposes streaming SSE endpoints (`/tune`, `/finale`, `/transmit`) that the frontend reads token by token. Every broadcast is cached in memory by `(frequency, language, variant)`, so re-tuning to a station you have already visited replays instantly instead of regenerating, which matters a lot when your entire inference budget is one CPU.
One bug here taught me more than the rest of the project combined. Rapid re-tuning used to lock the whole radio into permanent static. Each tune starts a generation that holds the model lock, and if you tune away before it finishes, that old generation keeps running in the background while every newer tune queues up behind a broadcast nobody will ever hear. The fix is a generation counter: every tune bumps it, and every in-flight stream checks on each token whether it is still current. The moment it is not, it stops itself. Three lines of code, and the whole difference between a radio that feels alive when you spin the dial fast 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," and the entire emotional effect depends on the interface selling the premise. `static/js/radio.js` and `static/css/radio.css` are hand-written with no UI framework. The tuning dial has real inertia and overshoot from an under-damped spring on the needle. The oscilloscope is drawn live from a Web Audio `AnalyserNode`. Static and per-station voices are synthesized in the browser, and Morse code is generated and played for the number station. The CRT look, scanlines, phosphor glow, vignette, is layered CSS over a canvas. Everything streams in as the model generates it, so the typewriter effect on screen is the real model output, not a fake animation.
### 5. The game design: the model performs, the code judges
I wanted the model to be expressive everywhere it was safe to be wrong, and never to be the thing that decides whether you win. The hidden number station at 104.7 MHz is an automatic operator that resists you. `decrypt.py` defines the actual win condition, transmit its sequence back or name what it is, in plain deterministic Python, derived from a per-session seed so every listener gets their own cipher. The model only role-plays the resistance. It never decides whether you cracked it. Crack it and the dial physically grows: `game.py` unlocks a hidden band from 108 to 112 MHz with five fixed fragments that reveal every station was a room in the same collapsing world, ending in a scripted finale where the radio goes dark, plays a closing theme, rolls credits, and offers to start over. Any model can be talked into letting you win if you let it hold the gate. This one never holds the gate.
---
## How to play
1. **Turn the dial** by dragging it, using the bezel knob, or the arrow keys. Every exact frequency is a station, and the same frequency gives everyone the same broadcast, so share your favorites.
2. Near a station the signal arrives corrupted and clears as you fine-tune.
3. One frequency is not a story. It is a number station with its own console. The operator does not want to talk to you, but it dictates things.
4. Whoever breaks the cipher discovers the dial can grow, and that every station was a fragment of the same world.
5. One frequency never clears. Maybe you can figure out what it says.
---
## Stack
- **Model:** [MiniCPM5-1B](https://huggingface.co/openbmb/MiniCPM5-1B) (OpenBMB), LoRA fine-tuned on a custom dataset of about 786 examples in Spanish, English, and French
- **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, and JS, no framework
- **Determinism:** frequency plus session seed maps to a station, and win conditions are validated server-side, never by the model
## Merit badges
- π **Off the Grid** β no cloud APIs, 100% local CPU inference
- π― **Well-Tuned** β published LoRA fine-tune on the Hub
- π¨ **Off-Brand** β fully custom `gr.Server` frontend, no default Gradio UI
- π¦ **Llama Champion** β runs through the llama.cpp runtime
- π‘ **Sharing is Caring** β dataset and build trace published on the Hub
- π **Field Notes** β [write-up published on the hackathon blog](https://huggingface.co/blog/build-small-hackathon/lost-frequency-radio)
Also competing for **π Tiny Titan** (the whole experience runs on a single 1B model), **π¨ Off-Brand Award** (custom radio UI built on `gr.Server`), and **ποΈ Bonus Quest Champion** (all six badges on one sash).
## Run locally (Windows)
```powershell
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
```powershell
python dataset\build_dataset.py # generates dataset/transmissions.jsonl
python train_lora.py # LoRA, bf16, on GPU
python export_gguf.py # merge to GGUF
python quantize_gguf.py # to Q4_K_M
```
---
Built by **Mariana Sinisterra** for the Hugging Face **Build Small Hackathon 2026**, track π *An Adventure in Thousand Token Wood*.
[GitHub](https://github.com/Mariana-Codebase) Β· [LinkedIn](https://www.linkedin.com/in/marianasinisterra/) Β· [marianacodebase.com](https://marianacodebase.com) Β· [X](https://x.com/MarianaCodebase)