Repository navigation
Crossfades between rundown items #11
robert-dean
started this conversation in
Ideas
Replies: 1 comment
|
Status check, 2026-09-22 (main at 23b983e) Done: the equal-power blend with its length taken from both records, and the head/tail trim (the app stamps Still open: the filtered handover (low-pass sweep, mid duck, bass handover) and beat matching; the ~3% clock drift ( |
0 replies
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Crossfades between rundown items
Status (2026-10-10)
The plain rung is built, and this discussion is now about what is left above it. What shipped,
so nothing below has to re-derive it:
apps/api/src/modules/playout/crossfade.tssizeseach boundary as
min(outgoing.outro, incoming.intro)from the four measured cue points, andannotate.tsstamps it twice, asliq_cross_end_durationon the outgoing record andliq_cross_start_durationon the incoming one (annotate.ts:469-507). A boundary with no blend isstamped a tenth of a second rather than zero, because a zero-length
crossnever becomes readyand drops the station onto the local bed.
stream/radio.liqholds thecross, withtype="log", curve=6.4rather thansin, which isa complementary pair and measured -3.08 dB at the midpoint. Whether a broadcast blends at all is a
resolveRulesfield, so an album stays cold.liq_cue_in/liq_cue_out.stream/voicecue.check.liqmeasured it: a cue armedsix seconds into a record with a four second blend in and an eight second blend out lands at six
seconds with no correction. The long argument that it did, and that kept this deferred for months,
is in the discussion's history and is not repeated here.
/control/skiptakesan optional
x-fade-ms. The two crossfade harnesses instream/were re-synced withradio.liqin stream: re-sync the crossfade harnesses, and measure a blend whose stamps disagree #517.
What is left: the two rungs above the plain fade, the anchor quantising they want, the drift in
on_air_elapsed, and/control/unpush. Each has its own section below.What the blend does inside the buffer
Added 2026-08-11. The built half decides how LONG the overlap is. This decides what happens
during it, and it is a separate question with a separate answer. Part of it needs the beat layer in
track-analysis rather than only the four cue points, and the plain fade
that shipped is the bottom rung of the ladder below rather than a different design.
Where the beat layer stands, 2026-10-10:
beat_thisis pinned and installed inanalysis/Dockerfile(licence reasoning inanalysis/requirements.txt), butanalysis/measure.pyemits no beats, downbeats or tempo yet, and nothing in
apps/apireads a beat confidence. So thebeat-matched rung and the anchor quantising are still blocked on #45. The filtered handover is not
(see the correction below).
A ladder gated on evidence, not on preference
The station should attempt the most ambitious transition the measurement supports and no more.
Three rungs, checked most-disqualifying first:
The numbers that work, from an implementation running against real libraries: a tempo outside
40-220 BPM disqualifies outright, beat confidence below about 0.2 on both sides drops to a plain
fade, and below about 0.55 on either side blocks beat-matching while still permitting the filtered
handover. Treat them as starting points rather than as findings, but keep the shape, because the
shape is the part that matters: missing evidence degrades to a defined behaviour instead of being
guessed at, and a tempo from metadata alone can never authorise beat-matching. That last rule is
why track-analysis stores provenance beside the value.
Corrected 2026-08-11: the middle rung does not need the beat layer, and the table above is wrong
to gate it on tempo. Read the three moves in "Three moves on the outgoing track" below and ask what
each one reads. The low-pass sweep is driven by position within the buffer. The mid-band duck is
driven by a level follower on the incoming track. The bass handover switches once at a fixed
fraction through the overlap. None of them reads a beat, a downbeat or a tempo. The tempo gate is
inherited from beat-matching, which is the rung above, and applying it here refuses a spectral
handover on precisely the pairs that most need one: two records at incompatible tempos, where a
level-only crossfade sounds worst.
So there is a rung between 1 and 2 that is available today, with no beat layer and no licence
question: everything in "Three moves" below, gated on nothing. It is not built because it is engine
work needing the filter chain per transition rather than on the bus, and because whether Liquidsoap
2.4 will modulate a filter cutoff inside a transition function is unverified. That wants a spike
before it wants a design. The bass handover is the part worth having: two basslines summing through a
six-second overlap is the one audible defect the plain rung cannot fix.
radio.liq:790-795says thesame thing where the transition is defined, and points here.
Two mechanics that are not obvious and are cheap once known:
match, counted the way a DJ counts it, and a naive ratio calls it a mismatch and drops a rung for
no reason.
the overlap only, is a beat-match. Changing playback rate is a detune, and it is audible on
anything with a sustained note.
Quantise the anchor, do not just fade at a time
A blend that starts at an arbitrary instant sounds like a blend. One that starts on a bar line
sounds intentional. Snap the transition point to the nearest phrase boundary within about four
beats, failing that to the nearest downbeat within about two, failing both use the raw time. The
tolerances are what stop quantisation from dragging the anchor somewhere musically worse than where
it started.
This one does need the beat layer: a phrase boundary and a downbeat are both outputs of it.
Three moves on the outgoing track, and one of them is not a fade
For the filtered handover, the useful part is spectral rather than level:
crossover. The record thins out and recedes rather than merely getting quieter.
compete where voices and guitars live.
Two basslines summing is the classic mud, and this is the single cheapest fix for it. It is also
the one move here that a level-only crossfade cannot approximate at all.
All three are engine work in
radio.liq, not app work, and they need the filter chain to be pertransition rather than on the bus.
What this does to the length rule
buffer = min(outgoing.outro, incoming.intro)stays the rule for the plain rung. The other tworungs want a length in BEATS rather than seconds, because the whole point is that the overlap lines
up with the music: roughly 8 beats for a compatible pair, 16 when the keys are distant or both sides
are singing, converted to seconds at the matched tempo and then clamped into the same 4-12 second
window the measurement produces. The clamp is what keeps the two rules from disagreeing, and the
min()above remains the ceiling in every case, so a blend still cannot eat a cold opening.Engine constraints, so they are not rediscovered
Measured against Liquidsoap 2.4.x, which is what
stream/Dockerfilepins. Any rung above the plainfade is built on the same operator, so these bind it too:
cross, not thecrossfadewrapper.crossfade(smart=false)silently routesthrough its own internal simple transition and ignores a custom callback entirely, so a transition
function passed to it appears to do nothing and there is no error to explain why.
persist_override=trueis required on 2.4. Without it the duration override is reset beforeit sizes the stamped track's own end-of-track buffer, so a per-item duration is accepted and then
not used. The flip side is that a stamp lingers over later unstamped tracks, so every pushed item
has to carry one, which for us is automatic, since every item goes through
itemAnnotations.track at full level while the incoming ramps in, which sums to about +6 dB and is audible as a
lurch on every transition. To vary the blend, vary the buffer, never the fade inside it. A filter
sweep is not a fade and is not bound by this, but the level envelope under it still is.
crosspresents its output as one never-ending track. Anythingtrack_sensitiveplaced aboveit never sees another boundary. Our lease gate and bed fallback are both
track_sensitive=falsealready, so they are unaffected, but this is the trap for anything addedabove the cross later.
on_air_elapsedruns about 3% slowon_air_elapsedinradio.liqcounts fixed ticks rather than measuring elapsed time: a thread addsvoice_tick(0.1s) on a thread runevery=voice_tick(radio.liq:401-407), and the ticks arriveless often than the count assumes. So a talk-up drifts later the further into a record it
sits: a fifth of a second at 6s, two thirds at 20s. Measured twice by
voicecue.check.liq, andstill the code on 2026-10-10.
Fixing it trades away the clock-step immunity that construct exists for, so it is a decision rather
than a bug. Reading a wall clock at the hook and subtracting would be exact, and would also jump
whenever the host's clock is stepped, which is the failure the tick counter was chosen to be immune
to. A monotonic source would answer both, if Liquidsoap 2.4 exposes one to a script; that is the
thing to check first. Whatever replaces it must stay a WALL clock zeroed in the
on_trackhook, nota source position: that is why the cross does not move it, and a source position would bring back
exactly the error the measurement above ruled out.
Retracting one queued item by request id
/control/pushalready answers with theridLiquidsoap assigned, anddrop_queuedinradio.liqalready does per-request removal, so a
/control/unpushtaking a single rid is a small addition. Itis not built because nothing in the app wants it: the only retraction today is "drop the whole
running order", which
/control/flushdoes (apps/api/src/modules/playout/liquidsoap.control.ts).A caller would appear if the director ever edited a lineup that was already partly handed over.
Moved from
docs/todo/crossfades.md. The file as it last stood: https://github.com/robert-dean/deadair/blob/71e431d45ce7b35a189c7d226832ec4a6247e9dc/docs/todo/crossfades.md. Rewritten 2026-10-10 down to what is still open; the full earlier text is in this discussion's edit history.All reactions