Personal infrastructure · Self-hosted

Lunar Vaults Radio

Six always-on music stations streamed from a NAS at home, built so family and friends hear the same song at the same second — with requests, votes, and a shared history. Five small containers, no database in the audio path, and curation that lives in the music library instead of the code.

Stack
Icecast · Liquidsoap · Python
Stations
Six, always on
Curation
Starred tracks, not config
Host
A NAS at home

▶ Open the live station

Why it exists

The music server couldn’t do “together.”

The household music server has no synchronized playback and no group sessions — everyone presses play alone. What it does have is a Radio tab that stores nothing but a URL. So the answer was to produce real radio: continuous streams the server treats like any other station, and every account sees.

That one constraint shaped everything after it. The stations had to be genuinely always-on, cheap enough to run forever on a home NAS, and boring to operate — nothing to start, nothing to schedule, nothing anyone has to remember exists.

the moving partsfive containers
stream server — one mount per station
audio engine — sources, crossfades, station idents
playlist builder — stars and playlists into station pools
interactive API — requests, votes, sign-in
now-playing page — what is on, for the household
No database in the audio path. Stop the interactive API and the music keeps playing — the stream never depends on the features around it.
The design bet

Curation is data, not code.

Nothing in the repo decides what plays. Heart a track in a phone client and a sidecar folds it into the right station within about fifteen minutes; add a song to a curated playlist and it moves stations the same way. Changing the music never means changing the system.

The same rule protects requests. A requested track is routed by matching the generated playlists themselves — never by re-deriving each station’s rules in a second place, because a second copy of a rule drifts silently the moment the first one changes.

What it does
+Shared shuffle — everyone hears the same song at the same second
+Song requests, routed to whichever station actually plays the track
+Votes on any recent play, not just the current one
+Station idents on rotation, synthesized rather than sourced
+Requests and votes fail without touching the audio path
The signal path

From a heart to everyone’s speakers.

Starany music clientPlaylistsrebuilt every 15 minLiquidsoapone engine, six sourcesIcecastone mount per stationEveryonethe same second
Curation flows one way: library → playlists → engine → stream. Nothing downstream writes back.
The mixing desk

The mix is a handful of numbers.

Every station is a weighted draw over a few sources — libraries and generated pools. The weights live in one env file, the pools rebuild every fifteen minutes, and the panel here reads the same tuning file the engine publishes: change a number on the server and this page changes with the rotation.

Weight-zero sources are omitted from the rotation entirely rather than drawn rarely — the same rule the engine applies — so what renders here is exactly what can reach the air.

Run long enough to have stories

Two failures worth keeping.

The play history has a single-writer rule — one process appends, everything else reads. A container recreation once left the history files owned by the wrong user on the old inode: every station froze its history for five days while every visible surface kept working perfectly. The fix mattered less than its ordering — recreate the container first, then repair the files, or the old mounted copy re-breaks them.

The second was subtler. Playlist stations reloaded on a fifteen-minute timer, and every reload restarts the shuffle — so a small station behaved like drawing with replacement: some tracks played over twenty times while others never came up. The repair was to reload only when the playlist actually changes, which restored a true rotation — every track exactly once per cycle.

the shuffle defect, measuredbefore
expected plays per track, one cycle  ~8
observed spread  1 to 23
cause  timed reload restarting the shuffle
fix  reload only when the playlist changes
Found in four days of the station’s own play history — the same history the lifetime stats below come from.
Sounding like a station

Idents you can prove.

Every station plays recorded idents on rotation — synthesized from a script rather than sourced, so they need no licence, are reproducible forever, and duck under the first spoken word exactly like the music beds they sit on.

Anything audible is auditioned before it ships: a montage tool renders song → ident → song with the engine’s real crossfade, so a change is judged by ear rather than by argument.

The on-air kit
+A sonic logo — one bell, musically identical every time
+Voiced liners over synthesized beds, per station
+Idents excluded from history — they are not plays
+New clips go on air by dropping a file — no restart
The lifetime record

Numbers from the station itself.

Loved on the air

Built once, on the air ever since.

Scott Matthews · Personal infrastructureLunar Vaults Radioscottmatthews.dev