A personal internet radio station. One Icecast stream, one broadcast. Every listener hears the same thing at the same time. An AI DJ picks the tracks and talks between them: station idents, time checks, the weather, a quick intro for whatever's going out next. You can ask for music in plain language; the DJ works out what you meant and slots it in.
It's radio, not a playlist. No per-listener shuffle, no skip button, no "up next for you." You tune in and hear whatever is on.
SUBWAVE.Showreel.1080p.mp4
- Project site — getsubwave.com
- Demo player — getsubwave.com/listen
- Mobile apps — native players — iOS on the App Store, Android on Google Play
- Setup walkthrough — getsubwave.com/setup
- Operator manual — getsubwave.com/manual
- Community — join the Discord
The listener player. One shared broadcast, with in-app song requests.
The admin console. Where the operator runs the station.
The Library Observatory. A data-art map of every track the DJ has tagged, placed by genre and lit by energy. Click a point for its full dossier: BPM, key, mood, embeddings, and nearest neighbours. Open it at /observatory.
- One shared Icecast stream. Every listener hears the same broadcast at the same time.
- AI DJ that picks and talks. Curates tracks, writes intros, and reads station idents, the time, and the weather.
- Plain-language requests. "Play something more upbeat" or "anything by Radiohead" works.
- Your own music library. Pulls from Navidrome over the Subsonic API. No external catalogue.
- Swappable LLM provider. Ollama, Anthropic, OpenAI, Google, DeepSeek, OpenRouter, Vercel AI Gateway, or any OpenAI-compatible server. Change it from the admin UI with no redeploy.
- Five TTS engines. Piper and Kokoro in-process for fast local speech, plus an optional
tts-heavysidecar (docker compose --profile tts-heavy up -d) that adds Chatterbox (zero-shot voice cloning) and PocketTTS (6× real-time, EN/FR/DE/IT/ES/PT). Cloud (OpenAI / ElevenLabs) is also available. Pick a different engine per kind of speech. - Multiple DJ personas. Up to 10 souls in rotation, each with its own voice and writing style.
- Dual-codec broadcast. MP3 128 kbps for Sonos, hardware radios, and cars; Ogg-Opus 96 kbps for modern browsers. The web player picks automatically.
- Native apps, PWA, and TUI. Native iOS (on the App Store) and Android (on Google Play) players — background audio, lock-screen / CarPlay / Android Auto controls, multi-station — an installable PWA on phone and desktop, and a TUI for the command line.
- Scheduled shows. A 24×7 grid; each slot has its own persona, mood, and skills.
- Pluggable skills. The DJ's between-track segments — weather, news, traffic, and your own — are skills. The built-ins are scaffolded as editable files under
state/skills/<kind>/on first boot, so you can rewrite a brief or change the news feed (BBC → your own RSS) right from the admin console — no code, no redeploy. Add your own by dropping aSKILL.md(plus optional data-fetching code) intostate/skills/, hitting Rescan, and enabling it. Seedocs/custom-skills.md. - Mood-aware rotation. Time of day, weather, and festival days bias what gets played and how the DJ talks.
- Hourly archives. Every hour saved as MP3 for later replay.
- Crossfade + voice ducking. Tracks blend smoothly; the music ducks under DJ speech and lifts back up.
- Admin console. Live status, queue, booth log, personas, shows, skills, stats, and a debug view of recent LLM calls.
- Library Observatory. A full-screen, data-art map of every tagged track at
/observatory— placed by genre, lit by energy, with a full dossier per track (BPM, key, mood, embedding fingerprints, and nearest-in-vector-space neighbours). Scales from a few hundred to tens of thousands of tracks. - MCP server. External agents (Claude Desktop, Cursor, etc.) can request songs and drive the DJ.
- Self-hosted. One
docker compose up -don a single Linux host. Optional Cloudflare in front for TLS.
This is a personal fork of perminder-klair/subwave. It tracks upstream closely and merges each release, but carries a set of enhancements that haven't been submitted upstream. Here's what differs:
When a scheduled show is active, the picker builds a brief-pool: a stratified basket of tracks pre-filtered to the show's genre, mood, and energy, drawn in popularity-weighted slices. Every LLM tool call — in both the agent and the pool picker — receives this basket as a hard constraint, so the DJ can't wander outside the show's genre even on a creative pick.
The show brief is PICKER_CRITERIA #1 (a hard constraint), not a system-prompt hint. The agent's first discovery call is also restricted to mood/energy/similarity tools when a show brief is active (MOOD_AWARE_TOOLS filter in dj-agent.ts).
Upstream moved the show brief to a softer system-prompt position in v0.16; this fork keeps it as a hard rule because we run long-format genre shows where drifting genre is a user-visible regression.
The underlying music library has ListenBrainz global listen-counts baked in as custom tags on every file — popularity_song (track-level listen-count percentile) and popularity_album (album-level). Navidrome surfaces these as custom tag columns via its API.
A weekly scheduler job (music/navidrome-api.ts) reads those custom tags and backfills two extra DB columns — popularity_song and popularity_album — so the picker can use real listen-count signal rather than inferring popularity from moods or metadata alone. Popularity is used to:
- Floor the brief-pool (tracks below the 45th popularity percentile aren't eligible for the popularity-weighted slice).
- Bias the pool picker's candidate ranking (
softRankByCompatinpicker.ts) so well-known tracks edge out obscure ones when all other signals are equal. - Weight the auto-playlist fallback (
buildCandidatesinpicker.tscalled fromscheduler.tswithpreferPopularity=true).
Without the ListenBrainz tags in the files, these columns stay NULL and the system falls back to upstream's behaviour (no popularity signal). The feature is a no-op on a library that hasn't had the tags applied.
The migration chain accounts for this: fork adds popularity as DB user_version 3 (upstream's v3 is audio_embedding_meta, which becomes fork's v4; upstream's v4–v9 are renumbered to fork's v5–v10).
Two operator-controlled filters are applied before any track enters the pick pool:
maxDurationSec— drops tracks longer than N seconds (useful for excluding DJ mixes and long-form albums).excludePatterns— a list of glob-style patterns matched against the track path; matching tracks are never picked.
Both are surfaced in the admin Settings panel and applied in picker.ts (isRadioPickable) and in the auto-playlist scheduler.
When the LLM picker fails (timeout, bad JSON, etc.), the fallback path (pickFallback in picker.ts) checks justPlayedArtists (a Set of artist keys for the current and previous track) before random-sampling from the candidate pool, so a network hiccup doesn't cause a back-to-back repeat of the same artist.
recentPicksSimilarity in music/library.ts computes pairwise text-embedding cosine similarity for the last N picks. When picks have been clustering (high similarity), the value is surfaced as a recentSimilarity flag in PICKER_CRITERIA VARIETY (#4), nudging the LLM to prefer a different genre or energy stratum over a tight similarity match.
The published subwave-tts-heavy image is built for both linux/amd64 and linux/arm64. Upstream keeps it amd64-only (citing PyTorch cross-build cost for an opt-in image). This fork runs the sidecar persistently on an Apple Silicon Mac mini and needs native performance — no QEMU tax — for acoustic analysis over a large library plus Chatterbox/PocketTTS voice synthesis.
All wheels used (torch, torchaudio, onnxruntime, chatterbox-tts, pocket-tts, librosa) publish aarch64 variants on PyPI and download.pytorch.org.
analyze.ts skips the one-ahead audio prefetch when the backend is the tts-heavy sidecar (usePrefetch = backend !== 'sidecar'). The Mac mini running tts-heavy has no access to the controller's Longhorn state volume where prefetched files land. The sidecar fetches audio from Navidrome itself when given a URL, so network cost is equivalent and the prefetch step is a no-op that would always 500.
A playlist is a list you control. Radio is a broadcast you join. SUB/WAVE is the second kind:
- One shared stream. A single Icecast mount everyone connects to. Everyone hears the same audio at the same instant. That's what makes it a station instead of a jukebox.
- No skip. Track-end is the only natural transition. The DJ — human-curated personas plus an LLM — owns the pacing, not the listener. (Operators can skip via the admin API; listeners cannot.)
- AI as the DJ, not the catalogue. The music is your own library, served by Navidrome over the Subsonic API. The LLM picks what's next and talks between tracks. It doesn't generate music and it doesn't replace your taste.
- Self-hosted and swappable. Runs on one Linux box behind Cloudflare. The LLM provider is swappable at runtime (Ollama, Anthropic, OpenAI, Google, OpenRouter, Vercel AI Gateway) with no redeploy.
curl -fsSL https://cli.getsubwave.com | sh # installs, then offers to init + start
subwave setup # connect Navidrome + LLMTwo Enter prompts during the installer (Run subwave init now?, then
Bring the stack up now?) and the stack is on-air. subwave setup
connects Navidrome and your LLM, or do the same in the browser at
http://localhost:7700/onboarding.
No clone, no Node on the host. subwave status / logs / doctor / update
work from anywhere afterwards.
If you'd rather skip our binary on your host and stick to docker compose:
mkdir subwave && cd subwave
curl -O https://raw.githubusercontent.com/perminder-klair/subwave/main/docker-compose.yml
curl -O https://raw.githubusercontent.com/perminder-klair/subwave/main/.env.example
mv .env.example .env
# Edit .env: set ADMIN_USER, ADMIN_PASS, SITE_URL (three vars, that's it).
docker compose up -d
# Then open https://your-host/onboarding. The web wizard collects Navidrome,
# LLM, TTS, DJ persona, and offers to render jingles.Functionally identical: same images, same state layout, same persistence.
The CLI just saves you the curl-and-edit dance and gives you subwave logs,
subwave doctor, etc. for the rest of the lifecycle.
Chatterbox (zero-shot voice cloning) and PocketTTS (fast multilingual) live in
a separate subwave-tts-heavy sidecar that adds ~5–6 GB of PyTorch and is
not started by default. To enable:
docker compose --profile tts-heavy up -dThe controller is wired up to discover the sidecar automatically. Stop it
again with docker compose --profile tts-heavy stop tts-heavy; the rest of
the stack keeps running and Chatterbox/PocketTTS personas silently fall back
to Piper. The old docker build --build-arg WITH_CHATTERBOX=1 path still
works if you already have a custom-built controller image — see
docker/Dockerfile.controller.
git clone https://github.com/perminder-klair/subwave.git && cd subwave
./scripts/setup.sh # scaffolds a 3-var root .env + state/
docker compose -f docker-compose.dev.yml up -d # Broadcast (icecast2 + liquidsoap) + Controller
cd web && npm install && npm run dev # web UI on :7700, separate and hot-reloading
# Then http://localhost:7700/onboarding to finish configuration.Dev compose bind-mounts controller/src/, radio.liq, and sounds/ from the
repo. Controller runs under tsx watch so src/** edits hot-reload inside
the container; radio.liq edits just need a docker compose -f docker-compose.dev.yml restart broadcast.
The standalone subwave CLI works inside the cloned repo too. cd subwave && subwave start dev does the right thing. The contributor convenience is npm start, which tsx-runs the CLI source directly so unreleased changes are
exercised. Same commands, same flags, no npm install -g needed.
The same CLI doubles as the console for running the station. Run npm start
for a status-aware menu; every menu action is also a one-shot subcommand,
appended after npm start --:
npm start # interactive operator console (status-aware menu)
npm start -- setup # first-boot wizard: Navidrome, LLM, admin, env files
npm start -- status # compose env, services, now-playing, recent events
npm start -- doctor # full diagnostic sweep
npm start -- start dev # docker compose up -d (dev or prod)
npm start -- restart broadcast # plain restart (radio.liq is bind-mounted in dev)
npm start -- restart controller # rebuild + recreate (source is COPY-d at build)
npm start -- logs controller # tail one service
npm start -- play # SUB/WAVE TUI — the terminal player
npm start -- listen # open the web player in a browser
npm start -- admin # open the admin console in a browser
npm start -- stop # docker compose down (confirms first)Single Linux host, Cloudflare terminating TLS, Caddy routing to four internal
services. The no-CLI quickstart above is
the canonical path: curl two files, fill in three vars, docker compose up -d, finish setup in the browser. See DEPLOY.md for host
prerequisites, Cloudflare setup, updates, and backup.
Bring your own reverse proxy. If you already run Traefik, nginx, or your own Caddy in your homelab, swap the bundled-Caddy compose for the BYO variant:
docker compose -f docker-compose.byo.yml up -dThat exposes the web UI on :7700, the controller API on :7701, and the
Icecast stream on :7702 (all configurable). Point your proxy at those three.
docker/Caddyfile is a working reference for the route table you need to
replicate. Details in DEPLOY.md.
Images on GHCR. Tagged releases publish to ghcr.io/perminder-klair/subwave-{caddy,broadcast,controller,web}.
All compose files pull :latest by default; pin a version with
SUBWAVE_VERSION=v1.2.3 in the root .env.
docker-compose.yml Production deploy with bundled Caddy (default)
docker-compose.byo.yml Production deploy for hosts with their own reverse proxy
docker-compose.dev.yml Local dev (broadcast + controller only; web runs separately)
controller/ Node.js controller, the AI DJ brain
src/llm/ LLM layer (AI SDK): provider registry, prompts, tools
src/broadcast/ queue, session, DJ agent, scheduler, jingles
src/music/ Subsonic client, pool picker, library tagging
src/audio/ TTS engines: Piper, Kokoro, Chatterbox, PocketTTS, cloud
src/routes/ HTTP API split by surface (public, request, onboarding, settings, …)
liquidsoap/ radio.liq, the Liquidsoap mixing pipeline
web/ Next.js 15 web UI (player, landing, admin, setup)
tui/ Terminal player, the listener UI in your terminal
docker/ Caddyfile, Dockerfiles, icecast.xml.template, supervisor entrypoint
scripts/ setup, jingle generation, update, health check
mcp-subwave/ MCP server that lets an agent request songs / drive the DJ
cli/ Operator CLI (TS, run via tsx loader, no build step)
bin/subwave Operator CLI entry: setup, status, doctor, lifecycle, play
- Controller code needs a rebuild, not a restart, because its source is
COPYd at image build time.radio.liqis bind-mounted, so a Liquidsoap restart is enough after editing it. - The LLM provider is swappable at runtime from the admin UI. Every model call goes through the Vercel AI SDK.
- There is no
/skipfor listeners. Track-end is the only natural transition; operators have an admin-only skip endpoint. - Navidrome ≥0.62 is recommended. It ships several security hardening
fixes (internet-radio management now admin-gated, transcode-config
disclosure restricted to admins, concurrent-transcode DoS limits) and the
OpenSubsonic
sonicSimilarityextension. SUB/WAVE streams withformat=rawso the transcode limits never throttle the radio, and whensonicSimilarityis enabled the picker automatically folds Navidrome's audio-based neighbours in as an extra track-selection source — no config, capability-probed, and a silent no-op when the extension is absent. Any reasonably recent Navidrome still works. - Several areas (queue/playback path,
radio.liq, the crossfade, voice ducking, the LLM layer) have non-obvious constraints that are easy to regress. Read the relevant note inCLAUDE.mdbefore touching them.
DEPLOY.md: production deployment, updates, backup.CLAUDE.md: deep architecture reference and the non-obvious constraints behind each subsystem.CONTRIBUTING.md: how to contribute.SECURITY.md: reporting security issues.mcp-subwave/README.md: the MCP server.
SUB/WAVE is playback and automation software. It does not grant you any rights to the music you broadcast through it, and it ships with no licensed content.
Owning a file — a purchased download, a CD you ripped, anything in your Navidrome library — covers your own private listening. It does not cover public performance. The moment SUB/WAVE streams to anyone but you, you are publicly performing copyrighted works, which in most countries requires licences for two separate rights:
- the musical composition (songwriting) — e.g. PRS for Music (UK), ASCAP / BMI / SESAC (US);
- the sound recording (the master) — e.g. PPL (UK), SoundExchange (US, under the statutory webcasting licence and its DMCA §114 conditions).
You are the broadcaster and you are solely responsible for clearing those rights. If you don't want to obtain licences, run the station privately (see DEPLOY.md → Make the station private), or broadcast only content you're cleared to use — music you created or own the rights to, Creative-Commons-licensed tracks, royalty-free libraries, or public-domain recordings.
This is general information, not legal advice. If you run a public station, talk to a media/IP lawyer in your jurisdiction.
MIT.













