fallback, switch, rotate and random all do the same thing: at any moment they pick one of their sources and stream it. Picking is the easy part. The hard part — and the one that has generated more mailing list threads than anything else in Liquidsoap — is what happens between two sources, when one stops being streamed and another takes over.

PR #5077 reworks that entirely.

The problem with switch-wide parameters

Until now, track_sensitive, transitions, transition_length, override and replay_metadata were parameters of the switch: one value, applied to everyone. That works right up until your sources disagree — which, on a real radio, they always do:

radio = fallback(track_sensitive=false, [live, jingles, music])

You wanted “the live show interrupts whatever is playing”. You also got “a jingle can chop a song in half”, because track_sensitive belonged to the fallback, not to the sources. Set it back to true and now the DJ has to wait for the current song to end before going on air.

There was no way to say live interrupts, jingles wait. The switch had exactly one opinion to give and three sources that needed different ones.

Composition as a handoff

In the new model, composition is not configured on the switch at all. It is negotiated between the leaving source and the entering one, and each source carries its own answer.

live  = input.harbor("live")
music = playlist("~/music")

radio = fallback([live, music])

No parameters, and this already does the right thing: the DJ cuts in mid-song with the music faded out underneath, and when they disconnect the playlist comes back on a fresh track rather than resuming the one that was interrupted.

Add jingles, and they wait:

radio = rotate([music.{weight = 3}, jingles.{weight = 1}])

Both are file sources, so neither is willing to interrupt the other: three songs, then a jingle, each starting cleanly at a track boundary with no fade — nothing was interrupted, so there is nothing to fade. And crucially, putting a live input next to them changes none of that. Every handoff involves exactly two sources, and only those two have a say.

Sometimes a file source should interrupt. Emergency announcements pushed into a queue need to go out now, not after the current song:

announcements = request.queue()

radio = fallback([announcements.{track_sensitive = false}, music])

Other things a source can ask for have nothing to do with interrupting at all — never being picked twice in a row, for instance:

radio = rotate([music, jingles.{single = true}])

You did not configure any of this on the switch. input.harbor is a live source and playlist is a file source, each with its own defaults; when a source does not behave like its type suggests, you say so once, on the source, and every switch it appears in follows.

From there it is per-source methods all the way down: track_sensitive, replay_metadata, single, weight, composition_type, plus on_select and on_leave for writing your own transitions and cleanup:

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. That distinction was impossible to express with the old transitions parameter.

Go read the docs

This is a genuinely different way of thinking about switching, and it is worth reading the whole thing rather than pattern-matching from a blog post:

  • Source composition — the concepts, walked through the setups you are likely to build: live shows, jingles, scheduled shows, backup files, emergency announcements, relays and custom transitions. It also covers the details this post glosses over — when a fade actually applies, how metadata replay interacts with a source’s own metadata, and why an on_select that mixes ending needs a duration bound.
  • Migration notes — this is a breaking change. transitions, transition_length and override are gone from switch operators, and track_sensitive / replay_metadata moved to the sources. The mapping is mechanical, and source.composition.legacy_on_select restores the old no-fade behaviour where you want it. A handful of stdlib operators (append, prepend, fallback.skip, map_first_track, overlap_sources) also stopped pinning track_sensitive and now inherit it — worth a look if you use them.

The short version of why it is worth the churn: the old API asked you to configure a switch. The new one asks each source to describe itself once, and then every switch it appears in does the right thing on its own.