Source composition
Operators such as fallback, switch,
rotate and random all do the same thing: at
any moment they pick one of their sources and stream it. The interesting
part is not the picking itself but what happens between two
sources — when one stops being streamed and another takes
over.
Think of it as a handoff between a leaving source and an entering one. Each source says how it wants to be handed over to, so you rarely have to configure the switch itself. This page walks through the situations you are likely to build.
A live show interrupting the music
The most common radio setup: music plays all day, and a live show takes over whenever someone connects.
live = input.harbor("live")
music = playlist("~/music")
radio = fallback([live, music])What happens:
- Nobody connected.
liveis not available, somusicplays. - The DJ connects, in the middle of a song. A live input does not wait for track boundaries, so it cuts in right away. The song is faded out underneath it rather than being chopped off.
- The DJ disconnects.
liveis simply gone, so there is nothing to fade out and the music comes back immediately. Because the song had been interrupted, the playlist does not resume it half-way through — it starts a fresh track.
You did not have to say any of that. input.harbor is a
live source and playlist is a file source, and each behaves
accordingly.
Jingles between songs
Now a jingle every few songs. A jingle cutting into the middle of a song would sound broken.
music = playlist("~/music")
jingles = playlist("~/jingles")
radio = rotate([music.{weight = 3}, jingles.{weight = 1}])Both are file sources, so neither is willing to interrupt the other: when the jingle’s turn comes up, the switch waits for the current song to finish. The jingle then starts cleanly at the boundary, with no fade — nothing was interrupted, so there is nothing to fade.
weight says how many tracks in a row rotate
takes from a source before moving on — three songs, then one jingle.
random uses the same method as a relative probability
instead. It defaults to 1, and it is a getter, so it can
change while the stream runs:
radio = rotate([music.{weight = {if night() then 6 else 3 end}}, jingles])A show on a schedule
show = playlist("~/morning-show")
music = playlist("~/music")
radio = switch([({8h-10h}, show), ({true}, music)])At 8h the show becomes eligible while a song is still playing. Both sides are file sources, so the show does not barge in: it starts once the current song ends. At 10h the same thing happens in reverse.
Falling back to a backup file
radio = fallback([playlist("~/music"), single("~/backup.mp3")])If the playlist cannot produce anything — no files, all requests failing — it is simply unavailable. Nothing was interrupted, so the backup starts immediately and without a fade.
An announcement that cannot wait
Sometimes a file source should interrupt. Say you push emergency announcements into a queue and they must go out now, not after the current song:
announcements = request.queue()
music = playlist("~/music")
radio = fallback([announcements.{track_sensitive = false}, music])track_sensitive = false means “I do not need to wait for
a boundary”. As soon as something lands in the queue, the song is faded
out and the announcement plays.
The fade only applies when the leaving source carries PCM
audio and nothing else. Video streams and encoded audio
(ffmpeg.copy) cannot be mixed, so those switch immediately
instead.
This is the general rule for interruptions:
A switch cuts into a song that is still playing only if at least one of the two sources involved has
track_sensitive = false. Otherwise it waits for the end of the track.
Both sides get a say, which is why adding a live input to a
fallback never changes how two playlists hand over to each
other.
A relay that carries a playlist
input.http is treated as live by default: it cuts in
immediately. But if the stream you are relaying is itself a playlist of
songs, you would rather wait for a boundary:
relay = input.http("https://relay.example.com/stream")
relay.composition_type := "file"
radio = fallback([live_show, relay, music])composition_type is either "file" or
"live" and picks which set of defaults a source gets. It is
chosen automatically — inputs that run on their own are live, files are
not — and you only set it when a source does not behave like its type
suggests.
Operators that wrap a source inherit its type, and operators that combine several are file-based only if all of their children are. Two cases are worth knowing about, because there is nothing static to inherit from:
bufferreports"live"even around a playlist. It is a generator on its own clock, so it has no child to inherit from. If you usebufferto resolve a clock conflict around file content, setcomposition_type := "file"on it.source.dynamicreports"live"while it holds nothing.
You can always ask a source what it settled on:
print(s.composition_type())Never the same jingle twice in a row
radio = rotate([music, jingles.{single = true}])single = true forbids picking that source for two
consecutive tracks.
A source that announces its own metadata
When a switch selects a source, listeners need to know what is playing — but the source may be resuming a track it already announced. So the switch replays that source’s latest metadata by default.
If a source manages its own announcements, turn it off:
radio = fallback([s1.{replay_metadata = false}, s2])Replayed metadata never overwrites metadata the source provides itself. If the entering source starts a fresh track with its own title, that title wins — the replay only fills in what would otherwise be missing.
Writing your own transition
The default handoff fades the leaving source out. To do something
else, give the entering source an
on_select:
def my_transition({ending, starting, replay_metadata = _}) =
if null.defined(ending) then
old = max_duration(3., null.get(ending))
(add([fade.out(duration=3., old), fade.in(duration=3., starting)]) : source)
else
starting
end
end
radio = fallback([s1.{on_select = my_transition}, s2])ending is the source being left, and it is
null when nothing was interrupted — so the
else branch is the “started at a boundary” case, where
there is nothing to blend.
Note the max_duration. Whatever you return keeps pulling
from ending until you stop it, and the leaving source is
only cleaned up once nothing pulls from it any more. Without a bound,
add would pull from it forever and the cleanup would never
run.
on_select runs in the streaming thread,
and the source graph you build in it is created and started right there,
then torn down when the handoff is over. Keep it cheap: no blocking
calls, no file or network access.
To keep the previous behaviour, where switching mid-track cut straight over with no fade, there is a ready-made transition:
radio = fallback([s1.{on_select = source.composition.legacy_on_select}, s2])It still replays metadata when replay_metadata is
true; it just returns starting directly.
Cleaning up after a source
The counterpart to on_select is on_leave,
which belongs to the source being left and runs once it
has been released:
radio =
fallback([
live,
music.{on_leave = fun ({track_sensitive}) ->
log("music left, finished naturally: #{track_sensitive}")}
])on_leave fires on every handoff, and
track_sensitive tells you how it ended:
true if the source had nothing left for its current track —
it reached a boundary, or simply became unavailable — and
false if the switch cut into a track it was still playing.
That is the switch’s own account of the handoff, not something the
source reports. This is what the default file behaviour uses to decide
to skip a half-played track so the source starts fresh next time.
Like on_select, on_leave runs in the
streaming thread and must return quickly. It is handed the internal
proxy wrapping your source, so its id() reads as
yoursource.proxy; the source methods you would expect
(skip, clear_last_metadata, …) all work on
it.
Where the defaults come from
Two global profiles hold the defaults,
source.composition.file and
source.composition.live. Each carries
on_select, on_leave,
track_sensitive and replay_metadata. They are
set by calling them with a complete profile record — there is no getter,
so you have to give all four fields:
source.composition.live(
{
on_select = source.composition.legacy_on_select,
on_leave = fun (_) -> (),
track_sensitive = false,
replay_metadata = true
}
)All four are looked up when they are needed rather than captured when
a source is built, so replacing a profile — or flipping a source’s
composition_type — takes effect on sources that already
exist. max_fade works the same way:
settings.source.composition.max_fade := 2.To see what a given source is actually using, ask it:
liquidsoap -h playlist
The Composition methods section lists
composition_type, track_sensitive,
replay_metadata, single,
on_select and on_leave, along with the
defaults in force. Outputs have no such section: they are the end of the
graph and never participate in a handoff.