- Python 97.5%
- Nix 2.5%
|
|
||
|---|---|---|
| personas | ||
| src/airadio | ||
| voices | ||
| .envrc | ||
| .gitignore | ||
| config.example.toml | ||
| flake.lock | ||
| flake.nix | ||
| LICENSE | ||
| pyproject.toml | ||
| README.md | ||
AI Radio
A Cyberpunk 2077 radio station hosted by two AIs talking to each other.
It is not a playlist. A local daemon generates dialogue with an LLM, speaks it with two distinct local neural voices, and serves the result as a live MP3 stream. RadioExt tunes the game to that stream, so the station in your car is genuinely improvising, and never repeats itself.
writer renderer transmitter
┌──────────┐ scripts ┌──────────┐ 24k mono ┌─────┐ mp3 ┌──────────┐
│ ollama │──────────►│ qwen3-tts│───────────►│ring │────────►│ /stream │──► game
│ / API │ queue │ VEX/SOL │ │ buf │ ffmpeg │ .mp3 │
└──────────┘ (2 deep) └──────────┘ └─────┘ └──────────┘
writes ahead speaks a segment drained at realtime
of playback at a time = the station clock
Writing and speaking are independent tasks. The writer works a segment or two ahead while the renderer is still speaking the previous one, so the model's latency overlaps playback instead of following it — which is what lets a slow local model keep a live station fed.
What you need
- RadioExt and its prerequisites (Cyber Engine Tweaks, redscript, Codeware) installed in Cyberpunk 2077.
- Nix with flakes, for the dev environment.
- An LLM: a local Ollama, or an API key for OpenAI-compatible / Anthropic.
- A GPU with about 3 GB free, and this one is not optional. Qwen3-TTS has no workable CPU path — it generates several times slower than realtime there and the stream starves. On a 5080 the 0.6B model runs at roughly 0.6x realtime, so the station stays comfortably ahead of the playhead.
- Minting voices needs more, about 5 GB, but only once and not while on air.
Setup
$ nix develop # or `direnv allow`, the .envrc is already here
$ uv pip install -e . # first run pulls torch, ~3 GB
$ cp config.example.toml config.toml
$ $EDITOR config.toml # set game.path, pick a model and persona
$ airadio voices # mint the hosts' voices (once; needs ~5 GB of VRAM)
$ airadio doctor # verifies ffmpeg, torch, the voices, the LLM, RadioExt
airadio voices is the one setup step with no equivalent in a stock-voice TTS.
See Voices for what it does and why it exists.
airadio doctor checks every moving part independently and tells you which one is
broken. Run it before anything else.
Running
$ airadio install # writes metadata.json into radioExt/radios/
$ airadio serve # goes on air
Then launch the game and tune to the station. airadio serve must be running
whenever you want to hear it — the game is only a client.
You can listen without the game at http://127.0.0.1:8077/, which also shows the current line as it is spoken. That page is the fastest way to tell whether a problem is in the station or in the game.
Choosing a station
Three ship in personas/:
| persona | what it is | |
|---|---|---|
ghost_in_the_signal |
101.5 | Two synthetic hosts on a pirate transmitter, aware they are machines and bored of it. Dry, unhurried, a little haunted. |
night_city_talk |
92.4 | Straight talk radio. Corpo filings, gang movement, infrastructure failures, callers nobody helps. |
n54_afterhours |
54.0 | Corporate-licensed evening lifestyle show. Relentlessly pleasant, quietly dystopian. British voices. |
$ airadio personas # list them
$ airadio serve --persona night_city_talk # or set station.persona in config
A persona carries its own station name, frequency and folder, so switching
persona switches the whole station — run airadio install again afterwards and the
new one appears alongside the old. If you pin name, fm or folder under
[station] in config.toml, your value wins and stops moving; that is what you
want once you have found a frequency that does not collide with your other
radio mods.
Writing your own
A persona is one YAML file in personas/. It defines the hosts and a weighted
pool of segments the director draws from:
id: my_station
language: english # what the script is written in, not the accent
station:
name: "88.1 My Station" # what RadioExt shows in the radio list
fm: 88.1 # sorts that list; pick something unoccupied
folder: MyStation # created under radioExt/radios/
tagline: "Something short."
hosts:
- id: VEX # the label the LLM uses, and what you see in /status
voice: vex # names voices/vex.wav; co-hosts need their own
speed: 1.04
design: > # what the voice sounds like. Read once, by
A woman in her early thirties, American. Bright, hard-edged,
slightly nasal. Speaks quickly and lands consonants like she is
done with the sentence before it ends.
temperament: > # how they talk. Read every segment, by the LLM
Fed to the model verbatim, so write it as direction to an actor,
not as a description of a character.
- id: SOL # one host is fine too -- a solo persona gets
voice: sol # monologue prompting instead of cross-talk
design: >
A man in his fifties, American. Low, soft, unhurried, with a little
gravel low in the register.
temperament: >
...
lore: # world facts the hosts may draw on
- Nobody has ever called in.
system: |
The framing prompt. Tone, register, what the show is, what it is not.
Be specific about what you do *not* want -- that does more work than
anything else here.
segments:
- id: night_shift_drift
weight: 5 # relative likelihood of being drawn
turns: 7 # roughly how many exchanges
brief: >
What this segment is. One concrete situation, not a topic.
- id: field_report
weight: 2
turns: 4
hosts: [SOL] # who is on mic; omit for the whole roster
brief: >
SOL calls in from somewhere he should not be.
The director never draws the same segment twice in a row, keeps the last 14 lines for continuity, and steers away from recently aired segments. Weights are how you control the rhythm of the show.
Who is on mic
A segment with no hosts: key uses the whole roster. Naming a subset puts only
those hosts in that segment, which is how you get field reports, two-handers
and solo bits without writing a persona per lineup:
hosts: [VESTA] # solo -- gets monologue prompting, not cross-talk
hosts: [DASH, COOPER] # a two-hander inside a four-host station
The cast is enforced at both ends. Only the hosts on mic are described to the model, so it is never introduced to someone it cannot use, and a line the model writes for an absent host is dropped rather than spoken in the wrong voice. When the cast is a subset, the prompt says outright who has left the studio -- the continuity history still contains their lines, and without that the model writes them straight back into the room.
Casting a single host is the same machinery a one-host station runs on, so a solo persona is just a roster of one.
Voices
Qwen3-TTS has no roster of stock voices to pick from, so the station makes its own. There are two ways to get a voice out of it, and this project uses both, at different times:
- VoiceDesign turns a prose description into a voice. It is expressive and it is unrepeatable — ask it twice and you get two different people.
- Voice cloning copies the speaker in a reference clip, exactly and cheaply.
So airadio voices runs VoiceDesign once per host, offline, and saves the result
as voices/<id>.wav. Every line that goes out afterwards is cloned from that
clip. Measured across four lines of one host, designing afresh each time drifts
41 Hz of median pitch; cloning from a fixed clip drifts 8. That difference is
the difference between a co-host and a stranger who keeps taking over the mic.
A host's identity is therefore a wav file. It is worth keeping — the voices/
directory is checked in for exactly this reason, and losing a clip loses the
host for good, because the description that produced it will not produce it
again.
$ airadio voices # mint what the current persona needs
$ airadio voices --all-personas # every persona shipped here
$ airadio voices --refresh --voice vex # re-roll one voice you did not like
Re-running is safe and usually a no-op: a clip is only re-minted when it is
missing, or when the design: prose it came from has changed since. Editing a
description is how you deliberately invalidate a voice; airadio doctor warns
when a persona and its clips have drifted apart.
Minting loads the 1.7B VoiceDesign model and wants about 5 GB of VRAM, which
is usually more than a local LLM has left over. Stop the LLM for the minute it
takes (ollama stop <model>); the broadcast itself runs on the 0.6B model and
about 2.2 GB, and never loads VoiceDesign at all.
The design: prose describes the instrument — age, gender, timbre, pace,
accent — while temperament: describes the performance and goes to the LLM.
Accent lives in design:, which is why a British station and an American one
now differ only in what their hosts' descriptions say. Be explicit about
register: "a man in his forties" alone gets read as a suggestion, and adding
"deep, chesty, low-pitched" gets it honoured.
You can also skip designing entirely and clone a real recording: drop a few
seconds of clean speech at voices/<id>.wav next to a voices/<id>.json
holding {"text": "<exactly what the clip says>"}, and nothing will overwrite
it — a clip with no fingerprint is treated as hand-made.
airadio say "some line" --voice vex renders a wav, which is the quick way to
audition a voice before committing a host to it.
Delivery
The default natural style aims at a conversation you are overhearing rather
than a script being read. It changes two things at once.
The prompt stops asking for well-formed sentences: lines become wildly uneven, short reactions ("Yeah." "Since when?") get their own line, hosts interrupt and circle back, and thoughts trail off. Most lines are deliberately not good lines, because the filler is what makes the sharp one land.
The pacing stops being a metronome. The pause before each line is derived from what came before it — an answer to a question lands almost immediately, a two-word reaction can clip into the end of the line it reacts to, and a thought that trails off leaves a hole — with a little jitter on top so nothing is exact.
$ airadio serve --style scripted # the even, announcer-style read
$ airadio serve --style natural # the default
Per-line tempo is applied after generation: Qwen3-TTS has no rate parameter, and resampling instead would transpose the host — the exact identity the clone prompt exists to hold still.
The method matters more than it looks. A phase vocoder keeps pitch but resynthesises the signal from a modified spectrum, and pays for it in phasiness: measured on a rendered line it cut onset sharpness from 2.84 to 1.21, which is audible as a hollow, metallic, in-a-tin-can quality on every line that gets stretched — and with per-host speeds and jitter, that is almost every line. The station uses WSOLA instead, which splices the waveform at points where it already matches itself; onset sharpness comes back at 2.85 and spectral flatness is unchanged from the raw take. It costs about 13 ms a line.
Underneath both, tts.trim_silence strips the padding the model leaves around
every utterance. That padding is a large fraction of a short line, and left in
it swamps the pacing entirely — the gap you hear would be the
model's, not the one the director chose. Leave it on unless you are chasing a
clipped word onset.
If overlaps ever sound muddy, set overlap_ms = 0 under [dialogue]; you keep
the variable pacing without hosts ever talking over each other.
Tone
The writer also tags every line with how it is said, and the tag is part of the format the parser expects:
VEX (deadpan): Forty-one dead. Company's calling it a scheduling error.
SOL (angry): A scheduling error.
VEX (amused): Their words, not mine. Heh.
SOL: ...
Fourteen tones are on offer — neutral, amused, laughing, excited,
urgent, angry, warm, deadpan, sarcastic, uneasy, awed,
conspiratorial, sad, tired — and each one moves four things: tempo,
level, the pause before the line, and the pause after it. An urgent line
runs 15% quick and pulls the next line in close; a tired one drags, drops
2 dB and leaves a hole on both sides. Tones that are reactions — a laugh, an
objection, alarm — are also allowed to clip further into the previous line
than an ordinary short answer would.
Common synonyms fold in (annoyed → angry, whispering → conspiratorial),
an untagged line is neutral, and a tag naming nothing recognisable is neutral
too. Delivery is a hint, never something a script depends on.
The writer can also put sounds in the dialogue. [laughs], [sighs],
[scoffs], [hums], [groans], [gasps] and [beat] are replaced with
something sayable — "Heh heh.", "Hah...", "Pff.", "Hm.", "Ugh.", "Huh.", "..."
— varied so the same laugh never lands twice in a row. Anything else in
brackets is still cut: there is no foley on this station, and [door slams]
read aloud is worse than [door slams] deleted.
All of this is indirect, and that is not an oversight. The Qwen3-TTS
checkpoint that clones voices takes no instruction text — generate_voice_clone
has no instruct parameter, and the 0.6B talker ignores one even on the paths
that accept it — so there is no "say this angrily" input to set. Tone reaches
the audio through the only two doors that exist: the words the model chooses,
and what this code does to the finished take.
Turn it off with emotion = false, or dial it with emotion_strength: 0
flattens every tone to neutral without touching the prompt, 1.0 is the tuned
table in delivery.py, and past about 1.5 the clone starts to audibly strain
against its reference clip.
$ airadio say "[laughs] Sure. That's the whole plan?" --tone amused -o test.wav
Configuration
Everything lives in config.toml; see config.example.toml, which documents
every key at its default. The parts that matter most:
[llm]
backend = "ollama" # ollama | openai | anthropic
model = "qwen3.8:27b"
api_key_env = "" # the NAME of the env var, never the key itself
[audio]
lookahead_seconds = 45 # how far ahead of the playhead to stay generated
backend = "openai" speaks to anything with an OpenAI-compatible API —
OpenRouter, vLLM, llama.cpp's server, LM Studio — via base_url.
Any setting can be overridden per-run: AIRADIO_LLM_MODEL, AIRADIO_PERSONA,
AIRADIO_GAME_PATH, AIRADIO_PORT and friends, or the matching CLI flags.
Keeping ahead of the playhead
Writing and speaking run as two independent tasks with a queue between them:
writer ──> [ script queue ] ──> renderer ──> [ audio buffer ] ──> encoder
(LLM) script_lookahead (qwen3-tts) lookahead_seconds
This split is what absorbs a slow model. The writer never looks at the audio buffer — it writes as fast as the model allows and blocks only when the script queue is full. So by the time a segment starts playing, the next one is already written, and the model has the entire airtime of the current segment (a minute or two) to produce the one after it. Nothing waits on the LLM at the moment the buffer runs low.
airadio serve also generates prebuffer_seconds before opening the transmitter,
so the station never opens on dead carrier — expect roughly ten seconds between
launching it and the stream becoming available, plus model load on a cold start.
Two numbers on /status tell you whether the pipeline is healthy:
starved_seconds |
airtime that went out as station bed because nothing was ready. Should stay at 0. |
scripts_ready |
segments sitting in the queue. Should sit at script_lookahead and dip briefly. Persistently 0 means the writer is losing. |
last_write_seconds |
how long the last segment took to write. |
If starved_seconds climbs, the fix is almost always lookahead_seconds.
It caps how much audio the renderer will bank: at 45 the cushion never exceeds
about one segment, which is thin when a segment takes nearly as long to write as
it does to play. Because the writer now runs independently, raising it simply
lets the cushion grow — 240 gives a local 27B model minutes of runway, at the
cost of the station being further "ahead" of itself.
script_lookahead matters less than it looks. The renderer drains the queue
whenever the audio buffer is below lookahead_seconds, so in practice the
cushion accumulates as audio, not as queued scripts — expect scripts_ready
to sit at 0 with a slow model, and do not read that as a fault.
dialogue.turns_scale multiplies each segment's requested length, so one model
call covers more airtime. It helps only as much as your model obeys length
instructions: measured on gemma3:27b it moved 43 turns to 46, while run-to-run
variance was 13 to 46. Worth trying, but check it does something before relying
on it.
If writing is genuinely slower than airtime no amount of buffering saves you — the deficit compounds — and the answer is a smaller or faster model.
A note on VRAM, because this is the constraint that shapes the setup: a 27B
model and Qwen3-TTS both want the GPU, and unlike the old Kokoro backend the
TTS cannot retreat to the CPU when it loses — it generates several times slower
than realtime there, so demoting it would starve the stream instead of saving
it. What the station does instead is refuse to start on a card that cannot hold
it (device = "auto" checks free VRAM first and says what is holding it), and,
if CUDA fails partway through a broadcast, retry once with the cache released
before dropping lines loudly rather than degrading silently.
The budget on a 16 GB card, measured here:
| VRAM | when | |
|---|---|---|
| 0.6B Base, cloning | 2.2 GB | held for the whole broadcast |
| 1.7B Base, cloning | 4.3 GB | held for the whole broadcast |
| 1.7B VoiceDesign | 4.1 GB | only during airadio voices |
Load order decides whether this fits. Ollama sizes its GPU offload to
whatever is free at the moment it loads, so with a 27B already resident there
may be under 1 GB left and the station will refuse to start. airadio serve
loads the voices before it ever calls the LLM, which is the order that works:
take the ~2–4 GB first and let Ollama fit itself around what remains.
At 0.6x realtime the renderer produces about 1.7 seconds of speech per second
of airtime, which is the headroom that keeps starved_seconds at zero.
The 1.7B Base model is the better default when it fits, and — measured here — costs nothing in speed: RTF 0.63 against the 0.6B's 0.65, for 1.9 GB more VRAM. What it buys is fidelity to the reference clip. Over six lines each:
| VEX (ref 266 Hz) | SOL (ref 88 Hz) | |
|---|---|---|
| 0.6B, drift from reference | +47 Hz | +2 Hz |
| 1.7B, drift from reference | +15 Hz | +5 Hz |
The 0.6B pushes a high voice noticeably above its own clip; the 1.7B mostly
holds it. Set tts.model if you have the card for it. /status reports which
model and device it landed on.
Commands
airadio serve |
go on air |
airadio doctor |
check every dependency and report which is broken |
airadio voices |
mint the reference clips the hosts are cloned from |
airadio install |
write the station into RadioExt (--force to overwrite) |
airadio uninstall |
remove it |
airadio script -n 3 |
print generated dialogue, no audio — for tuning prompts |
airadio say "text" |
render one line to a wav — for auditioning voices and tones (--tone) |
airadio personas |
list personas and their voices |
airadio metadata |
print the metadata.json that install would write |
airadio script is the one to reach for while writing a persona. It exercises the
whole director without loading a TTS model, so iteration is seconds rather than
minutes.
Troubleshooting
The station does not appear in-game. RadioExt only rescans on load. Check
that airadio install wrote where you expected (airadio metadata shows the file,
airadio doctor shows the directory), and that fm does not collide with another
station.
It appears but plays silence. Confirm the stream itself is fine by opening
http://127.0.0.1:8077/ in a browser. If that plays, the problem is the game's
decoder — set chunked = false under [server], which swaps HTTP/1.1 chunked
transfer for the oversized Content-Length that older streaming clients
expect.
Long gaps between lines. Generation is not keeping up. Check
starved_seconds at /status.
CUDA out of memory while running airadio voices. Expected, and the usual
case: VoiceDesign wants about 5 GB and your LLM is holding the card. Minting is
a one-off that takes under a minute, so ollama stop <model>, mint, and start
it again. The command now checks free VRAM first and tells you this rather than
failing per voice.
CUDA out of memory at airadio serve. Same cause, less room to negotiate:
the broadcast needs ~3 GB free for the lifetime of the run. Use a smaller quant
of the LLM, cap what Ollama keeps resident, or point [llm] at a remote
backend and give the whole card to the voices.
FileNotFoundError: /sbin/ldconfig on the first line of audio. Triton
looks for libcuda by shelling out to ldconfig, which NixOS does not have.
The devshell sets TRITON_LIBCUDA_PATH to fix this — you are running outside
nix develop.
A CUDA error on an RTX 50-series card. Those need sm_120 kernels, which
arrived in torch 2.7 — the pin in pyproject.toml covers it, but a stale
.venv may not. Delete .venv and reinstall.
A host sounds nothing like their description. VoiceDesign reads register
loosely; "a man in his forties" alone often comes back in a female range. Say
"deep, chesty, low-pitched" outright, then airadio voices --refresh --voice <id>
to re-roll. Each roll is a different person, so keep the take you like — it
cannot be reproduced.
Constant background static. That is audio.bed_gain, a deliberate carrier
hiss mixed under everything so the noise floor does not cut in and out between
lines. It defaults to off now; the old 0.004 is broadband noise at about
-48 dBFS, which the MP3 encoder spends bits smearing rather than reproducing.
Try ~0.001 if you want the continuity without the hiss.
Voices sound hollow or metallic, like a tin can. That was the phase-vocoder
time-stretch, replaced with WSOLA — update and it goes away. If you still hear
it, check that dialogue.speed_jitter and the per-host speed: values are not
demanding a very large stretch; anything past about ±15% will strain any
method.
Hosts talk over each other's topics / repeat themselves. Raise turns on
your segments so each block has room, or add more segments to the pool. The
director only remembers the last 14 lines.
Layout
personas/ station definitions (YAML) -- the creative surface
voices/ reference clips the hosts are cloned from (checked in)
src/airadio/
config.py TOML config + env overrides
persona.py persona loading and validation
llm.py ollama / openai-compatible / anthropic backends
director.py prompting, script parsing, text cleanup for speech
delivery.py the tone vocabulary and what each tone does to the audio
voices.py the voice bank: minting and loading reference clips
tts.py Qwen3-TTS voice cloning
broadcast.py realtime buffer, ffmpeg encoder, listener fan-out
engine.py the production loop that keeps the buffer fed
server.py /stream.mp3, /status, and a browser player
radioext.py writing the station into the game
cli.py command line
License
GPL-3.0-or-later. See LICENSE.