Commit 15695de4 by PLN (Algolia)

fix(xfade): 4 cycles was too slow to work with — and the dip was real

PLN, mid-rehearsal on the last speaker session before OPAL: "atm when i work
the track and change eg. a # gain 1.4 to # gain 1.5, it goes whole track all
d1-d12 through fadedown-fadeup over a few bars?? its not ok, i cant adjust
anymore." Then the good question: "fade d1 X -> d1 X should be constant, as
each percent of volume removed from X is added to id?"

He is right about the arithmetic and the rig breaks it in three places.

WHY CROSSFADING A PATTERN WITH ITSELF DIPS

  1. envEq/envEqR are EQUAL-POWER envelopes, g(t) = sqrt(sin(pi/2*t)), so
     g_in^2 + g_out^2 = 1. Correct — for a LINEAR gain stage.
  2. SuperDirt's law is amp = gain^4. At the midpoint both sides sit at
     g = sqrt(0.7071) = 0.8409, so each half's amplitude is 0.8409^4 = 0.5
     EXACTLY. The equal-power promise was made in the wrong domain.
  3. `overlay` fires the same sample TWICE. Two coherent voices would sum
     0.5 + 0.5 = 1.0 and be flat — that is PLN's model. What actually happens:

       orbit with `# cut N`   2nd voice releases the 1st (playSynths broadcasts
                              the cut group BEFORE amplitude is read — the same
                              mechanism as the 2026-07-29 ghost bug), one voice
                              survives at amp 0.5              => -6 dB
       orbit without cut      two independent voices, incoherent sum,
                              sqrt(.5^2 + .5^2) = 0.707        => -3 dB + comb

Deepest at t/2, unity again at t. With t=4 that is a four-bar swell on twelve
orbits at once — because each dN is its own transition and ctrl-enter re-sends
the whole blank-line-delimited block, all starting at the same `now`. Editing
one `# gain` changes one line but evaluates twelve.

THE STANDING CONCLUSION: an overlap crossfade can NEVER be transparent on a
cut-group orbit. `cut` means "one voice in this group"; a crossfade means "two".
Shortening the fade does not remove the dip, it makes it a blip instead of a
swell. The dip-free answer is a NON-overlapping transition (`clutchIn`), which
changes the feel of every seam in the set — so it waits for after OPAL (#13).
This file's own comment guessed that in July; it is now measured, not guessed.

CHANGES

- xfade default 4 -> 1 cycle. One token, so it is one token to flip back.
- xfadeCutIn gains a `t <= 0` branch returning the incoming pattern untouched.
  Needed before "0 = instant" can ever be offered: Tidal guards
  `_slow 0 _ = silence`, and `q |* gain silence` then has no right-hand values
  to match, so the INCOMING pattern comes out EMPTY. Unguarded, "0 cycles"
  means silence, not instant. Now `xfadeIn N 0 $ ...` is a real instant swap.
- Killed a trap in this file's own guidance: it recommended `p N $ ...` for fast
  iteration. `p` carries no `|< orbit`, so the sound lands on orbit 0 — wrong
  LCXL column, wrong Ardour stem, fader that looks dead — the exact failure the
  lines above it warn about. Points at `jumpTo N` / `xfadeIn N 0` instead.

VALIDATION: tools/check-boot.sh (safe mid-set, never grabs 6010) — helper block
typechecks against tidal-1.9.5, all 13 g* helpers audible with an untouched
controller, and the transition ghost check still reports 0 outgoing onsets after
the fade. Takes effect at the next Tidal boot; nothing in the running session
changed.
parent 5367121e
......@@ -143,9 +143,36 @@ let p = streamReplace tidal
xfadeCutIn t now pats = case pats of
[] -> silence
[q] -> q
(q:q':_) -> overlay (q |* gain (now `rotR` _slow t envEqR))
(playFor now (now + t) q' |* gain (now `rotR` _slow t envEq))
xfade i = transition tidal True (xfadeCutIn 4) i
-- t <= 0 must be an INSTANT swap, and it needs its own branch: Tidal
-- guards `_slow 0 _ = silence`, and `q |* gain silence` then has no
-- right-hand values to match, so the INCOMING pattern comes out empty.
-- Without this guard "0 cycles" means silence, not instant.
(q:q':_) | t <= 0 -> q
| otherwise -> overlay (q |* gain (now `rotR` _slow t envEqR))
(playFor now (now + t) q' |* gain (now `rotR` _slow t envEq))
-- LENGTH: 1 cycle, was 4 until 2026-08-03. PLN, mid-rehearsal: "atm when i
-- work the track and change eg. a # gain 1.4 to # gain 1.5, it goes whole
-- track all d1-d12 through fadedown-fadeup over a few bars?? i cant adjust
-- anymore." He is right, and the dip is real, not imagined:
-- * envEq/envEqR are EQUAL-POWER envelopes, g(t) = sqrt(sin(pi/2*t)), so
-- g_in^2 + g_out^2 = 1. Correct — for a LINEAR gain stage.
-- * SuperDirt's law is amp = gain^4. At the midpoint both sides sit at
-- g = sqrt(0.7071) = 0.8409, so each half's amplitude is 0.8409^4 = 0.5
-- EXACTLY. The equal-power promise was made in the wrong domain.
-- * `overlay` then fires the same sample TWICE. Two coherent voices would
-- sum 0.5+0.5 = 1.0 (PLN's model, and it would be flat). What actually
-- happens: on any orbit with `# cut N` the second voice releases the
-- first (playSynths broadcasts the cut group BEFORE amplitude is read —
-- the same mechanism as the ghost bug above), so ONE voice survives at
-- amp 0.5 => -6 dB. Without `cut`, two independent voices sum
-- incoherently, sqrt(.5^2+.5^2) = 0.707 => -3 dB, plus combing.
-- So a crossfade can never be transparent on a cut-group orbit: `cut` means
-- "one voice in this group", a crossfade means "two". Shortening to 1 cycle
-- does not remove the dip, it makes it a blip instead of a fade.
-- The real fix is a NON-overlapping transition (`clutchIn`) and that changes
-- the feel of every seam in the set, so it waits for after OPAL (#13).
-- Want the long dissolve back for one launch? `xfadeIn N 4 $ …`
xfade i = transition tidal True (xfadeCutIn 1) i
xfadeIn i t = transition tidal True (xfadeCutIn t) i
-- Tidal's original, ghost and all, kept reachable for comparison.
xfadeLeaky i t = transition tidal True (Sound.Tidal.Transition.xfadeIn t) i
......@@ -164,15 +191,18 @@ let p = streamReplace tidal
anticipate i = transition tidal True (Sound.Tidal.Transition.anticipate) i
anticipateIn i t = transition tidal True (Sound.Tidal.Transition.anticipateIn t) i
forId i t = transition tidal False (Sound.Tidal.Transition.mortalOverlay t) i
-- DEFAULT-XFADE track launches (#13). Every dN crossfades in over 4
-- cycles AND keeps its orbit. The naive `xfade N` LOSES the orbit that
-- DEFAULT-XFADE track launches (#13). Every dN crossfades in over 1
-- cycle (was 4 — see the dip analysis at xfade, above) AND keeps its orbit. The naive `xfade N` LOSES the orbit that
-- dN bakes in → sound lands on the wrong LCXL column / Ardour stem, so
-- the `(|< orbit N-1)` must stay composed in here. Fits the livemidi
-- model: dN evals are track LAUNCHES, so auto-blend has ~zero downside
-- and makes every track→track move clean with no code edit.
-- d1 $ <track A> … later … d1 $ <track B> ⇒ B xfades over A.
-- For an INSTANT set (fast iteration on one line) use p N $ …
-- For an orbit-correct HARD CUT use jumpTo N $ … (below).
-- For an INSTANT swap while composing/adjusting use jumpTo N $ … (below)
-- or xfadeIn N 0 $ … — both are orbit-correct.
-- Do NOT reach for `p N $ …` here: `p` has no `|< orbit`, so the sound lands
-- on orbit 0 — wrong LCXL column, wrong Ardour stem, silent-looking fader.
-- (This file used to recommend exactly that. It was wrong.)
d1 = xfade 1 . (|< orbit 0)
d2 = xfade 2 . (|< orbit 1)
d3 = xfade 3 . (|< orbit 2)
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment