Liquidsoap Worker (radio_worker.liq)
Liquidsoap is the radio automation engine on the production node. It reads the scheduler's playlists and continuously broadcasts them, and it hosts the live switch that lets live broadcasts (mic or restream) take over the channel.
In traditional-station terms, Liquidsoap is the on-air console + automation deck + transmitter encoder in one process: it is the only component that ever touches the actual audio stream, the only component a listener directly "hears," and therefore the only component where a bug can produce dead air, repeated songs, or stutter on air. Everything else in the system exists to feed this process a correct playlist; the most important design discipline on this project is never change the stream while the stream is live — every tuning choice below is a deliberate trade that favors continuous, clean audio over flexibility.
File locations
| File | Purpose |
|---|---|
radio_worker.liq | Primary worker script |
radio_worker.liq.bak_live | Previous revision kept for rollback |
Playout model
The playout model is the broadcast-science core of the station; the why behind each choice is explained in Broadcast Audio Science.
-
Playlist source —
playlist(mode="normal", reload_mode="watch", reload=1, playlist_file)readsplaylists/main.m3u. On every metadata change,notify_metadataPOSTs{channel:"loklok", track:"artist - title"}to the control plane's/metadataendpoint — this webhook is the single authoritative "just played" signal that feeds the scheduler's anti-repeat state (markOnAirTrack), so the two processes agree on what is actually on air even though they never share memory. The playlist is read in normal (sequential) mode: it is the rotation engine's job to decide order, and Liquidsoap's job to play it exactly as given. -
Crossfade —
cross(duration=2.0, smart_transition, ...).smart_transitionhard-sequences when either side is imaging (sfx,jingles,sweepers,shorts) so short clips are never swallowed by the 2s window; music-to-music gets a 2.0sfade.initial/fade.finaloverlap. The linear fade pair during the overlap isA linear (non-equal-power) taper is a deliberate simplicity: with all assets loudness-normalized to the same EBU R128 target (European Broadcasting Union, 2020), the overlapping tails are close in level and the center-power dip stays perceptually minor (Holman, 2010).
-
Per-track crossfade override —
crosspre-buffers the incoming track'sduration, so 0.24–0.31s stingers under a 2s window caused buffer starvation (hundreds ofEnd of track reached while buffering next track datawarnings). The scheduler annotates every short/imaging M3U entry withliq_cross_duration=<d>, whichcrossreads as its per-track override:Each value is strictly less than the shortest track in its category (stingers 0.24 s → 0.2 s). See Equations §12.1.
-
Ducking —
quiet_music = amplify(0.05, radio_music)(5% volume). Aswitchplaysquiet_musicwhile the live mic harbor is ready, full music otherwise. The ~26 dB duck keeps the music bed well below the speech-intelligibility SNR threshold so the voice cuts through (ANSI/ASA S3.5; American National Standards Institute, 1997). -
Live switch — a second
switchgives the channel_live harbor absolute priority: when a live broadcast is connected, the whole channel becomes it; the moment it drops, Liquidsoap falls back to the normal mix automatically. No engine code touches the switch — it is driven purely by "is the harbor source ready?".
- Safety + output —
mksafeinserts a silence fallback, thenoutput.icecastencodes Vorbis CBR 500 kbps, 48 kHz, stereo tolocalhost:8005/main("LOKLOK High-Fidelity - main").
Watch-mode reload (reload_mode="watch")
The worker opens playlists/main.m3u with reload_mode="watch": Liquidsoap
reloads the file and restarts from its head on every in-place write
(IN_MODIFY). This has two consequences the scheduler is built around:
- The M3U is deliberately kept stable — it is only rewritten when ~85% of the rotation has been consumed (or the daypart profile changes), so a reload never rewinds the stream onto tracks listeners just heard. See Anti-repeat & M3U rotation.
- The file must be pushed in place with a single write syscall
(
dd if='<tmp>' of='<final>' conv=notrunc bs=1M) and not viamv -for a plaincat tmp > final:mvcreates a new inode, andwatchmode uses inotify on the old inode, so the watch never fires and the new rotation is never loaded. The engine also pads every M3U to a fixed 32768 bytes (FIXED_M3U_SIZE) so dd-notrunc fully overwrites with no stale tail, and the singleIN_MODIFYmeans exactly one reload per push (the historical double-reload that replayed just-aired tracks).
Why watch rather than a scheduled reload? Because the file is the state, and
the watch is the change notification: it is push-based (fires the instant the
file changes) rather than poll-based (fires at an arbitrary interval whether or
not anything changed). The poll mode was tried and reverted — it reloads
roughly once a second unconditionally, which means the stream restarts from the
head on a cadence, defeating the entire stability design. watch mode is the
only mode that ties a reload exactly to a real rotation change. The cost is the
inode coupling documented below — a cost that is managed (never hand-edit the
m3u; always go through the engine) rather than eliminated.
Inode-freeze trap (2026-08-15): any manual remote edit of
main.m3uthat rewrites/mvs the file replaces the inode Liquidsoap watches — the rotation then silently freezes (pushed updates land on an inode liq never watches). After any manual remote m3u edit, restart Liquidsoap so it re-arms the watch on the live inode. Never hand-edit the m3u; always go through the engine's dd-notrunc push.
The M3U entries carry an annotate:type=<unquoted> prefix per line so
smart_transition knows each track's role (music vs. imaging). The type value
must be unquoted — type="foo" or quoted title/artist keys make
Liquidsoap fail to parse the annotation, and the playlist stalls on the current
track. Real on-air names come from the MP3's embedded ID3 tags instead.
Transition rules
| Segment type | Behaviour |
|---|---|
| Music → Music | 2.0-second crossfade (overlap + fade), keeping the music continuous |
| Imaging → Anything | Hard cut — instant switch for sweepers, jingles, ads, sfx, shorts |
| Into/out of short items | liq_cross_duration override — sfx/shorts 0.2s, jingles/sweepers/ads/news 0.5s pre-buffer instead of 2s (see Playout model §3) |
The shorts type (TikTok clips, 12–60 s) is hard-cut like sfx so the 2s cross
window never swallows a short clip or leaves a gap around it. Music/mashups carry
no liq_cross_duration override and keep the full 2s overlap.
Harbors (live inputs)
| Harbor | Mount | Password | Purpose |
|---|---|---|---|
input.harbor("live", port=9001, buffer=1.0) | live | hackme | Legacy live-mic input (ducked under 5% music) |
input.harbor("channel_live", port=8006, buffer=1.0) | channel_live | hackme | Channel-live takeover — ranked restream or WHIP ingest |
Connect to a harbor from an encoder with source:password@HOST:PORT/mount
(e.g. source:hackme@10.10.8.230:8006/channel_live). The engine's LiveManager
feeds 8006 via ffmpeg (icecast://source:hackme@…/channel_live).
Running
The worker is started by watch_radio.sh (cron */3) and — per its current
behaviour — never kills a healthy worker (prevents restart loops). It
dedupes stray workers, restarts only when the process is missing, and logs a
health probe (curl -s http://127.0.0.1:8005/main) to watch_radio.log.
Tuning tips
- Increase the crossfade duration for a more blended "club mix" feel; shorten it for talk-forward stations. Keep it ≤ imaging clip length (our IDs are 3–8s).
- Hard-cut imaging only works well if imaging files are already loudness-matched (the pipeline normalizes everything to EBU R128, so they are).
- If rotation stalls, check the playlists directory for a fresh M3U and confirm
the referenced files exist under
media/— mismatched paths are the most common cause of dead air. - The live switch needs nothing at all to work: connect a source to
8006and the channel goes live; stop it and the mix returns. See Live ingest.