mirror of
https://github.com/mistergibson/radio.git
synced 2026-11-03 19:31:41 -08:00
bug fixes
This commit is contained in:
parent
5ee5f19255
commit
369910f561
13 changed files with 662 additions and 1533 deletions
157
station.liq
157
station.liq
|
|
@ -1,8 +1,20 @@
|
|||
# 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 #{...}
|
||||
|
|
@ -20,6 +32,15 @@ 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
|
||||
|
|
@ -36,31 +57,137 @@ jingles_pl = playlist(jingles_dir)
|
|||
announcements_pl = playlist(announcements_dir)
|
||||
podcasts_pl = playlist(podcasts_dir)
|
||||
|
||||
# Rotate among the four directories; transition_length bounds each handoff.
|
||||
rotating_bg = rotate(transition_length=0.5, [music_pl, jingles_pl, announcements_pl, podcasts_pl])
|
||||
# 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.
|
||||
background_music = crossfade(fade_in=0.5, fade_out=0.5, rotating_bg)
|
||||
# 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("")
|
||||
|
||||
# --- Show queue source (reads current_queue.txt each tick) ---
|
||||
# In 2.2.x the callback must return a request (type ()->request), not null.
|
||||
# When the queue is empty we return a NON-resolving URI ("noshow:"). Liquidsoap
|
||||
# fails to resolve it, so this source goes DOWN -- which is exactly what lets
|
||||
# fallback() drop through to the rotating background music below. (A resolvable
|
||||
# sentinel like synth: would keep the show source up forever, masking the music.)
|
||||
def read_queue()
|
||||
qf = "#{queue_dir}/current_queue.txt"
|
||||
if file.exists(qf) and file.contents(qf) != "" then
|
||||
request.create(file.contents(qf))
|
||||
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
|
||||
request.create("noshow:")
|
||||
# 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
|
||||
|
||||
show_source = request.dynamic(prefetch=1, read_queue)
|
||||
# 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)
|
||||
|
||||
# --- Mux: show takes priority, fall back to background music ---
|
||||
main_out = mksafe(fallback([show_source, background_music]))
|
||||
# --- 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(
|
||||
|
|
|
|||
Loading…
Reference in a new issue