Commit 77903244 by PLN (Algolia)

fix(boot): the DJF "fix" was a parse error — BootTidal.hs could not load at all

Mid-rehearsal, PLN reported the three DJ filters still misbehaving after
yesterday's #48 bidirectional rewrite. He described three symptoms:

  C1 centre->0  : no LPF effect on d3 (hats)
  C1 centre->0  : kick goes from TOTALLY high-passed to normal
  C1 centre->127: d3 high-passes

Those fingerprint the *previous* (2026-07-25) definition exactly —
`gHPF ch = (# hpf (range 20 8000 ch))`, one-directional, no lpf term:
at centre hpf=4010Hz (kick gutted), at 0 hpf=20Hz (normal), at max
hpf=8000Hz. Four observations, four matches. So the running ghci was not
executing the new code. The question was why.

Root cause: the new gDJF was written as a right section containing another
operator of the same fixity —

    gDJF ch = (# lpf (...) # hpf (...))     -- `#` is infixl 9

which is a hard Haskell parse error, not a subtle type slip. GHC:
"The operator '#' of a section must have lower precedence than that of the
operand". One bad line takes down the entire enclosing `let` block, i.e.
EVERY g* helper — gF1/2/3, gMask, gMute*, gPanic, the drum helpers.

What made it dangerous rather than merely broken: nothing surfaced it. The
live ghci still held the older definitions from before the edit, so the rig
kept making sound and behaving plausibly while the file on disk was
unloadable. The failure was scheduled for the next cold boot — i.e. on
stage at OPAL. Same shape as #21's mute-bomb: a definition that lives only
in a running process is not a verified definition.

Fix: express it as two composed sections, which is also semantically
cleaner (two independent unions rather than one nested):

    gDJF ch = (# lpf (...)) . (# hpf (...))

The filter math itself was correct and is unchanged — verified at three
points: v=0 -> lpf 180 / hpf 20 (low-pass closed to subbass), v=0.5 ->
lpf 20000 / hpf 20 (both wide open = true bypass at the centre detent),
v=1 -> lpf 20000 / hpf 8000 (high-pass climbed to superhigh). Applied to
both BootTidal.hs and the live/lib/prelude.tidal rescue sheet, which had
inherited the identical broken form.

Also adds tools/check-boot.sh so this class of bug cannot ship again: it
extracts the helper let-block, dedents it into a real module, stubs the two
stream-bound names it closes over (p, setI), and runs `ghc -fno-code`.
Types only — no codegen, no stream, no port 6010 grab, safe to run mid-set.

Validation, both directions:
  - real BootTidal.hs      -> OK, exit 0 (whole 164-431 block typechecks
                              against tidal-1.9.5 / ghc 9.4.7)
  - bug reintroduced       -> FAIL, exit 1, pointing at the exact line

That negative test earned its keep immediately: the first version of the
guard used `ghc | grep -q` under `set -o pipefail`, where a failing ghc
makes the pipeline non-zero regardless of grep's verdict, silently
inverting the check. It reported OK on a file it had just proven broken.
Now it runs ghc once and tests the captured exit code. A guard that has
never seen the bug it targets is a hypothesis, not a guard.
parent a1e860a1
...@@ -302,21 +302,35 @@ let -- DPV specific parameters ...@@ -302,21 +302,35 @@ let -- DPV specific parameters
midiGdef = midiG' 1 0 1 -- midiGain default midiGdef = midiG' 1 0 1 -- midiGain default
midiG ch = midiG' ch 0 1 -- midiGain default midiG ch = midiG' ch 0 1 -- midiGain default
-- ============================================= -- =============================================
-- Global filters (#21) — SAFE-AT-REST -- Global filters (#21, #48) — SAFE-AT-CENTRE bidirectional DJ filter
-- The old form was gF1 = (# djfbus 1 (range 0.05 0.95 "^49")) and it -- The old form was gF1 = (# djfbus 1 (range 0.05 0.95 "^49")) and it
-- was a live footgun: `djfbus` is NOT a registered SuperDirt effect, and -- was a live footgun: `djfbus` is NOT a registered SuperDirt effect, and
-- the LCXL fader RESTS AT 0 → every gF-wrapped stream got an extreme -- the LCXL knob RESTS AT 0 → every gF-wrapped stream got an extreme
-- value into a missing bus → sound muted mid-set with no way back. -- value into a missing bus → sound muted mid-set with no way back.
-- Fix: use CORE params (hpf/lpf, always registered) mapped so that -- The 2026-07-25 fix made rest(0)=bypass, but only as TWO separate
-- REST (fader down = 0) == BYPASS. Untouched ⇒ no effect, ever. -- one-directional controls (gF1=hpf-only, gF2=lpf-only, gF3=spare) —
-- Push a fader up ⇒ the filter sweeps in. Cannot mute you. -- not what these three knobs (C1/C2/C3, CC49/50/51) actually are on
-- Live-confirmed 2026-07-25 ("f123 djfs seem to work great overall"). -- the hardware: three independent DJ-mixer-style filters, CENTRE =
-- no filtering, sweep down = low-pass closing in (toward subbass
-- only), sweep up = high-pass climbing (toward superhigh only).
-- Corrected 2026-07-27 mid-rehearsal (PLN: "each knob 1/2/3 was doing
-- a DJF: middle had no filter, total left = almost only subbass,
-- total right = almost only superhigh").
-- gDJF is safe AT THE CENTRE (ch=0.5), not at 0: both lpf and hpf
-- stay at their wide-open values until you move off centre in either
-- direction, so an untouched knob (rest ≈ centre detent) is bypass,
-- same guarantee as the #21 fix, just centred instead of zeroed.
-- ============================================= -- =============================================
gHPF ch = (# hpf (range 20 8000 ch)) -- fader up ⇒ high-pass climbs; rest(0)=hpf 20 ≈ open -- NB: written as two composed sections, NOT (# lpf .. # hpf ..). A right
gLPF ch = (# lpf (range 20000 180 ch)) -- fader up ⇒ low-pass closes; rest(0)=lpf 20k ≈ open -- section may not contain another operator of the same fixity, and `#` is
gF1 = gHPF "^49" -- CC49 fader = high-pass build (tension riser) -- infixl 9 — the nested form is a hard PARSE ERROR that takes the whole
gF2 = gLPF "^50" -- CC50 fader = low-pass sweep (muffle/drop) -- `let` block (i.e. every g* helper) down with it. Typechecked against
gF3 = id -- CC51 spare, held safe (id = never touches sound) -- tidal-1.9.5 with `ghc -fno-code` before landing.
gDJF ch = (# lpf (range 180 20000 (fmap (\v -> 1 - 2 * max 0 (0.5 - v)) ch)))
. (# hpf (range 20 8000 (fmap (\v -> 2 * max 0 (v - 0.5)) ch)))
gF1 = gDJF "^49" -- knob C1 = DJ filter (centre=open, left=lpf/subbass, right=hpf/superhigh)
gF2 = gDJF "^50" -- knob C2 = DJ filter, same shape
gF3 = gDJF "^51" -- knob C3 = DJ filter, same shape
-- PANIC CHORD (#34) — hold LCXL push buttons 73+74+91+92 together; the SC -- PANIC CHORD (#34) — hold LCXL push buttons 73+74+91+92 together; the SC
-- bridge (start_and_midi.scd) edge-detects the chord and flips a persistent -- bridge (start_and_midi.scd) edge-detects the chord and flips a persistent
-- "^93" toggle, echoed on the LCXL LEDs (lit = active). Tidal side is just -- "^93" toggle, echoed on the LCXL LEDs (lit = active). Tidal side is just
......
...@@ -26,8 +26,8 @@ ...@@ -26,8 +26,8 @@
-- same "untouched = no effect, ever" guarantee from #21, just recentred). -- same "untouched = no effect, ever" guarantee from #21, just recentred).
-- ===================================================================== -- =====================================================================
:{ :{
let gDJF ch = (# lpf (range 180 20000 (fmap (\v -> 1 - 2 * max 0 (0.5 - v)) ch)) let gDJF ch = (# lpf (range 180 20000 (fmap (\v -> 1 - 2 * max 0 (0.5 - v)) ch)))
# hpf (range 20 8000 (fmap (\v -> 2 * max 0 (v - 0.5)) ch))) . (# hpf (range 20 8000 (fmap (\v -> 2 * max 0 (v - 0.5)) ch)))
gF1 = gDJF "^49" -- knob C1 = DJ filter (centre=open, left=lpf/subbass, right=hpf/superhigh) gF1 = gDJF "^49" -- knob C1 = DJ filter (centre=open, left=lpf/subbass, right=hpf/superhigh)
gF2 = gDJF "^50" -- knob C2 = DJ filter, same shape gF2 = gDJF "^50" -- knob C2 = DJ filter, same shape
gF3 = gDJF "^51" -- knob C3 = DJ filter, same shape gF3 = gDJF "^51" -- knob C3 = DJ filter, same shape
......
#!/usr/bin/env bash
# check-boot.sh — typecheck BootTidal.hs WITHOUT booting anything.
#
# Why this exists (2026-07-27, mid-rehearsal):
# a DJF "fix" shipped to BootTidal.hs as (# lpf .. # hpf ..) — a right
# section containing another operator of the same fixity, which is a hard
# Haskell PARSE ERROR (`#` is infixl 9). One bad line took the whole `let`
# block down, i.e. EVERY g* helper. And nothing surfaced it: the running
# ghci still held the old definitions, so the rig kept making sound while
# the file on disk was unloadable. The bug would only have detonated at the
# next boot — on stage.
#
# The lesson is the rig's recurring one: a definition that lives only in a
# running process is not verified. Prove it structurally, cheaply, cold.
#
# Method: extract the big helper `let` block, dedent it into a real module,
# stub the few stream-bound names (p, setI) it closes over, and run
# `ghc -fno-code`. Types only — no code generated, no stream started, no
# port 6010 grabbed. Safe to run mid-set.
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
BOOT="${1:-$ROOT/BootTidal.hs}"
WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT
# Locate the helper block: the `:{ let ...` that defines the g* helpers,
# found by content rather than hardcoded line numbers so edits don't rot it.
START="$(grep -n '^let -- DPV specific parameters' "$BOOT" | cut -d: -f1)"
if [ -z "$START" ]; then
echo "check-boot: FAIL — could not locate the helper let-block in $BOOT" >&2
exit 2
fi
END="$(awk -v s="$START" 'NR>s && /^:\}/ {print NR-1; exit}' "$BOOT")"
if [ -z "$END" ]; then
echo "check-boot: FAIL — no closing :} after line $START" >&2
exit 2
fi
{
echo '{-# LANGUAGE OverloadedStrings #-}'
echo '{-# OPTIONS_GHC -Wno-missing-signatures -Wno-name-shadowing #-}'
echo 'module BootCheck where'
echo 'import Sound.Tidal.Context'
echo 'import Data.Char (toLower)'
# Names the block closes over from earlier :{ :} blocks (stream-bound).
echo 'p :: Int -> ControlPattern -> IO ()'
echo 'p = undefined'
echo 'setI :: String -> Pattern Int -> IO ()'
echo 'setI = undefined'
sed -n "${START},${END}p" "$BOOT" | sed -e '1s/^let //' -e 's/^ //'
} > "$WORK/BootCheck.hs"
echo "check-boot: typechecking $BOOT lines $START-$END ..."
# NB: run ghc ONCE and capture, rather than piping into `grep -q`. Under
# `set -o pipefail` a failing ghc makes the whole pipeline non-zero no matter
# what grep decides, which silently inverts the test — this guard shipped
# broken exactly that way and only its own negative test caught it.
set +e
OUT="$(ghc -fno-code -package tidal "$WORK/BootCheck.hs" 2>&1)"
RC=$?
set -e
if [ "$RC" -ne 0 ]; then
echo "" >&2
printf '%s\n' "$OUT" | sed "s#$WORK/BootCheck.hs#<let-block>#" >&2
echo "" >&2
echo "check-boot: FAIL — BootTidal.hs would NOT load. Do not boot." >&2
exit 1
fi
echo "check-boot: OK — helper block typechecks against tidal-1.9.5."
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