mirror of
https://github.com/mistergibson/radio.git
synced 2026-11-03 19:31:41 -08:00
206 lines
10 KiB
Text
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
|
|
)
|