Commit be52627d by PLN (Algolia)

fix(boot): the "fade into oblivion" was the PREVIOUS pattern cutting the new one dead

The bug that ate PLN's whole afternoon, and the reason he was working around it by
hand: "so for now hush then ctrl+enter seems like a fix ahah but not a lovely one :D".

## The symptom, and the clue that cracked it

  "wait i feel theres still a xfade issue? when i leave d5 on the 33 effcet, it
   fades in oblivion!"                                    (and d4, d7, d8, d12...)
  "reboot-then-ctrl-enter: doesnt seem to obliviate, loops forever. sounds like a
   track-to-track state bug"

Same code, two outcomes, depending on history. That is the whole diagnosis in one
sentence — and `hush` curing it is the confirmation, because `streamHush`
PREPENDS silence to the pattern history.

## The mechanism

`BootTidal.hs:100` made every dN a 4-cycle crossfade (#13):

    xfade i = transition tidal True (Sound.Tidal.Transition.xfadeIn 4) i
    d5 = xfade 5 . (|< orbit 4)

Tidal's xfadeIn is one line:

    xfadeIn t now (pat:pat':_) = overlay (pat |* gain rising) (pat' |* gain falling)

The envelopes are correct — measured with queryArc, the incoming rises 0.442 ->
1.0 and the outgoing falls 0.990 -> 0.0 across the 4 cycles, then both saturate.
The bug is that the outgoing pattern is turned DOWN but never OFF. `|*` takes
structure from the LEFT, so `pat'` keeps every event it ever had. Measured: at
cycle 64 of a 4-cycle fade, the outgoing pattern still emits one onset per cycle.

**And a gain-0 event is not a harmless event.** SuperDirt broadcasts the cut group
in `playSynths` (DirtEvent.sc:182-189) *before* amplitude is ever read — `~amp`
only appears later, at line 160, as an argument to the gate synth — and for a
positive `cut` it sets `cutAllSamples: 1`, releasing EVERY voice in the group
regardless of sample. So the silent ghost keeps killing the incoming pattern's
voice on every single hit, forever. The orbit is not fading out. It is being cut
to death by its own predecessor.

Every observation follows, including the ones that looked contradictory:

  * only orbits that own a `# cut` die. In take_5_drops, where PLN said "weirdly
    all d123 stay", d1/d2/d3 have NO cut group — and the ones he reported dying in
    vague_de_crime (d4, d5, d7) have cut 4, cut 5, cut 7.
  * a fresh boot is fine: one pattern in history, and `xfadeIn _ _ (pat:[]) = pat`
    returns it untouched, envelopes and all.
  * "4 bars" is the fade length. You hear the OLD pattern fade out — it wins the
    cut, being overlaid second and therefore sent last — and the new one never
    arrives at all.
  * d8's "weird glitches instead of proper heading breaks": cut 8, a chopped break
    fighting its own ghost.

Not explained by this, and still open: take_5_drops' d4, which has no cut group at
all. That fade is a separate lead (it shares `crushbus 41` with d7 — see the new
pvlint PV011).

## The fix

Keep Tidal's envelopes EXACTLY, and stop the outgoing pattern when its fade ends:

    xfadeCutIn t now (q:q':_) =
      overlay (q |* gain rising) (playFor now (now + t) q' |* gain falling)

Measured, onsets per cycle (incoming/outgoing), fading at cycle 0 over 4:

    xfadeIn 4    1/1 1/1 1/1 1/1 | 1/1 1/1 1/1 1/1    ghost forever
    clutchIn 4   0/1 0/1 1/0 1/0 | 1/0 1/0 1/0 1/0    clean, never overlaps
    xfadeCut 4   1/1 1/1 1/1 1/1 | 1/0 1/0 1/0 1/0    clean from cycle 4

`clutchIn` is also clean — it degrades one pattern into the other so they never
overlap at all — and is arguably the better transition for a rig where most orbits
own a cut group. It is NOT the default here because it changes the feel of every
transition on the rig, and 7 days before a gig is not when to do that. It stays
available as `clutchIn`, and Tidal's original stays reachable as `xfadeLeaky` for
A/B comparison.

Audibly, for a cut orbit, the transition becomes: the old pattern fades out over 4
bars, the new one arrives at full exactly as its envelope reaches 1.0. Which is
what PLN already describes hearing — except that now the new one arrives.

## Validation

- The binding was lifted VERBATIM out of BootTidal.hs and typechecked against the
  type `transition` demands (`Time -> [ControlPattern] -> ControlPattern`). The
  stream block cannot be typechecked in place — it closes over a live `Stream` —
  so this is how that part of the file gets checked at all.
- `tools/check-boot.sh` gains **Pass 5**, a permanent gate: it lifts `xfadeCutIn`
  out of the boot file and counts onsets from the outgoing pattern at cycles
  4/5/8/16/64/256. Must be 0.
- Mutation-tested, which is the only reason to trust it: with `playFor` removed
  the gate reports 6 ghost onsets and fails. Passes 1-4 all stay green on the
  broken version — it typechecks, it parses as Pulsar sends it, and it emits
  events. Counting the outgoing onsets is the only check that can see this class
  of bug, which is exactly why it now runs on every boot check.
- All 32 blocks still parse as single statements (#79 seam intact) — the new
  multi-line `case` sits inside the existing `let`, so it is still one statement.
parent 6ed637de
......@@ -97,8 +97,58 @@ let p = streamReplace tidal
all = streamAll tidal
resetCycles = streamResetCycles tidal
setcps = asap . cps
xfade i = transition tidal True (Sound.Tidal.Transition.xfadeIn 4) i
xfadeIn i t = transition tidal True (Sound.Tidal.Transition.xfadeIn t) i
-- THE GHOST FIX (2026-07-29) — why `xfade` is no longer Tidal's `xfadeIn`.
--
-- PLN, mid-recording: "wait i feel theres still a xfade issue? when i leave
-- d5 on the 33 effcet, it fades in oblivion!" … then, decisively:
-- "reboot-then-ctrl-enter: doesnt seem to obliviate, loops forever. sounds
-- like a track-to-track state bug" … "so for now hush then ctrl+enter seems
-- like a fix ahah but not a lovely one :D".
--
-- Tidal's xfadeIn is ONE line:
-- xfadeIn t now (pat:pat':_) = overlay (pat |* gain rising)
-- (pat' |* gain falling)
-- The gain envelopes are correct (measured: rising 0.44→1.0, falling
-- 0.99→0.0 over the 4 cycles). The bug is that the OUTGOING pattern is
-- turned DOWN but never OFF. `|*` takes structure from the left, so pat'
-- keeps every event it ever had, forever, at gain 0. Measured with
-- queryArc: at cycle 64 of a 4-cycle fade, the outgoing pattern still emits
-- one onset per cycle.
--
-- A gain-0 event is not a harmless event. SuperDirt broadcasts the cut group
-- in `playSynths` BEFORE amplitude is read (DirtEvent.sc:182-189), and for a
-- positive `cut` it sets cutAllSamples=1 — releasing EVERY voice in the
-- group. So the silent ghost keeps killing the incoming pattern's voice on
-- every single hit. The orbit is not fading: it is being cut to death by its
-- own predecessor.
--
-- Everything PLN observed follows from that:
-- * only orbits with `# cut N` die — d1/d2/d3 (drums, no cut) survived
-- * a fresh boot is fine: one pattern in history, and xfadeIn returns it
-- untouched (`xfadeIn _ _ (pat:[]) = pat`), so there is no ghost
-- * `hush` fixes it because streamHush PREPENDS silence to the history,
-- making the ghost silent-and-eventless
-- * "4 bars" is the fade length: you hear the OLD one fade out (it wins
-- the cut, being overlaid second) and the new one never arrives
--
-- The fix keeps Tidal's envelopes EXACTLY — the transition sounds identical
-- for every orbit that works today — and only stops the outgoing pattern
-- when its fade is over, with `playFor`. Measured, onsets per cycle:
-- xfadeIn 4 1/1 1/1 1/1 1/1 | 1/1 1/1 1/1 1/1 ghost forever
-- xfadeCut 4 1/1 1/1 1/1 1/1 | 1/0 1/0 1/0 1/0 outgoing stops at 4
-- `clutchIn 4` is also clean (it degrades one pattern into the other, so
-- they never overlap at all) but it changes the FEEL of every transition on
-- the rig, and 7 days before a gig is not when to do that. It stays
-- available as `clutchIn` for when that dissolve is what you want.
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
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
histpan i t = transition tidal True (Sound.Tidal.Transition.histpan t) i
wait i t = transition tidal True (Sound.Tidal.Transition.wait t) i
waitT i f t = transition tidal True (Sound.Tidal.Transition.waitT f t) i
......
......@@ -192,3 +192,64 @@ if [ -x "$ROOT/tools/check-boot-blocks.py" ]; then
exit 1
fi
fi
# --- Pass 5: does the default dN transition leave a GHOST? (2026-07-29) ------
# The bug PLN chased for a whole afternoon, in one sentence: Tidal's `xfadeIn`
# turns the OUTGOING pattern down but never off, so it emits events forever at
# gain 0 — and a gain-0 event still broadcasts its cut group (SuperDirt
# DirtEvent.sc:182-189 fires the cut in playSynths BEFORE amp is read, with
# cutAllSamples=1 for a positive cut). The ghost therefore keeps releasing the
# INCOMING pattern's voice on every hit, forever. Symptom: "ctrl+enter i hear
# sth, which fades" on every orbit that owns a `# cut`, cured only by `hush`
# (which prepends silence to the pattern history) or a reboot (which leaves one
# pattern in history, and xfadeIn returns a lone pattern untouched).
#
# Passes 1-4 cannot see this: it typechecks, it parses, and it emits events. The
# only way to catch it is to COUNT ONSETS FROM THE OUTGOING PATTERN AFTER THE
# FADE. This pass lifts the real `xfadeCutIn` out of BootTidal.hs verbatim and
# asserts that count is zero.
GHOSTCHK="$WORK/GhostCheck.hs"
if python3 - "$BOOT" "$GHOSTCHK" <<'PYEOF'
import pathlib, sys
boot, out = pathlib.Path(sys.argv[1]), pathlib.Path(sys.argv[2])
src = boot.read_text().splitlines()
try:
start = next(i for i, l in enumerate(src) if l.strip().startswith("xfadeCutIn t now pats"))
end = next(i for i, l in enumerate(src) if i > start and l.strip().startswith("xfade i ="))
except StopIteration:
sys.exit(3) # binding absent -> nothing to check (reported by the caller)
body = [l[4:] if l.startswith(" ") else l for l in src[start:end]]
out.write_text("\n".join([
"import Sound.Tidal.Context",
"import qualified Data.Map as Map",
"xfadeCutIn :: Time -> Time -> [ControlPattern] -> ControlPattern",
*body,
'nm e = case Map.lookup "s" (value e) of { Just (VS x) -> x ; _ -> "?" }',
"outgoing c = length [() | e <- queryArc",
' (xfadeCutIn 4 0 [s (pure "NEW") # cut (pure 5), s (pure "OLD") # cut (pure 5)])',
' (Arc c (c+1)), eventHasOnset e, nm e == "OLD"]',
"main = do",
" let ghosts = sum (map outgoing [4,5,8,16,64,256])",
' putStrLn (" outgoing onsets after a 4-cycle fade: " ++ show ghosts)',
" if ghosts == 0 then putStrLn \" ok the outgoing pattern STOPS\"",
" else do { putStrLn \" FAIL the outgoing pattern is a ghost that keeps cutting\" ; ioError (userError \"ghost\") }",
]) + "\n")
PYEOF
then
if ghc -package tidal -package containers -o "$WORK/ghostchk" \
-outputdir "$WORK/ghosto" "$GHOSTCHK" >"$WORK/ghost.log" 2>&1; then
echo "check-boot: transition ghost check (the outgoing pattern must stop) ..."
if "$WORK/ghostchk"; then
echo "check-boot: OK — no cut-stealing ghost after a transition."
else
echo "check-boot: FAIL — the default dN transition leaves a ghost." >&2
exit 1
fi
else
echo "check-boot: WARN — ghost check did not build:" >&2
tail -5 "$WORK/ghost.log" >&2
fi
else
echo "check-boot: WARN — no xfadeCutIn binding in BootTidal.hs; the default" >&2
echo " transition is Tidal's leaky xfadeIn (see Pass 5 comment)." >&2
fi
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