Skip to main content

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

FilePurpose
radio_worker.liqPrimary worker script
radio_worker.liq.bak_livePrevious 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.

  1. Playlist sourceplaylist(mode="normal", reload_mode="watch", reload=1, playlist_file) reads playlists/main.m3u. On every metadata change, notify_metadata POSTs {channel:"loklok", track:"artist - title"} to the control plane's /metadata endpoint — 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.

  2. Crossfadecross(duration=2.0, smart_transition, ...). smart_transition hard-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.0s fade.initial/fade.final overlap. The linear fade pair during the overlap is

    fout(t)=1tΔ,fin(t)=tΔ,Δ=2.0 sf_{out}(t) = 1 - \frac{t}{\Delta}, \qquad f_{in}(t) = \frac{t}{\Delta}, \qquad \Delta = 2.0\ \text{s}

    A 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).

  3. Per-track crossfade overridecross pre-buffers the incoming track's duration, so 0.24–0.31s stingers under a 2s window caused buffer starvation (hundreds of End of track reached while buffering next track data warnings). The scheduler annotates every short/imaging M3U entry with liq_cross_duration=<d>, which cross reads as its per-track override:

    dcross(t)={0.2 ssfx, shorts0.5 sjingles, sweepers, ads, news2.0 smusic, mashups (no override)d_{cross}(t) = \begin{cases} 0.2\ \text{s} & \text{sfx, shorts} \\ 0.5\ \text{s} & \text{jingles, sweepers, ads, news} \\ 2.0\ \text{s} & \text{music, mashups (no override)} \end{cases}

    Each value is strictly less than the shortest track in its category (stingers 0.24 s → 0.2 s). See Equations §12.1.

  4. Duckingquiet_music = amplify(0.05, radio_music) (5% volume). A switch plays quiet_music while 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).

  5. Live switch — a second switch gives 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?".

  1. Safety + outputmksafe inserts a silence fallback, then output.icecast encodes Vorbis CBR 500 kbps, 48 kHz, stereo to localhost: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 via mv -f or a plain cat tmp > final: mv creates a new inode, and watch mode 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 single IN_MODIFY means 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.m3u that 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 unquotedtype="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 typeBehaviour
Music → Music2.0-second crossfade (overlap + fade), keeping the music continuous
Imaging → AnythingHard cut — instant switch for sweepers, jingles, ads, sfx, shorts
Into/out of short itemsliq_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)

HarborMountPasswordPurpose
input.harbor("live", port=9001, buffer=1.0)livehackmeLegacy live-mic input (ducked under 5% music)
input.harbor("channel_live", port=8006, buffer=1.0)channel_livehackmeChannel-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 8006 and the channel goes live; stop it and the mix returns. See Live ingest.