# SUB/WAVE — production without the bundled reverse proxy. Use this when you # already run Traefik/nginx/Caddy: web, controller, and broadcast bind host # ports (${WEB_PORT:-7700} / ${CONTROLLER_PORT:-7701} / ${ICECAST_PORT:-7702}) # and your proxy fronts them. Liquidsoap stays internal to the broadcast # container. # # Ports bind 0.0.0.0 by default; if your proxy runs on THIS host, set # BIND_ADDRESS=127.0.0.1 in .env so the services are only reachable on loopback. # # The web image is baked for same-origin /api + /stream.mp3, so point your proxy # at ONE hostname and replicate docker/Caddyfile's route table: # /api/listener-auth → 404 / deny (do this one FIRST — see below) # /stream* → host:${ICECAST_PORT} (keep path; disable buffering) # /listen.pls /listen.m3u → host:${CONTROLLER_PORT} (keep path) # /api/* → host:${CONTROLLER_PORT} (strip the /api prefix) # everything → host:${WEB_PORT} # Split hostnames need a web rebuild with NEXT_PUBLIC_API_URL / # NEXT_PUBLIC_STREAM_URL (baked at build time). # # Block /api/listener-auth at your proxy: it is Icecast's URL-auth callback and # answers 200/401 on the shared privacy.password, so routing it hands the # internet a password oracle (#478). Icecast reaches the controller directly # over the compose network and never needs it through the proxy. # # State persists in /state (override with STATE_DIR); bind mount, so # `docker compose down -v` won't touch it. x-state: &state-mount ${STATE_DIR:-./state}:/var/sub-wave # Optional host relocation for the stem cache (STEMS_DIR in .env). The # container path is fixed because compose cannot know which station is active; # SUBWAVE_STEMS_DIR hands that path to the controller and the broadcast # entrypoint, which re-append the per-station segment under it. Unset leaves # the cache under the state dir, exactly as before. x-stems: &stems-mount ${STEMS_DIR:-${STATE_DIR:-./state}/stems}:/var/sub-wave/stems # Cap container log growth (10m × 3 ≈ 30MB/service). x-logging: &default-logging driver: json-file options: max-size: "10m" max-file: "3" services: # ------------------------------------------------------------------------- # BROADCAST — icecast2 + liquidsoap in one container # ------------------------------------------------------------------------- broadcast: image: ghcr.io/perminder-klair/subwave-broadcast:${SUBWAVE_VERSION:-latest} build: context: . dockerfile: docker/Dockerfile.broadcast container_name: sub-wave-broadcast restart: unless-stopped logging: *default-logging environment: - ICECAST_SOURCE_PASSWORD=${ICECAST_SOURCE_PASSWORD:-} - ICECAST_ADMIN_PASSWORD=${ICECAST_ADMIN_PASSWORD:-} - ICECAST_RELAY_PASSWORD=${ICECAST_RELAY_PASSWORD:-} # Concurrent-listener ceiling (). Empty → 100. - ICECAST_MAX_CLIENTS=${ICECAST_MAX_CLIENTS:-} # No bundled edge here, so nothing resolves by default and listener rows # show your proxy's address. Set this to that proxy's IP (as icecast sees # it) for real listener IPs in admin → Listeners. See docs/deployment.md. - ICECAST_TRUSTED_PROXY_IPS=${ICECAST_TRUSTED_PROXY_IPS:-} - ICECAST_TRUSTED_PROXY_HOSTS=${ICECAST_TRUSTED_PROXY_HOSTS:-} - TZ=${TZ:-Europe/London} # Container path of the STEMS_DIR bind mount above — empty when the # operator did not relocate, which keeps the cache under the state dir # exactly as before. Named apart from STEMS_DIR on purpose: that one is a # HOST path and reaches the controller through `env_file: ./.env`. - SUBWAVE_STEMS_DIR=${STEMS_DIR:+/var/sub-wave/stems} ports: - "${BIND_ADDRESS:-0.0.0.0}:${ICECAST_PORT:-7702}:7702" extra_hosts: - "host.docker.internal:host-gateway" volumes: - *state-mount - *stems-mount - ${STATE_DIR:-./state}/logs:/var/log/liquidsoap healthcheck: test: ["CMD-SHELL", "curl -fsS http://localhost:7702/status-json.xsl > /dev/null"] interval: 5s timeout: 3s retries: 12 start_period: 15s # ------------------------------------------------------------------------- # CONTROLLER — AI DJ brain, bound to host for your reverse proxy # ------------------------------------------------------------------------- controller: image: ghcr.io/perminder-klair/subwave-controller:${SUBWAVE_VERSION:-latest} build: context: . dockerfile: docker/Dockerfile.controller args: # Version reported by the controller; unset → controller/package.json. - SUBWAVE_BUILD_VERSION=${SUBWAVE_BUILD_VERSION:-} container_name: sub-wave-controller restart: unless-stopped logging: *default-logging depends_on: broadcast: condition: service_healthy # So a selective `up -d controller` also brings the socket-proxy up. # Remove this entry too if you drop the proxy below. docker-socket-proxy: condition: service_started environment: - NODE_ENV=production - TZ=${TZ:-Europe/London} - STATE_DIR=/var/sub-wave - SOUNDS_DIR=/sounds # Container path of the STEMS_DIR bind mount above — empty when the # operator did not relocate, which keeps the cache under the state dir # exactly as before. Named apart from STEMS_DIR on purpose: that one is a # HOST path and reaches the controller through `env_file: ./.env`. - SUBWAVE_STEMS_DIR=${STEMS_DIR:+/var/sub-wave/stems} # Optional Chatterbox/PocketTTS sidecar (--profile tts-heavy); # unreachable URL → fall back to Piper. - TTS_HEAVY_URL=${TTS_HEAVY_URL:-http://tts-heavy:8080} # Acoustic-analysis sidecar (default-on below). Probed, then local venv. - ANALYZE_URL=${ANALYZE_URL:-http://analyzer:8080} # Admin Stats panel via the socket-proxy — the controller never touches # the raw Docker socket. Unset to disable. - DOCKER_HOST=tcp://docker-socket-proxy:2375 env_file: - ./.env extra_hosts: - "host.docker.internal:host-gateway" ports: # BIND_ADDRESS=127.0.0.1 keeps the admin API off the network when the # proxy is same-host (see header). - "${BIND_ADDRESS:-0.0.0.0}:${CONTROLLER_PORT:-7701}:7701" volumes: - *state-mount - *stems-mount healthcheck: test: ["CMD-SHELL", "curl -fsS http://localhost:7701/health > /dev/null"] interval: 10s timeout: 5s retries: 6 start_period: 20s # ------------------------------------------------------------------------- # DOCKER-SOCKET-PROXY — locked-down Docker API for the Stats system panel # ------------------------------------------------------------------------- # Read-only, GET-only, CONTAINERS-section-only slice of the Docker API over # internal TCP. Optional: remove it (plus the controller's DOCKER_HOST + # depends_on entry) to drop the Stats panel. docker-socket-proxy: image: ghcr.io/tecnativa/docker-socket-proxy:0.3.0 container_name: sub-wave-docker-proxy restart: unless-stopped logging: *default-logging environment: - CONTAINERS=1 volumes: - /var/run/docker.sock:/var/run/docker.sock:ro # ------------------------------------------------------------------------- # WEB — Next.js listener UI, bound to host for your reverse proxy # ------------------------------------------------------------------------- web: image: ghcr.io/perminder-klair/subwave-web:${SUBWAVE_VERSION:-latest} build: context: . dockerfile: web/Dockerfile args: - SITE_URL=${SITE_URL:-} - NEXT_PUBLIC_GA_ID=${NEXT_PUBLIC_GA_ID:-} # Admin footer version; unset → web/package.json (web/next.config.js). - SUBWAVE_BUILD_VERSION=${SUBWAVE_BUILD_VERSION:-} container_name: sub-wave-web restart: unless-stopped logging: *default-logging depends_on: # The homepage renders per-request against the controller — starting web # before it is healthy serves broken first pages. controller: condition: service_healthy environment: - NODE_ENV=production - SUBWAVE_HOMEPAGE=${SUBWAVE_HOMEPAGE:-player} - SUBWAVE_DJ_BRAIN_ENABLED=${SUBWAVE_DJ_BRAIN_ENABLED:-false} - SITE_URL=${SITE_URL:-} # Set to 1 to keep the shared product pages (landing, docs, news, # catalogs) self-canonical + in this install's sitemap instead of # crediting getsubwave.com (web/lib/site.ts IS_OFFICIAL_SITE). - SUBWAVE_INDEX_ALL=${SUBWAVE_INDEX_ALL:-} # GA Measurement ID at RUNTIME (web/lib/ga.ts) — analytics turn on with a # `web` recreate, no rebuild. - GA_ID=${NEXT_PUBLIC_GA_ID:-} # Server-side base URL for generateMetadata; internal compose name. - CONTROLLER_INTERNAL_URL=http://controller:7701 ports: - "${BIND_ADDRESS:-0.0.0.0}:${WEB_PORT:-7700}:7700" # ------------------------------------------------------------------------- # TTS-HEAVY (optional) — sidecar for Chatterbox + PocketTTS # ------------------------------------------------------------------------- # NOT started by default (#103): # docker compose -f docker-compose.byo.yml --profile tts-heavy up -d # With the profile off, the controller falls back to Piper. tts-heavy: image: ghcr.io/perminder-klair/subwave-tts-heavy:${SUBWAVE_VERSION:-latest} build: context: . dockerfile: docker/Dockerfile.tts-heavy args: # GPU opt-in (source build only) — see docs/gpu-tts.md. CHATTERBOX_TORCH_INDEX_URL: ${CHATTERBOX_TORCH_INDEX_URL:-https://download.pytorch.org/whl/cpu} # RTX 50-series only: override chatterbox's torch==2.6.0 pin. CHATTERBOX_TORCH_SPEC: ${CHATTERBOX_TORCH_SPEC:-} # amd64-only image; pinned so it runs under emulation on arm64 hosts. platform: linux/amd64 container_name: sub-wave-tts-heavy restart: unless-stopped logging: *default-logging # OOM containment: a runaway model load dies here, not via the host # OOM-killer taking broadcast/controller with it. mem_limit: ${TTS_HEAVY_MEM_LIMIT:-10g} profiles: ["tts-heavy"] environment: - TTS_HEAVY_DEVICE=${TTS_HEAVY_DEVICE:-cpu} - POCKET_TTS_VOICE=${POCKET_TTS_VOICE:-alba} # Which engines to load (comma-separated); each costs RAM + weights. - TTS_HEAVY_ENGINES=${TTS_HEAVY_ENGINES:-chatterbox,pocket-tts} # Idle seconds before an engine's worker is stopped and its memory # returned (empty = 1800 cuda / 3600 cpu; 0 = always resident). See # .env.example and #1579. - TTS_HEAVY_IDLE_UNLOAD_S=${TTS_HEAVY_IDLE_UNLOAD_S:-} - CHATTERBOX_IDLE_UNLOAD_S=${CHATTERBOX_IDLE_UNLOAD_S:-} - POCKET_TTS_IDLE_UNLOAD_S=${POCKET_TTS_IDLE_UNLOAD_S:-} # Seconds /speak waits for a cold engine to load before giving up and # letting the DJ fall through to its rescue voice. - TTS_HEAVY_LOAD_TIMEOUT_S=${TTS_HEAVY_LOAD_TIMEOUT_S:-} # Fallback Chatterbox reference WAV when a line has no persona voice of # its own — a path inside this container (see docker-compose.yml). - CHATTERBOX_REFERENCE_WAV=${CHATTERBOX_REFERENCE_WAV:-} # Optional — PocketTTS voice cloning (#238): accept the terms at # huggingface.co/kyutai/pocket-tts and set HF_TOKEN. - HF_TOKEN=${HF_TOKEN:-} volumes: - *state-mount # Persist HF caches across recreates, or the multi-GB weight fetch # repeats every time. - tts-heavy-chatterbox-cache:/opt/chatterbox/hf-cache - tts-heavy-pocket-cache:/opt/pocket-tts/hf-cache # ------------------------------------------------------------------------- # ANALYZER — acoustic-analysis sidecar (bpm/key/intro/loudness; optional # CLAP "sounds-like" embeddings + Demucs vocal ranges) # ------------------------------------------------------------------------- # Starts by default; only the tts-heavy voices stay opt-in. # Off switch: ANALYZER_REPLICAS=0 in .env removes the container (the next # `up -d` stops and deletes it) — for operators who run analysis elsewhere via # ANALYZE_URL and don't want a redundant idle image. Unset/empty = 1 = the # default-on station, unchanged. A PROFILE can't express this: profiles are # opt-in only, and an empty interpolated profile drops the service for anyone # who sets COMPOSE_PROFILES at all (which docs/tts-heavy.md tells Unraid and # Portainer operators to do). See docs/tts-heavy.md#turning-the-analyzer-off. analyzer: # Default: LEAN multi-arch. ANALYZER_HEAVY=1 in .env → CLAP + Demucs heavy # image (amd64-only; on arm64 also set DOCKER_DEFAULT_PLATFORM=linux/amd64). image: ghcr.io/perminder-klair/subwave-analyzer${ANALYZER_HEAVY:+-heavy}:${SUBWAVE_VERSION:-latest} build: context: . dockerfile: docker/Dockerfile.analyzer args: # Local build mirrors the pulled image: lean unless ANALYZER_HEAVY set. WITH_CLAP: ${ANALYZER_HEAVY:+1} WITH_DEMUCS: ${ANALYZER_HEAVY:+1} container_name: sub-wave-analyzer # 0 = don't create this container at all (see the off switch above). # 0 and 1 are the ONLY valid values: container_name is fixed above, and # compose refuses a fixed name for >1 replica, so 2 fails every command. deploy: replicas: ${ANALYZER_REPLICAS:-1} restart: unless-stopped logging: *default-logging # OOM containment: a runaway analysis dies here, not via the host # OOM-killer taking broadcast/controller with it. mem_limit: ${ANALYZER_MEM_LIMIT:-6g} environment: # Force CLAP / Demucs on for the whole pass; usually the admin toggles # drive these per request. - ANALYZE_AUDIO_EMBEDDING=${ANALYZE_AUDIO_EMBEDDING:-} - ANALYZE_VOCAL_ACTIVITY=${ANALYZE_VOCAL_ACTIVITY:-} # Torch device for CLAP/Demucs: auto (default) / cpu / cuda. - ANALYZE_DEVICE=${ANALYZE_DEVICE:-} # Idle seconds before the worker drops CLAP/Demucs models (0 = never; # default 300 cuda / 1800 cpu — see #1204). - ANALYZE_IDLE_UNLOAD_S=${ANALYZE_IDLE_UNLOAD_S:-} # Idle seconds before the sidecar recycles the whole worker process # (default 3600; 0 = never). - ANALYZE_RECYCLE_IDLE_S=${ANALYZE_RECYCLE_IDLE_S:-} - CLAP_MODEL=${CLAP_MODEL:-} - CLAP_MODEL_PATH=${CLAP_MODEL_PATH:-} # Demucs model + analysis-window overrides (see .env.example). - DEMUCS_MODEL=${DEMUCS_MODEL:-} - ANALYZE_SECONDS=${ANALYZE_SECONDS:-} - ANALYZE_CONCURRENCY=${ANALYZE_CONCURRENCY:-} - ANALYZE_CLAP_WINDOWS=${ANALYZE_CLAP_WINDOWS:-} - ANALYZE_OUTRO_SECONDS=${ANALYZE_OUTRO_SECONDS:-} # Anonymous HF downloads are rate-limited; same var as tts-heavy. - HF_TOKEN=${HF_TOKEN:-} volumes: - *state-mount - *stems-mount - analyzer-cache:/opt/analyzer/hf-cache volumes: tts-heavy-chatterbox-cache: tts-heavy-pocket-cache: analyzer-cache: