No description
  • JavaScript 49.1%
  • Rust 36.1%
  • Python 6.4%
  • CSS 4.3%
  • Shell 2.1%
  • Other 2%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Kyle Phillips f89297dd8e
All checks were successful
CI / check-caddy (push) Successful in 14s
CI / test-node (push) Successful in 6m23s
CI / check-liquidsoap (push) Successful in 1m25s
CI / build (., deploy/node.Dockerfile, node) (push) Successful in 43s
CI / build (deploy/caddy, deploy/caddy/Dockerfile, caddy) (push) Successful in 29s
CI / build (deploy/essentia, deploy/essentia/Dockerfile, essentia) (push) Successful in 29s
CI / build (deploy/icecast, deploy/icecast/Dockerfile, icecast) (push) Successful in 27s
CI / build (deploy/liquidsoap, deploy/liquidsoap/Dockerfile, liquidsoap) (push) Successful in 28s
CI / build (deploy/analysis, deploy/analysis/Dockerfile, analysis) (push) Successful in 6m33s
fix(radio-audio): decode HE-AAC (the whole production library) through an ffmpeg pipe
The box's pre-flight found every file 96 kbps HE-AAC (tidekeeper -q
Normal), not the 320k AAC-LC the build assumed; symphonia has no SBR
decoder and the native path refused all 496 playable rows.

Decoder is now a wrapper: symphonia when it is exact, otherwise
ffpipe::FfmpegDecoder — ffprobe for rate/duration, ffmpeg spawned lazily
at the first read as f32le stereo 44.1k on a pipe, killed on drop. Seeks
up to 90 s decode from the start and discard (bit-identical to the
reference decode-check grades against: −300 dB); further seeks jump the
demuxer 2 s early and discard the pre-roll (lag 0; SBR's noise/sinusoid
indices cannot be reproduced after a jump, so only the noise realisation
differs — documented in the module). Every call site (engine, analysis,
verify-library, audition, render, seed) gets the fallback through
Decoder::open. verify-library reports how many rows go through ffmpeg;
live.rs logs each late block's lag at debug.

Gated: decode-check on a real Tidal HE-AAC file from the start and at
seeks of 1.3, 30 and 89.9 s (−300 dB, lag +0); short HE-AAC fixtures
seeded, analysed and served for 130 s with transitions and a clean drain;
workspace tests 111/0; plan --oracle 0 diffs.

CUTOVER.md records the finding; the compose pins DOWNLOAD_CMD_TEMPLATE to
-q Normal as the old stack ran, and notes SETUP_TOKEN is absent from the
old env and that the box had no backups before 19 Sep.
2026-09-19 12:10:22 +10:00
.forgejo/workflows fix(liquidsoap): the drain was stopping on the spot, and a healthcheck that asks if the clock is moving 2026-09-14 22:10:06 +10:00
deploy feat(radio): cutover — backup/verify/healthcheck, images, compose, runbook — phase 6 2026-09-17 09:59:08 +10:00
docs fix(bundle): stop import discarding the explicit flag, and true up the docs 2026-08-18 02:25:07 +10:00
radio fix(radio-audio): decode HE-AAC (the whole production library) through an ffmpeg pipe 2026-09-19 12:10:22 +10:00
server feat(radio-analysis): the native measurements, and an analysis-parity gate 2026-09-16 16:12:22 +10:00
tools feat(radio): cutover — backup/verify/healthcheck, images, compose, runbook — phase 6 2026-09-17 09:59:08 +10:00
web-ui-radio fix(ui): one header, one box model for buttons, and a feed that forgets what was removed 2026-09-15 06:06:20 +10:00
.dockerignore feat(security): what a stranger on the internet can make the radio do, bounded 2026-09-14 22:51:42 +10:00
.gitignore feat(radio): cutover — backup/verify/healthcheck, images, compose, runbook — phase 6 2026-09-17 09:59:08 +10:00
RADIO_DESIGN.md fix(bundle): stop import discarding the explicit flag, and true up the docs 2026-08-18 02:25:07 +10:00
README.md fix(bundle): stop import discarding the explicit flag, and true up the docs 2026-08-18 02:25:07 +10:00

Warehouse Radio

A self-hosted democratic radio for a workplace. Workers add songs from any phone; the shared rotation streams to the warehouse speaker as a gated web-radio station. Playback is locked by design: anyone can add to the rotation, but nobody can skip, pause, or queue during the day.

Tidal is only the catalogue and the source of files. A submitted track is downloaded to a local library and played from there, never streamed live from Tidal.


How it works

Worker phones ──▶ search Tidal ──▶ submit ──▶ auto-filters ──▶ download (tidekeeper)
                                                                     │
                                                                     ▼
                                                             local library (FLAC)
                                                                     │
                                              fair-shuffle rotation engine
                                                                     │
                    ┌────────────────────────────────┬──────────────┴───────────┐
                    ▼                                 ▼                          ▼
              stub (dev)                        MPD (LAN box)          Liquidsoap → Icecast
                                                                       (gated web radio)
                                                                              │
                                                            ┌─────────────────┴─────────────┐
                                                            ▼                               ▼
                                                   warehouse speaker 🔊              /listen (browser)
  • Submit → filter → download. Search hits the Tidal catalogue; submissions pass auto-filters (explicit / dedupe / per-worker daily cap / blocklist), then download into the library.
  • Rotation. A fair-shuffle engine picks the next track. It avoids immediate repeats and spreads plays across submitters, so nobody dominates.
  • Playback. Pluggable output: stub (dev), mpd (local speaker), or stream, where Liquidsoap pulls the rotation and pushes to Icecast. stream is the deploy target.
  • Auth. Workers sign in with a name and either a password or a passkey. The first account is admin. PINs still work: they were the original credential, and the app prompts workers to move off them rather than locking anyone out. Passkeys stay off until you set WEBAUTHN_RP_ID and WEBAUTHN_ORIGIN.
  • Listening. /listen is the player. A signed-in worker needs nothing extra; anyone without an account needs STREAM_KEY, which is a link you can share.

Full design rationale: RADIO_DESIGN.md.


Repo layout

Path What
server/src/ Node app (Express + better-sqlite3 + ws); see the module map below
server/db/radio.sql Schema: workers, library, submissions, bans, play_history
web-ui-radio/ htmx worker + admin UI, "Dispatch" design system, self-hosted htmx
deploy/ Docker Compose stack (Node + Liquidsoap + Icecast + Caddy) + deploy/README.md
.forgejo/workflows/ Forgejo Actions: validates, then builds + pushes the six images (node, liquidsoap, icecast, caddy, essentia, analysis)
docs/design-concepts.html The three UI concepts that led to "Dispatch"

Server module map (server/src/)

Module Role
index.js Bootstrap: secrets, middleware, route mounting, static UI, engine + downloader start
config.js All env-driven config (single source of truth for the knobs below)
db.js / radioDb.js SQLite connection + radio data access (workers, library, bans, history)
catalog.js / tidal.js Tidal catalogue search (mock or live) over the Tidal Open API client
filters.js Submission auto-filters (explicit / dedupe / rate-limit / blocklist)
downloader.js Bounded download queue → tidekeeper (or stub); crash-recovery, retries
rotation.js Fair-shuffle selection + deterministic "coming up" preview
radioEngine.js Playback orchestration; drives a player or serves the stream pull model
player.js Player backends: stub (simulated) and mpd (TCP)
barcode.js Code 128 encoder for the now-playing barcode; the visualiser keeps its bar widths
passkeys.js WebAuthn registration + assertion (@simplewebauthn/server)
scope.js Rotation scope: normal / banger / everything
enrichment.js · beatgrid.js · loops.js · automix.js Track analysis and the transition engine (see docs/automix-design.md)
artLabel.js Dithered album art for the player's label
bundle.js · bundleImport.js · analysisExport.js Offline test bundles and the analysis sidecar
views.js / adminViews.js Server-rendered htmx fragments (worker / admin)
routes/ui.js · routes/admin.js · routes/stream.js htmx worker UI, admin UI, Liquidsoap /internal/*
crypto.js · sessionStore.js · metrics.js · routes/ws.js AES/scrypt, SQLite sessions, Prometheus, WebSocket

Modes (all env-switchable)

Each has a dev default that runs with no external services, and a production value:

Env Dev Prod Meaning
CATALOG_MODE mock live Search source: built-in fixture, or Tidal (needs client ID + secret)
DOWNLOADER_MODE stub tidekeeper Placeholder file vs real FLAC download
PLAYER_MODE stub stream (or mpd) Simulated vs Liquidsoap→Icecast web radio (or a local MPD speaker)

Configuration

Key env vars (defaults live in server/src/config.js):

Var Purpose
DB_PATH, LIBRARY_DIR SQLite file, downloaded-audio directory
TIDAL_CLIENT_ID / _SECRET Tidal app for live search (overrides the admin panel)
COUNTRY_CODE Tidal catalogue market (e.g. AU)
DOWNLOAD_CMD_TEMPLATE tidekeeper invocation ({url} / {outdir} placeholders)
ROTATION_NO_REPEAT, ROTATION_FAIRNESS_WINDOW Rotation tuning
STREAM_INTERNAL_TOKEN Shared secret for Liquidsoap → /internal/*
STREAM_KEY Shareable listen link; grants /listen without an account
WEBAUTHN_RP_ID, WEBAUTHN_ORIGIN Passkey scope; unset means passkeys are off
TRUST_PROXY, SESSION_COOKIE_SECURE Behind Caddy in production
ENCRYPTION_KEY, SESSION_SECRET Set in production (else generated + stored in the DB)

Running

Local (no external services). Requires Node ≥ 22 (better-sqlite3 13):

cd server && npm install
DB_PATH=./data/db.sqlite CATALOG_MODE=mock DOWNLOADER_MODE=stub PLAYER_MODE=stub node src/index.js
# → http://localhost:3000  (register the first account = admin)

Full stack locally (Docker). The whole web-radio pipeline in stub/mock mode:

cd deploy
docker compose -f docker-compose.yml -f docker-compose.local.yml --env-file <your.env> up -d --build

Deploy (VPS or CasaOS, images built by Forgejo Actions): see deploy/README.md.


Note on licensing

Streaming copyrighted music to a workplace, and over a public or keyed URL, is webcasting. That normally requires licensing (in AU: APRA AMCOS / PPCA), and it is against Tidal's consumer ToS. That's an operator responsibility, not a code concern.