No description
  • Python 97.5%
  • Nix 2.5%
Find a file
mrfluffy 93b24e64c3 License under GPL-3.0-or-later
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 15:30:50 +00:00
personas Rename to AI Radio; voice bank, delivery tones, new persona 2026-11-05 15:30:50 +00:00
src/airadio Rename to AI Radio; voice bank, delivery tones, new persona 2026-11-05 15:30:50 +00:00
voices Rename to AI Radio; voice bank, delivery tones, new persona 2026-11-05 15:30:50 +00:00
.envrc initial 2026-09-02 20:36:53 +01:00
.gitignore Rename to AI Radio; voice bank, delivery tones, new persona 2026-11-05 15:30:50 +00:00
config.example.toml Rename to AI Radio; voice bank, delivery tones, new persona 2026-11-05 15:30:50 +00:00
flake.lock initial 2026-09-02 20:36:53 +01:00
flake.nix Rename to AI Radio; voice bank, delivery tones, new persona 2026-11-05 15:30:50 +00:00
LICENSE License under GPL-3.0-or-later 2026-11-05 15:30:50 +00:00
pyproject.toml License under GPL-3.0-or-later 2026-11-05 15:30:50 +00:00
README.md License under GPL-3.0-or-later 2026-11-05 15:30:50 +00:00

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.