# CLAUDE.md This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. ## What this is SUB/WAVE is a personal internet radio station: one Icecast stream, all listeners hear the same broadcast, AI DJ picks tracks and reads scripts between them. See `README.md` for the architecture diagram and rationale. ## Common commands Three operator entry points, all driving the same compose files + `state/` layout: the **standalone `subwave` CLI** (single binary, no clone — default for new installs), raw `docker compose` (no-CLI alternative), and `npm start` (contributor convenience inside a clone). ```bash # --- dev (Mac smoke test, requires git clone) --- docker compose -f docker-compose.dev.yml up -d # Broadcast (icecast2+liquidsoap) + Controller (tsx watch) cd web && npm install && npm run dev # web UI on :7700, separate process docker compose logs -f controller # prod default curl http://localhost:7700/api/health # liveness via Caddy edge (prod) ``` The CLI resolves its install location via `SUBWAVE_HOME` (priority: `--home` → `SUBWAVE_HOME` env → `~/.config/subwave/config.json` → cwd if it has a `docker-compose.yml` → `~/subwave` if it exists → error). The cwd fallback is what makes `cd subwave-repo && npm start` work with zero config. There is no `/skip` endpoint — track-end is the only natural transition. Liquidsoap controls pacing. **Compose files live at the repo root**, not under `docker/`. One root `.env` is the entire boot config surface — everything else lives in `state/settings.json`, managed by the wizard + admin UI. **Dev hot-reloads; prod needs a rebuild.** In dev compose, `controller/src/`, `controller/scripts/`, and `radio.liq` are bind-mounted and the controller runs `tsx watch`, so edits restart in-place. In **prod** images `COPY` source at build time, so `restart` reruns the *same baked-in code* — changes need `up -d --build`. ```bash docker compose -f docker-compose.dev.yml restart controller # rarely needed — tsx watch handles src/** edits docker compose -f docker-compose.dev.yml restart broadcast # after radio.liq edits in DEV (bind-mounted) docker compose up -d --build controller # after controller/src/** in PROD docker compose up -d --build broadcast # after radio.liq / icecast.xml.template / Dockerfile.broadcast in PROD ``` `web` runs as a Next.js dev server (`npm run dev`) and hot-reloads in dev; prod builds the web image and needs a rebuild like the others. **Lint is the merge gate; tests are not.** `controller/` and `web/` each expose `npm run lint` (`eslint . && tsc --noEmit`), and `mcp-subwave/` exposes `npm run lint` (`tsc --noEmit`); CI runs all three on every PR (`.github/workflows/lint.yml`, plus a theme-token mirror drift check on `controller`) and those jobs are what block a merge. `controller/` also has a test suite — `npm test` (`scripts/run-tests.ts`) auto-discovers every `scripts/*.test.ts` and runs each as its own tsx subprocess, so a test that fails by `process.exit(1)` and one that fails by a thrown `assert` are caught the same way. **Dropping a `*.test.ts` file into `controller/scripts/` is the whole registration step** — no package.json edit. `npm test -- ` filters to matching files. `web/` and `mcp-subwave/` have no tests. Since CI doesn't run any of this, run `npm test` yourself before pushing controller changes. ## Architecture Four cooperating processes with **file-based IPC** through a shared `state/` dir (mounted at `/var/sub-wave` in containers). This is the load-bearing fact about how the system works — there is no socket or RPC channel between controller and Liquidsoap. - **Controller → Liquidsoap**: - `next.txt` — controller writes one annotated track URI; Liquidsoap polls every 1.0s, drains, and `request.queue.push`es it. - `say.txt` — WAV path; polled every 0.5s, fed through `voice_queue`, **heavy-ducked** (`smooth_add p=0.22`). Station IDs, hourly time, weather, request intros. - `intro.txt` — between-track auto-DJ links; polled every 0.5s, fed through `intro_queue`, **light-ducked** (`smooth_add p=0.30`) so the song that just started stays audible under the voice. - `auto.m3u` — fallback playlist the controller rewrites every `AUTO_QUEUE_REFRESH_MINUTES` (default 60) for the current mood; Liquidsoap reloads on file change (`reload_mode="watch"`). - `liquidsoap_*.txt` (jingle_ratio, crossfade, opus_enabled, opus_bitrate, flac_enabled, aac_enabled, aac_bitrate, …) — tiny files written by `settings.update()`, read once at `radio.liq` startup. Changes need a Liquidsoap restart (controller triggers via `/restart-mixer` → telnet). - **Liquidsoap → Controller / UI**: - `now-playing.json` — written from `music_meta.on_metadata(on_meta)`. The hook must stay on `music_meta`, the **pre-cross** handle captured before `music` is wrapped in `cross(...)`. Hooking the post-cross source fires twice per transition (because `dj_transition` passes `initial_metadata=` into both `fade.in` and `fade.out`), freezing the UI one song behind. `on_metadata` is used instead of `on_track` because `on_track` gets swallowed by source switches (request queue → auto playlist). - `jingle-playing.json` — `{filename, startedAt}` written when a jingle starts feeding the jingle rotate (issue #997). Jingles play outside the controller's voice serialiser (and `music_meta` is captured above the rotate, so the incoming track's metadata fires while the stinger is still in the cross buffer); `airVoice` (`broadcast/queue.ts`) reads this and holds every spoken segment until the stinger's window (start + clip length + crossfade) has passed. `jingleRatio: 0` disables jingles entirely (radio.liq skips the rotate; needs mixer restart like every ratio change). - `bed-playing.json` — `{filename, startedAt}` written when a track annotated `subwave_kind="bed"` starts (beds — an instrumental the DJ talks over BETWEEN songs, so a long link isn't talked over the song it's introducing). A bed is pushed into `dj_queue` like any other request but is NOT a song: `on_meta` branches on the annotation BEFORE its `title != ""` gate, so a bed never reaches `now-playing.json` and never pushes an ICY title. That silence is also why the marker exists — the controller learns "the bed is on air, air the link now" only from this file (`queue.onBedStarted`, read on the same 1.5s now-playing tick). Same shape and rationale as `jingle-playing.json`. - **Controller → Web UI**: HTTP. `useStationFeed` (`web/hooks/useStationFeed.js`) polls `/now-playing` + `/state` every 5s. - **Controller state**: `session.json` — the live DJ session (chat-history JSON, see `broadcast/session.js`); archived to `state/sessions/.json` on roll. Controller-internal, not read by Liquidsoap. - **Browsers → Icecast**: direct `