radio/station.liq
2026-10-18 02:20:50 -07:00

206 lines
10 KiB
Text

# station.liq - Cascadia Digital Collective Radio Automation
# Liquidsoap 2.2.4-1+dev compatible
# Run from /srv/radio so relative paths resolve correctly
#
# One queue, one primitive. Both kinds of scheduled content go through a single
# request.dynamic reading current_queue.txt:
# * "show" entries -> a finite episode file (local path or media URL)
# * "stream" entries -> a continuous live Icecast/Shoutcast mount URL
# The JRuby scheduler writes whichever URI is due into current_queue.txt at its
# scheduled clock time; liquidsoap polls the file each tick and plays it. When
# the file is empty the source goes DOWN and the mux falls through to the
# looping background music.
settings.log.file.set(false)
# Level 3: normal verbosity. (Level 4 was used while hunting the transition
# bug and reverted once the crossfade fix was confirmed live on 15 Oct.)
settings.log.level.set(3)
# --- Load config ---
# Explicit type annotation required: without it, fields used only in #{...}
# interpolations default to null (see liquidsoap json docs).
let json.parse ({ storage, icecast = { host, port, mount, source_username, source_password } }
: { storage: string,
icecast: { host: string, port: int, mount: string,
source_username: string, source_password: string } })
= file.contents("config.json")
storage_base = storage
music_dir = "#{storage}/music"
jingles_dir = "#{storage}/jingles"
announcements_dir = "#{storage}/announcements"
podcasts_dir = "#{storage}/podcasts"
queue_dir = "#{storage}/queues"
# --- Silence fallback resource ------------------------------------------------
# The empty-queue branch of read_queue returns a request that RESOLVES (a real
# silent file) instead of one that fails. The file lives beside this script;
# station.rb exports RADIO_DIR (its own resolved directory) before exec'ing
# liquidsoap, so no install path is baked in here and the service works
# wherever it is installed.
radio_dir = environment.get(default="", "RADIO_DIR")
silence_uri = "file://#{radio_dir}/silence.mp3"
icecast_host = host
icecast_port = port
icecast_mount = mount
icecast_user = source_username
icecast_pass = source_password
print("Config loaded: storage=#{storage_base}, icecast=#{icecast_host}:#{icecast_port}#{icecast_mount}")
# --- Background source: rotate among the featured directories, with 0.5 s
# crossfades between tracks. Each playlist(dir) reads its directory
# recursively, so audio in sub-directories is included automatically.
music_pl = playlist(music_dir)
jingles_pl = playlist(jingles_dir)
announcements_pl = playlist(announcements_dir)
podcasts_pl = playlist(podcasts_dir)
# Rotate among the four directories. NO transition_length and NO transitions:
# both default to a plain hard cut (transition_length defaults to 5.0, which is
# what the outgoing track's "4.94585034014s remaining" was reporting -- rotate
# was composing a ~5s layer transition while the crossfade above it composed a
# 0.5s blend over the SAME boundary, so the two got out of sync and the
# crossfade's buffer went stale). rotate now hands off with a hard cut;
# crossfade is the only operator that blends.
rotating_bg = rotate([music_pl, jingles_pl, announcements_pl, podcasts_pl])
# Crossfade 0.5 s between consecutive tracks of the rotated stream. This is the
# ONLY operator composing transitions; see the note above.
#
# duration=0.5 is the fix for the buffer-emptied collapse. `duration` is NOT the
# fade length -- it is how much audio crossfade buffers IN ADVANCE from each
# track to compute the blend, and it defaults to 5.0. We were fading for 0.5s
# but buffering 5.0s, and the 2.2.4 release notes say plainly that a track
# shorter than the buffered duration is a programming error: all its data gets
# consumed computing the fade-in and none is left, which is exactly the
# "Buffer emptied, buffering needed" collapse in the journal. Sizing the buffer
# to the fade (0.5) means the crossfade only ever holds half a second of each
# track, so it cannot outrun the data.
# `minimum` is deliberately OMITTED. Per the build's own `-h crossfade`,
# `minimum` is a threshold on the CROSS's own duration ("Minimum duration for a
# cross"), and a cross can never exceed `duration` -- so any minimum above 0.5
# here is a bar nothing can clear, and it vetoed EVERY handoff with
# "Not enough data for crossing". The default (-1.) always tries, which is what
# we want: short-track protection is already delivered by duration=0.5 itself,
# since the fade only ever consumes half a second of each track.
background_music = crossfade(fade_in=0.5, fade_out=0.5, duration=0.5, rotating_bg)
# --- Scheduled-content source (reads current_queue.txt each tick) -------------
# Both kinds of scheduled content go through this single request.dynamic:
# * "show" entries -> a finite episode file (local path or media URL)
# * "stream" entries -> a continuous live Icecast/Shoutcast mount URL
# The scheduler writes whichever URI is due into current_queue.txt; an empty
# queue makes this source go DOWN so the mux falls through to background.
#
# last_uri remembers the URI this callback last HANDED OUT, so a poll that
# finds the queue file unchanged does not re-queue the same request. It is a
# string ref on purpose: ref : ('a) -> () -> 'a takes its type from the
# initial value, so ref(null) pins to a bare nullable that == cannot compare
# (orderable-type error). ref("") pins to string and compares cleanly.
last_uri = ref("")
def read_queue()
qf = "#{queue_dir}/current_queue.txt"
if file.exists(qf) and file.contents(qf) != "" then
let u = string.trim(file.contents(qf))
# Always hand out the URI the queue file currently names, and remember it.
# A duplicate is NOT re-queued here: the poller below decides whether to
# call fetch() at all, and it only does so when this URI changes. Feeding a
# request on a duplicate poll was the bug -- fetch() APPENDS to the queue
# (doc: "Try feeding the queue with a new request"), so a poller that fired
# every 5s on an unchanged file added one silence request per poll, with
# nothing draining them (prefetch=0 has no consumer). The source stayed UP
# behind a pile of silence, so fallback never handed off to background
# music, and the queued show never played.
last_uri.set(u)
request.create(u)
else
# Empty queue: return a request that RESOLVES, not one that fails.
# "noshow:" is an unregistered protocol, so request.create("noshow:")
# never resolves; a request.dynamic whose callback keeps returning
# failing requests goes permanently DOWN and stops polling the file --
# which is why the URI written at 12:00:10 was never seen until a
# restart. A real silent file resolves through the static "file"
# protocol, keeps the source UP and polling, and is inaudible, so
# fallback still prefers a real show the moment one appears.
if radio_dir == "" then
print("WARNING: RADIO_DIR is unset; start via station.rb or the silence fallback will not resolve")
end
request.create(silence_uri)
end
end
# prefetch=0: the poller below is the ONLY thing that feeds this source, so
# every callback invocation has exactly one cause and the queue cannot be
# filled by two mechanisms at once (prefetch=1 plus a poller is the "played
# twice" bug).
show_source = request.dynamic(prefetch=0, read_queue)
# --- Re-invocation poller -----------------------------------------------------
# Why this exists: a "stream" entry is captured by ffmpeg and the LOCAL capture
# path is queued while ffmpeg is still writing it, so liquidsoap opens a
# growing file and reads it as an open-ended stream. At the writer's EOF it
# wraps back to byte 0 instead of firing request-finished -- and the dynamic
# callback is only re-invoked on request end, so the NEXT queued URI is never
# pulled. That is the 16:00 capture looping instead of handing off to 17:00.
#
# fetch() re-invokes the callback on demand (confirmed on this build), so a
# recurrent thread calls it on a wall clock and the next queued URI cuts in
# regardless of whether the current capture ever ends. fast=false keeps this
# below request resolution in priority, since fetch() can block.
#
# The poller -- not the callback -- owns the fetch decision. It reads the queue
# file itself and calls fetch() ONLY when the file names a URI different from
# the one already handed out. On a duplicate it does nothing, so no request is
# appended and the queue stays bounded. (fetch() appends; calling it on a timer
# without a consumer is what grew the queue to 50 silence requests.)
poll_interval = 5.
def poll_queue()
qf = "#{queue_dir}/current_queue.txt"
if file.exists(qf) and file.contents(qf) != "" then
let u = string.trim(file.contents(qf))
if u != last_uri() then
# ignore(): fetch() returns bool, but an if with no else must be unit.
# Without this the branch's bool becomes the if's value and the type
# checker rejects it (Error 5: unit vs bool). ignore converts it.
ignore(show_source.fetch())
end
end
poll_interval
end
thread.run.recurrent(fast=false, delay=0., poll_queue)
# --- Mux: scheduled content first, then background music ----------------------
# fallback picks the first child that is UP. With the queue empty, show_source
# is down and background_music carries the broadcast. track_sensitive=false is
# what makes a queued show cut in live at its appointed time instead of
# waiting for the current background track to finish.
mux = fallback(track_sensitive=false, [show_source, background_music])
# NO crossfade here. The blender lives INSIDE background_music (above), where
# every transition it composes is a track end it controls. A crossfade placed
# above the fallback was asked to blend layer handoffs it could not control:
# expiry kills the running show mid-frame (a skip, not a track end), and the
# docs warn a crossfade whose track "ends without any warning" may not have
# enough data to compose with. Those half-composed frames are the "bad data"
# that stopped clients. Layer handoffs are hard cuts now, which is exactly how
# live radio beds work; the encoder only ever sees complete, valid frames.
main_out = mksafe(mux)
# --- Output to Icecast ---
output.icecast(
%mp3(bitrate=128),
host=icecast_host,
port=icecast_port,
user=icecast_user,
password=icecast_pass,
mount=icecast_mount,
name="Road Unit Radio",
description="Community radio automation",
genre="various",
url="",
public=true,
main_out
)