| --- |
| 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. |
|
|
| <p align="center"> |
| <a href="https://huggingface.co/spaces/build-small-hackathon/lost-frequency-radio"><b>▶ Live demo</b></a> · |
| <a href="https://www.youtube.com/watch?v=MIv956xWMzc">🎬 Demo video</a> · |
| <a href="https://x.com/MarianaCodebase/status/2065857307444199484">📣 Social post</a> · |
| <a href="https://huggingface.co/build-small-hackathon/MiniCPM5-1B-lost-frequency-radio-GGUF">🧠 Fine-tuned model</a> · |
| <a href="https://huggingface.co/datasets/build-small-hackathon/lost-frequency-radio-transmissions">📚 Dataset</a> · |
| <a href="https://huggingface.co/blog/build-small-hackathon/lost-frequency-radio">📓 Field Notes write-up</a> · |
| <a href="https://huggingface.co/datasets/build-small-hackathon/lost-frequency-radio-agent-trace">🛰️ Agent trace</a> |
| </p> |
|
|
| <p align="center"> |
| <img src="static/img/demo.gif" alt="Lost Frequency Radio: tuning the dial through parallel-universe broadcasts" width="640" /> |
| </p> |
|
|
| ## 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`](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](https://huggingface.co/build-small-hackathon/MiniCPM5-1B-lost-frequency-radio-GGUF). 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`](app.py): 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`](requirements.txt) and [`model.py`](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](https://huggingface.co/blog/build-small-hackathon/lost-frequency-radio)**. | |
| | 📡 **Sharing is Caring** | You shared your agent trace on the Hub for everyone to learn from | The full, scrubbed [agent build trace](https://huggingface.co/datasets/build-small-hackathon/lost-frequency-radio-agent-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: gradio` via `gradio.Server`. You are reading its Space right now. |
| - **Show, Don't Tell:** [demo video](https://www.youtube.com/watch?v=MIv956xWMzc) and [social post](https://x.com/MarianaCodebase/status/2065857307444199484) 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`](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`](https://huggingface.co/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](https://huggingface.co/build-small-hackathon/MiniCPM5-1B-lost-frequency-radio-GGUF). |
| 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`](app.py) 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 |
|
|
| 1. **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. |
| 2. Near a station the signal arrives **corrupted** and clears as you fine-tune. |
| 3. 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. |
| 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. 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](#-two-ways-to-listen-instant-or-live) above. |
|
|
| --- |
|
|
| ## Stack |
|
|
| - **Model:** [MiniCPM5-1B](https://huggingface.co/openbmb/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) |
|
|
| ```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 -> 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. |
|
|