- Shell 31.8%
- Python 28%
- Liquidsoap 23.2%
- Nix 12.2%
- Just 4.8%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
The queue is a directory of files under .state/queue, so a future UI can drive it without a database or an API. liquidsoap pulls from it with request.dynamic gated on a native file.ls check (the gate matters: fallback polls readiness every frame, and an ungated null-returning callback forks a shell ~1000x/second), falls back to a rotation directory, then to a digital-silence floor so the mount exists from boot and the Amp always has something to dial. No loudness stage -- a volume knob on the Amp is the better place; see README for the three attempts that proved it. Also: nix dev shell, just task list, ingest scripts that keep the source codec, and a stdlib-only UPnP/SSDP probe for the WiiM. |
||
| config | ||
| scripts | ||
| station | ||
| .envrc | ||
| .gitignore | ||
| flake.lock | ||
| flake.nix | ||
| justfile | ||
| README.md | ||
333radio
yt-dlp in, a queue in the middle, sound out of the WiiM Amp in the living room.
Architecture
just enqueue URL ──> yt-dlp ──> media/ (tagged, original codec)
│
▼
.state/queue/NNNNNN one file per item
│
liquidsoap ──────┘ request.dynamic asks scripts/next
│ crossfade 3 s — no loudness processing, see below
▼
icecast :8000/live.mp3 320k mp3, one mount
│
▼
WiiM Amp — dials the mount as a station and holds it
The Amp is handed one URL for the evening. Everything about that choice follows from it: a renderer cannot crossfade two things it was told about separately, so the mixing has to happen server-side, which means a stream, which means liquidsoap feeding icecast. In exchange the laptop never has to control the speaker at all — put the mount URL in WiiM Home as a custom station and the Amp will play the house radio without knowing or caring that this repo exists.
Why not push one file per track over UPnP? Better sound (the Amp's own DAC, no re-encode) and fewer daemons, but every track costs a fetch-and-buffer gap and there is no way to overlap them. That is the right design for "play me this song", not for "keep the living room quietly full".
What is verified
Ran end to end on 2026-10-01 with generated tones:
just stationbrings up icecast + liquidsoap; mount answers200 audio/mpeg,icy-br:320, decoded by ffprobe as mp3 / 44.1 kHz / stereo / 320k.- Two queued tracks both reached air, in order — the queue advances.
- File tags reach the speaker: now-playing reported
Bravo - Two, fromartist/titlewritten by yt-dlp. icy-metaintappears only when the client sendsIcy-MetaData: 1. Players do send it; curl does not, which is a fun half hour if you don't know it.- The station is dialable from the first second even with an empty queue and an
empty rotation:
<sources>1</sources>and/live.mp3→ 200. That needed the silence floor —output.icecastdoes not open itsSOURCEconnection until its input is ready, so a station booting with nothing to play never created the mount and the Amp had nothing to dial. The idle stream is 320k mp3 at -91 dB (digital silence, verified — not a tone). - Rotation: junk files in
fallback/are moved to.state/junkat startup with a warning, then the rotation plays and announces its track. Queuing a request while a rotation track is in progress does not cut it short — the request takes over at the next track boundary (~16 s with an 8 s sample), which is whattrack_sensitive = trueis for. - Idle station, mounted with nothing to play: 0 log lines in 10 s, no
request-leak warnings, no latency warnings. Tracks queued afterwards still reach
air, so the
availablegate opens again correctly. - With the queue drained after connecting, icecast still reports
<sources>1</sources>: the mount stays dialled rather than dropping.
Gotchas learned the expensive way, already commented in the source
- yt-dlp 403 on the video stream. Without
-f, the default format spec isbestvideo*+bestaudio, so--extract-audiodownloads a whole video to peel the audio off it — and the video URL is where YouTube throws 403s first (unable to download video datawhile the audio you wanted is fine). Use-f bestaudio/best. request.dynamicspins at 10 forks/second when the queue is empty. Itsretry_delaydefaults to 0.1 s, and each poll is a forked shell script. Left idling overnight that is ~800k processes.retry_delay = 2.0.playlistreads every file in its directory, includingfallback/.gitkeep, and logs a decoder error for each one it cannot decode. The obvious fix —check_next— is worse than the disease: when it returns false the playlist re-asks,fallbackpolls every frame, and you get the request-leak storm. So the filter lives inscripts/station, which moves non-audio files into.state/junkbefore liquidsoap starts. The filter has to be outside the operator that would reject.- Icecast adminroot must point at the XSL files the package ships. With an
empty one,
/admin/answers 500 with nothing in the log — and because HTML error pages render from adminroot too (error-html.xsllives there), every browser request turns any error into500 - Recursive error, since icecast cannot render the page explaining the error. Browsers are the common case: they sendAccept: text/html, curl does not, which is why it reproduces in a browser and not in a shell. - ...and do not probe for
admin.xslto verify that path. Icecast 2.4 renamed the entry point todashboard.xsl, so that check fails on any modern version and silently falls back to the empty directory — the guard caused the bug it was written to prevent. Test for "any.xslhere" instead. The admin UI is HTTP basic auth (WWW-Authenticate: Basic), so a browser pops a login prompt:admin/hackme. - ffprobe reads Ogg tags at the stream level, not the format level
(
-show_format/-show_entries format_tagscome back empty for.opus, while mp3/ID3 shows up there). Checking tags in the wrong place made me write a 150-line tagger for a problem yt-dlp did not have — it tags opus correctly. Read tags withmutagen, orffprobe -show_entries stream_tags. liquidsoap:if c then radio = crossfade(...) endbindsradiolocally and the crossfade is silently discarded. Reassign through theifinstead.liquidsoap --checkcatches it; nothing else will.- An endless source is an endless track, and a track-sensitive
fallbacksits on a track until it ends. Put a permanent silence source in the same list as the queue and the station latches onto silence forever — observed: one track played, then nothing. The fix is two fallbacks: inner one track-sensitive so a song is never cut short, outer one not, so it leaves silence the moment there is something to play.sine()defaults toamplitude=1.0, sosine(0.)alone is a full-scale tone — theamplitude = 0.is load-bearing. sine()defaults toamplitude=1.0:sine(0.)is a full-scale tone, not silence.- Icecast segfaults on a malformed config rather than reporting it, and
<alias>takessource=/dest=, notalias=twice. - Icecast serves the mount verbatim:
/liveis not/live.mp3. The mount default carries the extension, and the flake's shellHook agrees with it. get_process_outputis deprecated in favour ofprocess.read(same signature), and the deprecation warning is logged once per call.fallbackpolls readiness every frame, andrequest.dynamicanswers that poll by fetching. A callback that returnsnullon an empty queue therefore becomes a fork bomb: ~1150 shell scripts a second, a "possible request leak" warning every 100 of them, andLatency is too high: we must catchup 3.8 secondsas the audio clock starves.retry_delaydoes not help — it governs the retry path, not the readiness poll.availableis the designed off-switch; feeding it a nativefile.lscheck means the forked script runs only when there is something to play.- Which is how the silence floor nearly sank the ship. Adding the floor is what started the polling. The version without it was quiet and I had measured that ("13 log lines in 12 s") — then changed the graph and never re-ran the same measurement. Idle log volume was the health signal, and I stopped watching it the moment I changed the thing it was watching.
- Two variables for one fact.
scripts/stationsetRADIO_PORTfor icecast's listener whilestation.liqdialledRADIO_ICECAST_PORT. Equal at the default, so every day-one run worked;RADIO_PORT=8001moved icecast while liquidsoap kept streaming into whatever owned 8000, and the symptom was only a mount that never appeared. Tests that pass by accident are worse than tests that fail.
Volume
Turn it up on the Amp. There is deliberately no loudness stage in the stream: a volume knob is a constant offset, applied after the DAC at the level you are actually listening at, which is a better place for "too quiet" than anything running on the laptop. Verified transparent — a file measuring −25.1 LUFS loud / −45.5 quiet comes out of the mount at −25.1, unchanged.
What a knob cannot do is match one track to the next. If yt-dlp's wildly varying
sources ever make that a real complaint, do not add a live processor; measure
each file once in scripts/grab and write ReplayGain tags into it, so the gain is
a constant per track, computed once and cached in the file. Check the WiiM app's own
volume leveling first — it may already do this for free.
Three tenants occupied this slot before it was emptied, and all three cost more than
they paid for: normalize(lufs = true) is an automatic gain control that rode gain on
a sliding window and audibly pumped quiet verses against loud choruses; any loudness
measure placed after the silence floor swells at the top of every song, because
silence wants unlimited gain until a track starts; and a track-based replacement
measured nothing at all, which is the worst outcome — dead code that reads like a
feature.
Tuning
just station reads RADIO_PORT (moves icecast and liquidsoap together),
RADIO_MOUNT (default live.mp3), RADIO_SOURCE_PASSWORD, RADIO_STATE,
RADIO_XFADE (seconds, default 3),
RADIO_BITRATE (default 320), and RADIO_NEXT_SCRIPT / RADIO_FALLBACK_DIR to
point the auto-DJ somewhere other than scripts/next and fallback/ — which is
also how you run a throwaway second instance for testing without disturbing the
one that is playing music.
Day one
direnv allow
just doctor # toolchain, encoders, LAN address, what answers
just station # icecast + liquidsoap, Ctrl-C stops both
just enqueue https://youtu.be/… # pulls it and queues it
just queue
just listen # hear it locally before blaming the Amp
Then in WiiM Home: Music Services → add a custom station →
http://<the address just doctor prints>:8000/live.mp3.
Not done
- No web UI yet —
enqueueis a CLI, andpnpm devhas nothing to run. - Icecast's browser admin UI is not worth fighting. The 500 was mine (empty
adminroot) and is fixed; what remains is that
/admin/stats.xsland friends 404 in 2.5 even though the files exist and auth is correct. The JSON API is healthy —just info, or/admin/stats?mode=json— and the frontend this repo is about to grow should render queue and now-playing itself rather than adopt icecast's stylesheet stack. - The Amp is assumed to reach icecast. Same wifi/VLAN, and never
127.0.0.1. - UPnP control is unproven on this LAN: no MediaRenderer answered M-SEARCH and nothing had TCP 1400 open, with the Amp asleep. It is only needed for volume and power, so it is a nice-to-have rather than a dependency.
- Nothing watches for icecast or liquidsoap dying. That is the NixOS module's job.