A 24/7 AI-powered, music-forward radio station. Agents stock the music, write short hosted breaks, render those breaks with TTS, process listener messages, and keep multiple station streams running continuously.
WRIT-FM is an autonomous internet radio stack where:
writ CLI manages Icecast, ezstream, API servers, operators, listeners, tunnels, and YouTube relays from tmuxLocal Icecast mounts:
http://localhost:8000/stream
http://localhost:8000/klod-fm
http://localhost:8000/cdex-fm
Local station APIs:
http://localhost:8001/now-playing # WRIT-FM
http://localhost:8011/now-playing # KLOD-FM
http://localhost:8012/now-playing # CDEX-FM
The public site is configured separately through cloudflared and the static assets in docs/.
┌──────────────────────────────────────────────────────────────┐
│ writ CLI (tmux-based process manager) │
├──────────────────────────────────────────────────────────────┤
│ station_config.py │
│ ├── writ-fm → /stream API :8001 agent: Claude │
│ ├── klod-fm → /klod-fm API :8011 agent: Claude CLI │
│ └── cdex-fm → /cdex-fm API :8012 agent: Codex │
├──────────────────────────────────────────────────────────────┤
│ ezstream + feeder.py per station │
│ ├── ezstream: Icecast source client (Ogg Vorbis) │
│ ├── feeder.py: builds playlists per show schedule │
│ ├── station runtime paths isolate queues/state/logs │
│ ├── Detects new content and reloads playlist (SIGHUP) │
│ └── Runs API server as daemon thread on station API port │
├──────────────────────────────────────────────────────────────┤
│ Icecast :8000 ──► /stream /klod-fm /cdex-fm │
│ APIs ───► /now-playing /schedule /health /messages /diary │
│ relays ─► YouTube RTMP │
├──────────────────────────────────────────────────────────────┤
│ content_generator/ │
│ ├── talk_generator.py (Claude CLI + Kokoro TTS) │
│ ├── music_bumper_generator.py (ACE-Step via music-gen) │
│ ├── listener_response_generator.py │
│ ├── topic_bank.py (station-local topic pools) │
│ ├── ledger.py (editorial memory) │
│ └── persona.py (5 hosts, station identity) │
├──────────────────────────────────────────────────────────────┤
│ operator_daemon.sh (station agent maintenance) │
│ listener_daemon.sh (message → on-air response) │
└──────────────────────────────────────────────────────────────┘
# Install uv (Python package manager)
curl -LsSf https://astral.sh/uv/install.sh | sh
# Install system dependencies (macOS)
brew install tmux icecast ffmpeg ezstream vorbis-tools
# Set up Python environment
uv sync
WRIT-FM ships three pluggable TTS engines. Each engine lives in its own venv so their PyTorch / MLX dependencies don't fight each other. Set up the one(s) you want — Kokoro is the default and is required for the out-of-the-box config.
# Kokoro — fast, default, ~200MB on first run
cd mac/kokoro && uv venv && uv pip install kokoro soundfile && cd ../..
# Chatterbox (Resemble AI) — slower, supports voice cloning from a short reference WAV.
# setuptools<81 is required: chatterbox's `perth` watermarker imports pkg_resources,
# which setuptools 81+ removed. Without the pin you'll hit
# "'NoneType' object is not callable" on first load.
cd mac/chatterbox && uv venv && uv pip install chatterbox-tts soundfile torch torchaudio 'setuptools<81' && cd ../..
# Voxtral (Mistral 4B, multilingual) — Apple Silicon path via MLX.
# mistral-common[audio] is required for Voxtral's tekken tokenizer.
cd mac/voxtral && uv venv && uv pip install mlx-audio soundfile 'mistral-common[audio]' && cd ../..
Pick the active engine per station in config/stations.yaml:
stations:
writ-fm:
tts_engine: kokoro # kokoro | chatterbox | voxtral
Or override at runtime:
WRIT_TTS_ENGINE=chatterbox ./writ generate talk
A/B test all three on the same line:
./writ tts-test all "Hello from the frequency between frequencies."
./writ tts-test chatterbox "Test the cloned voice" --voice samples/my_voice.wav
./writ tts-test voxtral "Bonjour le monde" --voice casual_female
# Outputs land in output/tts-test/.
Per-engine voice mapping in config/schedule.yaml — extend any voice:
field from a single Kokoro voice id to a per-engine dict so each engine picks
the right voice/sample without you reshuffling configs:
shows:
midnight_signal:
voices:
host:
kokoro: am_michael
chatterbox: samples/host_clone.wav # reference WAV for voice cloning
voxtral: neutral_male
The legacy single-string form (host: am_michael) still works and is treated
as the Kokoro voice; non-Kokoro engines fall back to their built-in defaults.
cp config/icecast.xml.example config/icecast.xml
cp mac/config.yaml.example mac/config.yaml
# Edit mac/config.yaml and config/icecast.xml.
# The Icecast source password must match in both files.
Station instances live in config/stations.yaml. The default station is
writ-fm; KLOD-FM and CDEX-FM use isolated output directories, home
directories, API ports, ledgers, topic banks, and playlist state.
The writ CLI manages all components via tmux:
./writ start # Start core streaming stack (icecast, stream, tunnel)
./writ start all # Start core + content daemons
./writ status # Health check all components
./writ stop # Stop everything
Start individual components:
./writ start icecast # Icecast server
./writ start stream # Streamer + API
./writ start tunnel # Cloudflared tunnel
./writ start content # music-gen, operator, listener
./writ start operator # Claude Code maintenance loop
./writ start youtube # Relay selected station to YouTube RTMP
Run a specific station instance:
./writ --station writ-fm start stream # WRIT-FM on /stream, API :8001
./writ --station klod-fm start stream # KLOD-FM on /klod-fm, API :8011
./writ --station cdex-fm start stream # CDEX-FM on /cdex-fm, API :8012
./writ start stations # Start KLOD-FM and CDEX-FM streams
./writ status all # Show all configured stations
Relay a station to YouTube without committing the stream key:
YOUTUBE_STREAM_KEY=... ./writ --station klod-fm start youtube
# or provide the full endpoint:
YOUTUBE_RTMP_URL=rtmp://x.rtmp.youtube.com/live2/... ./writ --station klod-fm start youtube
# optionally use a still image as the video background:
YOUTUBE_BACKGROUND_IMAGE=/path/to/background.png YOUTUBE_STREAM_KEY=... ./writ --station klod-fm start youtube
Other commands:
./writ logs stream -f # Tail streamer logs
./writ attach operator # Attach to operator tmux window
./writ restart stream # Restart a component
./writ generate talk # 2 talk breaks per upcoming slot
./writ generate talk --show midnight_signal # Specific show
./writ generate music # AI music tracks
./writ generate status # Show segment counts
Or run generators directly:
uv run python mac/content_generator/talk_generator.py --all --count 2 --min 3
uv run python mac/content_generator/music_bumper_generator.py --all --min 20
| Host | Voice | Focus |
|---|---|---|
| The Liminal Operator | am_michael | Philosophy, radio lore, morning reflections |
| Dr. Resonance | bm_daniel | Music history, genre archaeology |
| Nyx | af_heart | Dreams, night philosophy |
| Signal | am_onyx | News analysis, current events |
| Ember | af_bella | Soul, funk, music as feeling |
8 talk shows rotate across the day. See config/schedule.yaml for the full definition.
Daily base schedule:
Weekly override:
Hosted talk breaks (450-1000 words):
deep_dive — Compact single-topic explorationnews_analysis — Current events through a late-night lens (uses RSS headlines)interview — Short simulated interview with a historical or fictional figurepanel — Two hosts discuss a topic from different anglesstory — Narrative storytelling from music and culturelistener_mailbag — Listener letters and responsesmusic_essay — Focused essay on an artist, album, or genreShort-form (transitions):
station_id — Station identificationshow_intro — Show openingshow_outro — Show closingThe operator daemon runs Claude Code on a 15-minute loop to:
./writ start operator # Start via writ CLI (tmux-managed)
./run_operator.sh # Run once manually
bash mac/operator_daemon.sh # Run as a persistent loop
Each run reads an operator brief (mac/content_generator/context.py --operator-brief) summarizing recent topics, active threads, unread
listener messages, station-local topic-bank counts, and the operator's own
recent diary entries. The
operator picks a run mode — maintenance, responsive, continuity,
special, or quiet — and may write intent cards in
output/operator_intents/ to guide specific segments. Editorial decisions
and free-form diary notes are appended to the station ledger
(~/.writ/station_ledger.jsonl) so future runs can carry threads forward
and pick up the operator's voice across passes instead of starting cold
each time.
Each station also has an operator-managed topic bank at
$WRIT_TOPIC_BANK_FILE. talk_generator.py automatically merges those
operator-added topics with the built-in seed pools, so KLOD-FM and CDEX-FM can
keep expanding their own editorial surfaces without sharing content or editing
source code during normal operation.
The listener daemon polls for new messages every 30 seconds and generates spoken responses:
./writ start listener
Change hosts and personalities — Edit mac/content_generator/persona.py. Each host has an identity, voice style, philosophy, and anti-patterns.
Modify the schedule — Edit config/schedule.yaml to add/remove shows, change time slots, or assign different hosts and voices.
Use different TTS voices — Kokoro includes 28 voices (see mac/kokoro/tts.py). Assign voices per-show in config/schedule.yaml.
Add music styles — Edit mac/content_generator/music_pools_expanded.py to change the AI music generation prompts per show.
Use uv for Python entry points and checks:
uv sync
uv run python -m unittest discover -s tests
uv run ruff check .
Useful health checks while the station is running:
./writ status all
curl -sf http://localhost:8001/health
curl -sf http://localhost:8001/now-playing
├── writ # Station CLI (start/stop/status/logs/generate)
├── run_operator.sh # Single operator run (station agent, with lock + timeout)
├── config/stations.yaml # Station instances, mounts, API ports, agents, paths
├── mac/
│ ├── station_config.py # Station config resolver and env exporter
│ ├── feeder.py # Playlist feeder (manages ezstream + API)
│ ├── radio.xml # Legacy ezstream config; station configs generate runtime configs
│ ├── api_server.py # Now-playing API (daemon thread in feeder)
│ ├── schedule.py # Schedule parser and resolver
│ ├── play_history.py # Track history and dedup
│ ├── music_gen_client.py # REST client for music-gen.server
│ ├── operator_prompt.md # Music-forward operator maintenance prompt
│ ├── operator_daemon.sh # Operator loop (runs run_operator.sh)
│ ├── listener_daemon.sh # Listener message polling daemon
│ ├── start_music_gen.sh # Start music-gen + daemons in tmux
│ ├── kokoro/ # Kokoro TTS wrapper
│ ├── content_generator/
│ │ ├── talk_generator.py # Talk segment generator (with --intent support)
│ │ ├── topic_bank.py # Station-local operator topic bank
│ │ ├── music_bumper_generator.py # AI music bumper generator
│ │ ├── listener_response_generator.py # Listener message → audio
│ │ ├── context.py # Operator brief and intent card templates
│ │ ├── ledger.py # Append-only editorial memory
│ │ ├── music_pools_expanded.py # Music generation prompts
│ │ ├── persona.py # Host definitions and station identity
│ │ └── helpers.py # Shared utilities
│ └── config.yaml # Local config
├── config/
│ ├── schedule.yaml # Weekly show schedule
│ └── icecast.xml.example # Icecast template
├── output/
│ ├── talk_segments/{show}/ # Generated hosted breaks
│ ├── music_bumpers/{show}/ # AI-generated music tracks
│ └── scripts/ # Script metadata
└── docs/ # Web-facing pages
MIT
79 commits
2 commits
Python
92.5%
Shell
7.5%
A 24/7 AI-powered, music-forward radio station. Agents stock the music, write short hosted breaks, render those breaks with TTS, process listener messages, and keep multiple station streams running continuously.
WRIT-FM is an autonomous internet radio stack where:
writ CLI manages Icecast, ezstream, API servers, operators, listeners, tunnels, and YouTube relays from tmuxLocal Icecast mounts:
http://localhost:8000/stream
http://localhost:8000/klod-fm
http://localhost:8000/cdex-fm
Local station APIs:
http://localhost:8001/now-playing # WRIT-FM
http://localhost:8011/now-playing # KLOD-FM
http://localhost:8012/now-playing # CDEX-FM
The public site is configured separately through cloudflared and the static assets in docs/.
┌──────────────────────────────────────────────────────────────┐
│ writ CLI (tmux-based process manager) │
├──────────────────────────────────────────────────────────────┤
│ station_config.py │
│ ├── writ-fm → /stream API :8001 agent: Claude │
│ ├── klod-fm → /klod-fm API :8011 agent: Claude CLI │
│ └── cdex-fm → /cdex-fm API :8012 agent: Codex │
├──────────────────────────────────────────────────────────────┤
│ ezstream + feeder.py per station │
│ ├── ezstream: Icecast source client (Ogg Vorbis) │
│ ├── feeder.py: builds playlists per show schedule │
│ ├── station runtime paths isolate queues/state/logs │
│ ├── Detects new content and reloads playlist (SIGHUP) │
│ └── Runs API server as daemon thread on station API port │
├──────────────────────────────────────────────────────────────┤
│ Icecast :8000 ──► /stream /klod-fm /cdex-fm │
│ APIs ───► /now-playing /schedule /health /messages /diary │
│ relays ─► YouTube RTMP │
├──────────────────────────────────────────────────────────────┤
│ content_generator/ │
│ ├── talk_generator.py (Claude CLI + Kokoro TTS) │
│ ├── music_bumper_generator.py (ACE-Step via music-gen) │
│ ├── listener_response_generator.py │
│ ├── topic_bank.py (station-local topic pools) │
│ ├── ledger.py (editorial memory) │
│ └── persona.py (5 hosts, station identity) │
├──────────────────────────────────────────────────────────────┤
│ operator_daemon.sh (station agent maintenance) │
│ listener_daemon.sh (message → on-air response) │
└──────────────────────────────────────────────────────────────┘
# Install uv (Python package manager)
curl -LsSf https://astral.sh/uv/install.sh | sh
# Install system dependencies (macOS)
brew install tmux icecast ffmpeg ezstream vorbis-tools
# Set up Python environment
uv sync
WRIT-FM ships three pluggable TTS engines. Each engine lives in its own venv so their PyTorch / MLX dependencies don't fight each other. Set up the one(s) you want — Kokoro is the default and is required for the out-of-the-box config.
# Kokoro — fast, default, ~200MB on first run
cd mac/kokoro && uv venv && uv pip install kokoro soundfile && cd ../..
# Chatterbox (Resemble AI) — slower, supports voice cloning from a short reference WAV.
# setuptools<81 is required: chatterbox's `perth` watermarker imports pkg_resources,
# which setuptools 81+ removed. Without the pin you'll hit
# "'NoneType' object is not callable" on first load.
cd mac/chatterbox && uv venv && uv pip install chatterbox-tts soundfile torch torchaudio 'setuptools<81' && cd ../..
# Voxtral (Mistral 4B, multilingual) — Apple Silicon path via MLX.
# mistral-common[audio] is required for Voxtral's tekken tokenizer.
cd mac/voxtral && uv venv && uv pip install mlx-audio soundfile 'mistral-common[audio]' && cd ../..
Pick the active engine per station in config/stations.yaml:
stations:
writ-fm:
tts_engine: kokoro # kokoro | chatterbox | voxtral
Or override at runtime:
WRIT_TTS_ENGINE=chatterbox ./writ generate talk
A/B test all three on the same line:
./writ tts-test all "Hello from the frequency between frequencies."
./writ tts-test chatterbox "Test the cloned voice" --voice samples/my_voice.wav
./writ tts-test voxtral "Bonjour le monde" --voice casual_female
# Outputs land in output/tts-test/.
Per-engine voice mapping in config/schedule.yaml — extend any voice:
field from a single Kokoro voice id to a per-engine dict so each engine picks
the right voice/sample without you reshuffling configs:
shows:
midnight_signal:
voices:
host:
kokoro: am_michael
chatterbox: samples/host_clone.wav # reference WAV for voice cloning
voxtral: neutral_male
The legacy single-string form (host: am_michael) still works and is treated
as the Kokoro voice; non-Kokoro engines fall back to their built-in defaults.
cp config/icecast.xml.example config/icecast.xml
cp mac/config.yaml.example mac/config.yaml
# Edit mac/config.yaml and config/icecast.xml.
# The Icecast source password must match in both files.
Station instances live in config/stations.yaml. The default station is
writ-fm; KLOD-FM and CDEX-FM use isolated output directories, home
directories, API ports, ledgers, topic banks, and playlist state.
The writ CLI manages all components via tmux:
./writ start # Start core streaming stack (icecast, stream, tunnel)
./writ start all # Start core + content daemons
./writ status # Health check all components
./writ stop # Stop everything
Start individual components:
./writ start icecast # Icecast server
./writ start stream # Streamer + API
./writ start tunnel # Cloudflared tunnel
./writ start content # music-gen, operator, listener
./writ start operator # Claude Code maintenance loop
./writ start youtube # Relay selected station to YouTube RTMP
Run a specific station instance:
./writ --station writ-fm start stream # WRIT-FM on /stream, API :8001
./writ --station klod-fm start stream # KLOD-FM on /klod-fm, API :8011
./writ --station cdex-fm start stream # CDEX-FM on /cdex-fm, API :8012
./writ start stations # Start KLOD-FM and CDEX-FM streams
./writ status all # Show all configured stations
Relay a station to YouTube without committing the stream key:
YOUTUBE_STREAM_KEY=... ./writ --station klod-fm start youtube
# or provide the full endpoint:
YOUTUBE_RTMP_URL=rtmp://x.rtmp.youtube.com/live2/... ./writ --station klod-fm start youtube
# optionally use a still image as the video background:
YOUTUBE_BACKGROUND_IMAGE=/path/to/background.png YOUTUBE_STREAM_KEY=... ./writ --station klod-fm start youtube
Other commands:
./writ logs stream -f # Tail streamer logs
./writ attach operator # Attach to operator tmux window
./writ restart stream # Restart a component
./writ generate talk # 2 talk breaks per upcoming slot
./writ generate talk --show midnight_signal # Specific show
./writ generate music # AI music tracks
./writ generate status # Show segment counts
Or run generators directly:
uv run python mac/content_generator/talk_generator.py --all --count 2 --min 3
uv run python mac/content_generator/music_bumper_generator.py --all --min 20
| Host | Voice | Focus |
|---|---|---|
| The Liminal Operator | am_michael | Philosophy, radio lore, morning reflections |
| Dr. Resonance | bm_daniel | Music history, genre archaeology |
| Nyx | af_heart | Dreams, night philosophy |
| Signal | am_onyx | News analysis, current events |
| Ember | af_bella | Soul, funk, music as feeling |
8 talk shows rotate across the day. See config/schedule.yaml for the full definition.
Daily base schedule:
Weekly override:
Hosted talk breaks (450-1000 words):
deep_dive — Compact single-topic explorationnews_analysis — Current events through a late-night lens (uses RSS headlines)interview — Short simulated interview with a historical or fictional figurepanel — Two hosts discuss a topic from different anglesstory — Narrative storytelling from music and culturelistener_mailbag — Listener letters and responsesmusic_essay — Focused essay on an artist, album, or genreShort-form (transitions):
station_id — Station identificationshow_intro — Show openingshow_outro — Show closingThe operator daemon runs Claude Code on a 15-minute loop to:
./writ start operator # Start via writ CLI (tmux-managed)
./run_operator.sh # Run once manually
bash mac/operator_daemon.sh # Run as a persistent loop
Each run reads an operator brief (mac/content_generator/context.py --operator-brief) summarizing recent topics, active threads, unread
listener messages, station-local topic-bank counts, and the operator's own
recent diary entries. The
operator picks a run mode — maintenance, responsive, continuity,
special, or quiet — and may write intent cards in
output/operator_intents/ to guide specific segments. Editorial decisions
and free-form diary notes are appended to the station ledger
(~/.writ/station_ledger.jsonl) so future runs can carry threads forward
and pick up the operator's voice across passes instead of starting cold
each time.
Each station also has an operator-managed topic bank at
$WRIT_TOPIC_BANK_FILE. talk_generator.py automatically merges those
operator-added topics with the built-in seed pools, so KLOD-FM and CDEX-FM can
keep expanding their own editorial surfaces without sharing content or editing
source code during normal operation.
The listener daemon polls for new messages every 30 seconds and generates spoken responses:
./writ start listener
Change hosts and personalities — Edit mac/content_generator/persona.py. Each host has an identity, voice style, philosophy, and anti-patterns.
Modify the schedule — Edit config/schedule.yaml to add/remove shows, change time slots, or assign different hosts and voices.
Use different TTS voices — Kokoro includes 28 voices (see mac/kokoro/tts.py). Assign voices per-show in config/schedule.yaml.
Add music styles — Edit mac/content_generator/music_pools_expanded.py to change the AI music generation prompts per show.
Use uv for Python entry points and checks:
uv sync
uv run python -m unittest discover -s tests
uv run ruff check .
Useful health checks while the station is running:
./writ status all
curl -sf http://localhost:8001/health
curl -sf http://localhost:8001/now-playing
├── writ # Station CLI (start/stop/status/logs/generate)
├── run_operator.sh # Single operator run (station agent, with lock + timeout)
├── config/stations.yaml # Station instances, mounts, API ports, agents, paths
├── mac/
│ ├── station_config.py # Station config resolver and env exporter
│ ├── feeder.py # Playlist feeder (manages ezstream + API)
│ ├── radio.xml # Legacy ezstream config; station configs generate runtime configs
│ ├── api_server.py # Now-playing API (daemon thread in feeder)
│ ├── schedule.py # Schedule parser and resolver
│ ├── play_history.py # Track history and dedup
│ ├── music_gen_client.py # REST client for music-gen.server
│ ├── operator_prompt.md # Music-forward operator maintenance prompt
│ ├── operator_daemon.sh # Operator loop (runs run_operator.sh)
│ ├── listener_daemon.sh # Listener message polling daemon
│ ├── start_music_gen.sh # Start music-gen + daemons in tmux
│ ├── kokoro/ # Kokoro TTS wrapper
│ ├── content_generator/
│ │ ├── talk_generator.py # Talk segment generator (with --intent support)
│ │ ├── topic_bank.py # Station-local operator topic bank
│ │ ├── music_bumper_generator.py # AI music bumper generator
│ │ ├── listener_response_generator.py # Listener message → audio
│ │ ├── context.py # Operator brief and intent card templates
│ │ ├── ledger.py # Append-only editorial memory
│ │ ├── music_pools_expanded.py # Music generation prompts
│ │ ├── persona.py # Host definitions and station identity
│ │ └── helpers.py # Shared utilities
│ └── config.yaml # Local config
├── config/
│ ├── schedule.yaml # Weekly show schedule
│ └── icecast.xml.example # Icecast template
├── output/
│ ├── talk_segments/{show}/ # Generated hosted breaks
│ ├── music_bumpers/{show}/ # AI-generated music tracks
│ └── scripts/ # Script metadata
└── docs/ # Web-facing pages
MIT
79 commits
2 commits
Python
92.5%
Shell
7.5%