:set -XOverloadedStrings
:set prompt ""

import Sound.Tidal.Context
import System.IO (hSetEncoding, stdout, utf8)
import Data.Char (toLower)

hSetEncoding stdout utf8

-- Metadata & Visualization OSC setup
:{
-- Target for visualizer metadata OSC messages
metadataTarget = Target { oName = "ParVaguesViz", 
                          oAddress = "127.0.0.1", 
                          oPort = 57120, 
                          oBusPort = Nothing,
                          oLatency = 0.1, 
                          oWindow = Nothing, 
                          oSchedule = Live,
                          oHandshake = False }

-- DPV visualization target
targetdpv = Target {oName = "didacticpatternvisualizer",
                   oAddress = "127.0.0.1",
                   oPort = 1818,
                   oLatency = 0.2,
                   oWindow = Nothing,
                   oSchedule = Live,
                   oBusPort = Nothing,
                   oHandshake = False
                  }
formatsdpv = [OSC "/delivery" Named {requiredArgs = []} ]

-- Pulsar Editor Highlighting Target (NEW)
editorTarget = Target { oName = "editor", 
                        oAddress = "127.0.0.1", 
                        oPort = 6013, 
                        oLatency = 0.02, 
                        oSchedule = Pre BundleStamp, 
                        oWindow = Nothing, 
                        oHandshake = False, 
                        oBusPort = Nothing }

-- Editor highlighting shape (NEW)
editorShape = OSCContext "/editor/highlights"

-- SuperDirt target
superdirtTarget' = superdirtTarget {oLatency = 0.1, oAddress = "127.0.0.1", oPort = 57120}

-- Set up targets for SuperDirt, DPV, metadata, AND editor highlighting
oscmapdpv = [(targetdpv, formatsdpv),
            (superdirtTarget', [superdirtShape]),
            (metadataTarget, []),
            (editorTarget, [editorShape])  -- Added editor highlighting
            ]
:}

-- Initialize TidalCycles with all targets (increased frame rate for smoother highlighting)
tidal <- startStream defaultConfig {cFrameTimespan = 1/25} oscmapdpv

:{
-- Simpler metadata system using parameters that get sent through normal patterns
let trackName = pS "trackName"    -- Custom parameter for track name
    trackType = pS "trackType"    -- Custom parameter for track type
    trackSubtype = pS "trackSubtype"  -- For variants like bass2, fx2, etc.
    
    -- Simple shorthands - add these to ANY pattern
    labelKick n = (# trackName n) . (# trackType "kick")
    labelSnare n = (# trackName n) . (# trackType "snare")
    labelHihat n = (# trackName n) . (# trackType "hihat")
    labelBreak n = (# trackName n) . (# trackType "breaks")
    
    -- ParVagues specific shorthands
    labelBass n = (# trackName n) . (# trackType "bass")
    labelBass2 n = (# trackName n) . (# trackType "bass") . (# trackSubtype "bass2")
    labelMelody n = (# trackName n) . (# trackType "melodic")
    labelMelody2 n = (# trackName n) . (# trackType "melodic") . (# trackSubtype "melody2")
    labelFX n = (# trackName n) . (# trackType "fx")
    labelFX2 n = (# trackName n) . (# trackType "fx") . (# trackSubtype "fx2")
    labelFX3 n = (# trackName n) . (# trackType "fx") . (# trackSubtype "fx3")
    labelVocal n = (# trackName n) . (# trackType "vocals")
    labelVocal2 n = (# trackName n) . (# trackType "vocals") . (# trackSubtype "vocal2")
:}

:{
let p = streamReplace tidal
    hush = streamHush tidal
    list = streamList tidal
    mute = streamMute tidal
    unmute = streamUnmute tidal
    solo = streamSolo tidal
    unsolo = streamUnsolo tidal
    once = streamOnce tidal
    first = streamFirst tidal
    asap = once
    nudgeAll = streamNudgeAll tidal
    all = streamAll tidal
    resetCycles = streamResetCycles tidal
    setcps = asap . cps
    -- 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
        -- 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
    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
    jump i = transition tidal True (Sound.Tidal.Transition.jump) i
    jumpIn i t = transition tidal True (Sound.Tidal.Transition.jumpIn t) i
    jumpIn' i t = transition tidal True (Sound.Tidal.Transition.jumpIn' t) i
    jumpMod i t = transition tidal True (Sound.Tidal.Transition.jumpMod t) i
    mortal i lifespan release = transition tidal True (Sound.Tidal.Transition.mortal lifespan release) i
    interpolate i = transition tidal True (Sound.Tidal.Transition.interpolate) i
    interpolateIn i t = transition tidal True (Sound.Tidal.Transition.interpolateIn t) i
    clutch i = transition tidal True (Sound.Tidal.Transition.clutch) i
    clutchIn i t = transition tidal True (Sound.Tidal.Transition.clutchIn t) i
    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 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 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)
    d4 = xfade 4 . (|< orbit 3)
    d5 = xfade 5 . (|< orbit 4)
    d6 = xfade 6 . (|< orbit 5)
    d7 = xfade 7 . (|< orbit 6)
    d8 = xfade 8 . (|< orbit 7)
    d9 = xfade 9 . (|< orbit 8)
    d10 = xfade 10 . (|< orbit 9)
    d11 = xfade 11 . (|< orbit 10)
    d12 = xfade 12 . (|< orbit 11)
    -- orbit-correct HARD CUT (when you DON'T want the 4-cycle blend)
    jumpTo i = case i of
        1  -> jump 1  . (|< orbit 0);   2  -> jump 2  . (|< orbit 1)
        3  -> jump 3  . (|< orbit 2);   4  -> jump 4  . (|< orbit 3)
        5  -> jump 5  . (|< orbit 4);   6  -> jump 6  . (|< orbit 5)
        7  -> jump 7  . (|< orbit 6);   8  -> jump 8  . (|< orbit 7)
        9  -> jump 9  . (|< orbit 8);   10 -> jump 10 . (|< orbit 9)
        11 -> jump 11 . (|< orbit 10);  12 -> jump 12 . (|< orbit 11)
        _  -> p i
    -- d13-d16 stay plain `p` (no orbit offset): utility/capture streams,
    -- orbit 13 is the tidal-ears capture vsink — do NOT give these xfade.
    d13 = p 13
    d14 = p 14
    d15 = p 15
    d16 = p 16
:}

:{
let setI = streamSetI tidal
    setF = streamSetF tidal
    setS = streamSetS tidal
    setR = streamSetR tidal
    setB = streamSetB tidal
:}

:{
let -- DPV specific parameters
    grid = pS "grid"
    connectionN = pI "connectionN"
    connectionMax = pI "connectionMax"
    speedSequenser = pF "speedSequenser"
    clear = pI "clear"
    sizeMin = pF "sizeMin"
    sizeMax = pF "sizeMax"
    figure = pS "figure"
    color = pS "color"
    -- =============================================
    -- Mutable Instruments synth parameters
    -- =============================================
    -- MiPlaits (macro oscillator)
    engine = pI "engine"        -- 0-15: oscillator model
    harm = pF "harm"            -- harmonics
    timbre = pF "timbre"
    morph = pF "morph"
    plaitslevel = pF "plaitslevel"  -- internal LPG level
    plaitsdecay = pF "plaitsdecay"  -- "decay" is built into Tidal, so renamed
    fm_mod = pF "fm_mod"
    timb_mod = pF "timb_mod"
    morph_mod = pF "morph_mod"
    lpg_colour = pF "lpg_colour"
    -- MiRings (resonator)
    rstruct = pF "struct"       -- "struct" is a Tidal function, so aliased
    bright = pF "bright"        -- brightness
    damp = pF "damp"            -- damping
    pos = pF "pos"              -- position
    model = pI "model"          -- resonator model
    poly = pI "poly"            -- polyphony
    intern_exciter = pI "intern_exciter"
    easteregg = pI "easteregg"
    -- MiElements (physical modeling)
    strength = pF "strength"
    contour = pF "contour"
    bow_level = pF "bow_level"
    blow_level = pF "blow_level"
    strike_level = pF "strike_level"
    flow = pF "flow"
    mallet = pF "mallet"
    bow_timb = pF "bow_timb"
    blow_timb = pF "blow_timb"
    strike_timb = pF "strike_timb"
    geom = pF "geom"
    eldamp = pF "eldamp"
    elpos = pF "elpos"
    elmodel = pI "elmodel"
    space = pF "space"
    -- MiOmi (dual osc FM synth)
    detune = pF "detune"
    level1 = pF "level1"
    level2 = pF "level2"
    ratio1 = pF "ratio1"
    ratio2 = pF "ratio2"
    fm1 = pF "fm1"
    fm2 = pF "fm2"
    fb = pF "fb"
    xfb = pF "xfb"
    filter_mode = pI "filter_mode"
    omicutoff = pF "omicutoff"
    reson = pF "reson"
    rotate = pF "rotate"
    omispace = pF "omispace"
    -- MiWarps (wave folder / ring mod)
    algo = pI "algo"
    timb = pF "timb"
    osc = pI "osc"
    lev1 = pF "lev1"
    lev2 = pF "lev2"
    vgain = pF "vgain"
    -- MiTides (complex envelope / osc)
    slope = pF "slope"
    tsmooth = pF "smooth"       -- "smooth" is a Tidal function, so aliased
    shift = pF "shift"
    output_mode = pI "output_mode"
    ramp_mode = pI "ramp_mode"
    ratio = pI "ratio"
    tiderate = pF "tiderate"
    -- MiVerb global effect
    verbwet = pF "verbwet"
    verbtime = pF "verbtime"
    verbdamp = pF "verbdamp"
    verbhp = pF "verbhp"
    verbfreeze = pI "verbfreeze"
    verbdiff = pF "verbdiff"
    verbgain = pF "verbgain"
    -- MiClouds global effect
    cloudspitch = pF "cloudspitch"
    cloudspos = pF "cloudspos"
    cloudssize = pF "cloudssize"
    cloudsdens = pF "cloudsdens"
    cloudstex = pF "cloudstex"
    cloudswet = pF "cloudswet"
    cloudsgain = pF "cloudsgain"
    cloudsspread = pF "cloudsspread"
    cloudsrvb = pF "cloudsrvb"
    cloudsfb = pF "cloudsfb"
    cloudsfreeze = pI "cloudsfreeze"
    cloudsmode = pI "cloudsmode"
    cloudslofi = pI "cloudslofi"
    -- MiRipples global effect
    ripplescf = pF "ripplescf"
    ripplesreson = pF "ripplesreson"
    ripplesdrive = pF "ripplesdrive"
    ripplesgain = pF "ripplesgain"
    -- =============================================
    -- Other parameters
    modIndex = pF "modIndex"
    mix =       pF "mix"
    vibrato =   pF "vibrato"
    chorus = pF "chorus"
    lagamount = pF "lagamount"
    dec = pF "dec"
    sus = pF "sus"
    cutoff = pF "cutoff"
    drumMachine name ps = stack
                    (map (\ x ->
                        (# s (name ++| (extractS "s" (x)))) $ x
                        ) ps)
    drumFrom name drum = s (name ++| drum)
    drumM = drumMachine
    drumF = drumFrom
    -- =============================================
    -- orDef — THE UNTOUCHED-CONTROL FIX (#55, 2026-07-27)
    -- A bare "^NN" does NOT default to 0 when the CC has never been
    -- received: it yields NO EVENTS AT ALL. And `#`/`|>` cannot emit
    -- without a value on its right, so ANY g*-wrapped stream is
    -- COMPLETELY SILENT until that physical control is moved once.
    -- Proven by queryArc on an empty controls map, not by ear.
    -- This is why "move all the controls" was always step one of a gig:
    -- the habit was masking a mute-bomb, and a Tidal restart re-armed it.
    -- orDef substitutes a default when the control pattern is empty. It
    -- must live HERE, at the helper level, rather than at each call site,
    -- because tracks combine controls arithmetically —
    --   midiOn ("^34" - "^18")   (perfect.tidal:63)
    -- and `touched - untouched` is itself empty, so a per-CC default
    -- could not catch it.  (Caveat, accepted: with one of the two
    -- touched and the other not, the combination still falls back to the
    -- default rather than to a partial value. Safe — audible, branch off
    -- — but not clairvoyant.)
    orDef :: a -> Pattern a -> Pattern a
    orDef d p = Pattern $ \st -> case query p st of
                                   [] -> query (pure d) st
                                   es -> es
    -- =============================================
    -- Midi boolean buttons — default OFF (untouched button == not pressed)
    midiOn    ch    pat = someCyclesBy (orDef 0 ch)              pat
    midiOff   ch    pat = someCyclesBy (orDef 1 (1 - ch))        pat
    midiNo    c1 c2 pat = someCyclesBy (orDef 1 (1 - c1 - c2))   pat
    midiBoth  c1 c2 pat = someCyclesBy (orDef 0 (c1 + c2 - 1))   pat
    -- Parameterized DJF
    _LPF lMin lMax ch = (# lpf (min lMax ((range lMin ((lMax*2) - lMin) ch))))
    _HPF hMin hMax ch = (# hpf (max hMin (min hMax (range (hMin - hMax) (hMax - hMin) ch))))
    midiDJF   ch lMin lMax hMin hMax = (_LPF lMin lMax ch) . (_HPF hMin hMax ch)
    -- FIXME: Seems to cut some lows when lMin != 0
    -- Midi gain control (faders)
    -- ------------------------------------------------------------------
    -- midiGGlobal used to read the LIVE fader:  orDef 0.769 "^77" * 1.3
    -- Retired 2026-07-29 (#53), on PLN's call ("midiGlobal can be killed imo"),
    -- for two independent reasons:
    --
    --  1. SAFETY. It put a global gain on ONE physical fader, so a single
    --     accidental nudge of fader 1 attenuated every midiG-using stream at
    --     once — the whole set, silently, with nothing on screen to say why.
    --     A control that can silence everything should not be reachable by
    --     brushing past it in the dark.
    --  2. IT COST US FADER 1. CC77 is also MIDI-learned in Ardour (or was free,
    --     pending #46). With Tidal reading it too, moving fader 1 double-dipped:
    --     Ardour's track gain AND Tidal's global gain, multiplied. Freeing ^77
    --     is what lets the surface become fader N -> dN, so the eight faders
    --     line up with d1-d8 instead of sitting one lane off (#46).
    --
    -- Now a FIXED pre-set. 1.0 = unity, exactly what the old untouched default
    -- (0.769 * 1.3) evaluated to, so every existing track's level is unchanged
    -- by this edit — the refactor is deliberately inaudible. Set it here if you
    -- ever want global headroom; it is one number in one place, not a fader.
    midiGGlobal :: Pattern Double
    midiGGlobal = 1.0
    _gainG ch     = (gain (midiGGlobal * ch)) -- gain Global
    midiG' ch l h = _gainG (range l h ch)     -- midiGain   control
    midiGdef      = midiG' 1 0 1             -- midiGain   default
    midiG  ch     = midiG' ch 0 1             -- midiGain   default
    -- =============================================
    -- 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
    -- was a live footgun: `djfbus` is NOT a registered SuperDirt effect, and
    -- 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.
    -- The 2026-07-25 fix made rest(0)=bypass, but only as TWO separate
    -- one-directional controls (gF1=hpf-only, gF2=lpf-only, gF3=spare) —
    -- not what these three knobs (C1/C2/C3, CC49/50/51) actually are on
    -- 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.
    -- =============================================
    -- NB: written as two composed sections, NOT (# lpf .. # hpf ..).  A right
    -- section may not contain another operator of the same fixity, and `#` is
    -- infixl 9 — the nested form is a hard PARSE ERROR that takes the whole
    -- `let` block (i.e. every g* helper) down with it.  Typechecked against
    -- tidal-1.9.5 with `ghc -fno-code` before landing.
    -- orDef 0.5 (#55): an UNTOUCHED knob sends nothing at all, which without
    -- a default makes the whole stream silent — see the orDef note above.
    -- 0.5 == the centre detent == full bypass, so untouched really is "no
    -- effect, ever", which is what #21 was reaching for all along.
    gDJF ch = (# lpf (range 180 20000 (fmap (\v -> 1 - 2 * max 0 (0.5 - v)) (orDef 0.5 ch))))
            . (# hpf (range 20   8000  (fmap (\v -> 2 * max 0 (v - 0.5))     (orDef 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
    -- 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
    -- a plain 0/1 gain gate.  Not applied to any stream by default — wrap it on
    -- whichever track you're actively composing:  d1 $ gPanic $ gF1 $ …
    gPanic = (# gain (range 1 0 (orDef 0 "^93")))  -- ^93=0 normal, =1 silent
                                                  -- orDef 0 (#55): untouched
                                                  -- must be NORMAL, not silent
    -- Global masks (defaults)
    -- gMask is RETIRED (2026-07-29). It used to be `midiOn "^41" (mask "t!7 f")`
    -- — button 1 chopping the last 8th out of every bar. PLN killed it:
    --   "kill gMask! its unprevisible anyway ... id rather invest all on my own
    --    manual masking perf"
    -- and he is right: a global that silently removes an event from every stream
    -- it wraps is exactly the kind of thing that makes a rig feel haunted, and
    -- the gesture is better played by hand.
    --
    -- It stays as `id` rather than being deleted because 230 .tidal files name it
    -- directly; removing the binding would stop every one of them compiling. Same
    -- retirement pattern this rig already used for the midiG family (#73): keep
    -- the name, empty the behaviour, delete the usages later at leisure.
    --
    -- The payoff is the surface, not the sound: retiring it FREES ^41 (button top
    -- row, column 1), which is the one slot the column-aligned button map needed
    -- for d1's gate. With ^41 free, the top button row becomes fully per-orbit
    -- (d1..d8) and the whole board reduces to one sentence — see #46/#92.
    gMask = id
    gMute1 = (midiOn "^73" (mask "f*16"))
    gMute2 = (midiOn "^74" (mask "f*16"))
    gMute3 = (midiOn "^75" (mask "f*16"))
    -- gM<N> used to be `gMask . gMute<N>`; with gMask retired it is just the mute.
    gM1 = gMute1
    gM2 = gMute2
    gM3 = gMute3

    -- Launchpad S controls
    -- Launchpad S pad controls
    pad00 = pF "pad00"
    pad01 = pF "pad01"
    pad02 = pF "pad02"
    pad03 = pF "pad03"
    pad04 = pF "pad04"
    pad05 = pF "pad05"
    pad06 = pF "pad06"
    pad07 = pF "pad07"
    pad08 = pF "pad08"
    pad10 = pF "pad10"
    pad11 = pF "pad11"
    pad12 = pF "pad12"
    pad13 = pF "pad13"
    pad14 = pF "pad14"
    pad15 = pF "pad15"
    pad16 = pF "pad16"
    pad17 = pF "pad17"
    pad18 = pF "pad18"
    pad20 = pF "pad20"
    pad21 = pF "pad21"
    pad22 = pF "pad22"
    pad23 = pF "pad23"
    pad24 = pF "pad24"
    pad25 = pF "pad25"
    pad26 = pF "pad26"
    pad27 = pF "pad27"
    pad28 = pF "pad28"
    pad30 = pF "pad30"
    pad31 = pF "pad31"
    pad32 = pF "pad32"
    pad33 = pF "pad33"
    pad34 = pF "pad34"
    pad35 = pF "pad35"
    pad36 = pF "pad36"
    pad37 = pF "pad37"
    pad38 = pF "pad38"
    pad40 = pF "pad40"
    pad41 = pF "pad41"
    pad42 = pF "pad42"
    pad43 = pF "pad43"
    pad44 = pF "pad44"
    pad45 = pF "pad45"
    pad46 = pF "pad46"
    pad47 = pF "pad47"
    pad48 = pF "pad48"
    pad50 = pF "pad50"
    pad51 = pF "pad51"
    pad52 = pF "pad52"
    pad53 = pF "pad53"
    pad54 = pF "pad54"
    pad55 = pF "pad55"
    pad56 = pF "pad56"
    pad57 = pF "pad57"
    pad58 = pF "pad58"
    pad60 = pF "pad60"
    pad61 = pF "pad61"
    pad62 = pF "pad62"
    pad63 = pF "pad63"
    pad64 = pF "pad64"
    pad65 = pF "pad65"
    pad66 = pF "pad66"
    pad67 = pF "pad67"
    pad68 = pF "pad68"
    pad70 = pF "pad70"
    pad71 = pF "pad71"
    pad72 = pF "pad72"
    pad73 = pF "pad73"
    pad74 = pF "pad74"
    pad75 = pF "pad75"
    pad76 = pF "pad76"
    pad77 = pF "pad77"
    pad78 = pF "pad78"
    pad80 = pF "pad80"
    pad81 = pF "pad81"
    pad82 = pF "pad82"
    pad83 = pF "pad83"
    pad84 = pF "pad84"
    pad85 = pF "pad85"
    pad86 = pF "pad86"
    pad87 = pF "pad87"
    pad88 = pF "pad88"
:}

-- FIXME: STALE EXAMPLES
-- :{
-- -- Example metadata setup for common tracks
-- -- Run these at the start of your session
-- setupMetadata = do
--   labelKick 0 "Kick"            -- d1: Kick
--   labelSnare 1 "Snare"          -- d2: Snare
--   labelHihat 2 "Drums"          -- d3: Drums
--   labelBass 3 "Bass"            -- d4: Bass
--   labelMelody 4 "Synth"         -- d5: Synth
--   labelFX 5 "FX"                -- d6: FX
--   labelVocal 8 "Vocals"         -- d9: Vocals
--   labelBreak 7 "Breakbeats"     -- d8: Breaks
-- 
-- -- For BLUE GOLD tracks
-- setupBlueGold = do
--   labelKick 0 "Kick"
--   labelSnare 1 "Snare" 
--   labelHihat 2 "Hihats"
--   labelBass 3 "Bass"
--   labelMelody 4 "Suns Keys"
--   labelFX2 5 "Guitars 1"
--   labelFX3 6 "Guitars 2"
--   labelBreak 7 "Breakbeats"
--   labelVocal 8 "Father Voice"
--   labelFX 9 "Cues & Noise"
-- :}

-- Jazz chord symbol converter (Am → a'min, C7 → c'dom7, etc.)
-- Use it in patterns with parseBP like: d1 $ n (jchord "<Am C D F>") # s "superpiano"
:{
jchord :: String -> ControlPattern
jchord input = n (parseBP_E $ convertChordSymbols input)
  where
    convertChordSymbols :: String -> String
    convertChordSymbols str = unwords $ map parseChordSymbol (words str)

    parseChordSymbol :: String -> String
    parseChordSymbol symbol =
      -- Handle pattern syntax chars at beginning/end
      case symbol of
        ('<':rest) -> '<' : parseChordSymbol rest
        ('>':rest) -> '>' : parseChordSymbol rest
        ('[':rest) -> '[' : parseChordSymbol rest
        (']':rest) -> ']' : parseChordSymbol rest
        ('{':rest) -> '{' : parseChordSymbol rest
        ('}':rest) -> '}' : parseChordSymbol rest
        ('|':rest) -> '|' : parseChordSymbol rest
        ('~':rest) -> '~' : parseChordSymbol rest
        _ -> case reverse symbol of
          ('>':tser) -> parseChordSymbol (reverse tser) ++ ">"
          (']':tser) -> parseChordSymbol (reverse tser) ++ "]"
          ('}':tser) -> parseChordSymbol (reverse tser) ++ "}"
          ('|':tser) -> parseChordSymbol (reverse tser) ++ "|"
          _ -> if null symbol || not (any (`elem` "ABCDEFG") symbol)
               then symbol  -- no note names, pass through
               else
                 let (root, quality) = splitChordSymbol symbol
                     tidalRoot = convertRoot root
                     tidalQuality = convertQuality quality
                 in tidalRoot ++ "'" ++ tidalQuality

    -- NEW: Convert root note to Tidal notation
    convertRoot :: String -> String
    convertRoot root = map toLower $ replace '#' 's' $ replace 'b' 'f' root
      where
        replace :: Char -> Char -> String -> String
        replace from to = map (\c -> if c == from then to else c)

    splitChordSymbol :: String -> (String, String)
    splitChordSymbol (c:rest)
      | c `elem` "ABCDEFG" =
          case rest of
            ('#':q) -> ([c, '#'], q)
            ('b':q) -> ([c, 'b'], q)
            ('s':q) -> ([c, 's'], q)  -- also accept 's' directly
            ('f':q) -> ([c, 'f'], q)  -- also accept 'f' directly
            _ -> ([c], rest)
      | otherwise = ([c], rest)
    splitChordSymbol [] = ("C", "maj")  -- default

    convertQuality :: String -> String
    convertQuality "" = "maj"
    convertQuality "M" = "maj"
    convertQuality "maj" = "maj"
    convertQuality "m" = "min"
    convertQuality "min" = "min"
    convertQuality "-" = "min"
    convertQuality "7" = "dom7"
    convertQuality "maj7" = "maj7"
    convertQuality "M7" = "maj7"
    convertQuality "^7" = "maj7"
    convertQuality "m7" = "min7"
    convertQuality "min7" = "min7"
    convertQuality "-7" = "min7"
    convertQuality "mM7" = "minmaj7"
    convertQuality "-M7" = "minmaj7"
    convertQuality "dim" = "dim"
    convertQuality "o" = "dim"
    convertQuality "dim7" = "dim7"
    convertQuality "o7" = "dim7"
    convertQuality "m7b5" = "m7f5"
    convertQuality "half-dim7" = "m7f5"
    convertQuality "aug" = "aug"
    convertQuality "+" = "aug"
    convertQuality "sus2" = "sus2"
    convertQuality "sus4" = "sus4"
    convertQuality "sus" = "sus4"
    convertQuality "7sus4" = "seven"  -- closest match
    convertQuality "9" = "nine"
    convertQuality "maj9" = "maj9"
    convertQuality "M9" = "maj9"
    convertQuality "^9" = "maj9"
    convertQuality "m9" = "min9"
    convertQuality "min9" = "min9"
    convertQuality "-9" = "min9"
    convertQuality "11" = "eleven"
    convertQuality "maj11" = "maj11"
    convertQuality "M11" = "maj11"
    convertQuality "m11" = "min11"
    convertQuality "min11" = "min11"
    convertQuality "13" = "thirteen"
    convertQuality "maj13" = "maj13"
    convertQuality "M13" = "maj13"
    convertQuality "6" = "six"
    convertQuality "m6" = "min6"
    convertQuality "69" = "six"  -- closest
    convertQuality "add9" = "nine"  -- approximate
    convertQuality other = "maj"  -- fallback to major

-- Extract root notes for bass lines (Am7 -> a, C#m9 -> cs, Bb -> bf)
-- FIXED: Now properly converts # to s and b to f for Tidal notation
jroot :: String -> ControlPattern
jroot input = n (parseBP_E $ extractRoots input)
  where
    extractRoots :: String -> String
    extractRoots str = unwords $ map extractRoot (words str)

    extractRoot :: String -> String
    extractRoot symbol =
      case symbol of
        ('<':rest) -> '<' : extractRoot rest
        ('>':rest) -> '>' : extractRoot rest
        ('[':rest) -> '[' : extractRoot rest
        (']':rest) -> ']' : extractRoot rest
        ('{':rest) -> '{' : extractRoot rest
        ('}':rest) -> '}' : extractRoot rest
        ('|':rest) -> '|' : extractRoot rest
        ('~':rest) -> '~' : extractRoot rest
        _ -> case reverse symbol of
          ('>':tser) -> extractRoot (reverse tser) ++ ">"
          (']':tser) -> extractRoot (reverse tser) ++ "]"
          ('}':tser) -> extractRoot (reverse tser) ++ "}"
          ('|':tser) -> extractRoot (reverse tser) ++ "|"
          _ -> if null symbol || not (any (`elem` "ABCDEFG") symbol)
               then symbol
               else
                 let (root, _) = splitChordSymbol symbol
                 in convertRoot root

    -- NEW: Convert root note to Tidal notation
    convertRoot :: String -> String
    convertRoot root = map toLower $ replace '#' 's' $ replace 'b' 'f' root
      where
        replace :: Char -> Char -> String -> String
        replace from to = map (\c -> if c == from then to else c)

    splitChordSymbol :: String -> (String, String)
    splitChordSymbol (c:rest)
      | c `elem` "ABCDEFG" =
          case rest of
            ('#':q) -> ([c, '#'], q)
            ('b':q) -> ([c, 'b'], q)
            ('s':q) -> ([c, 's'], q)
            ('f':q) -> ([c, 'f'], q)
            _ -> ([c], rest)
      | otherwise = ([c], rest)
    splitChordSymbol [] = ("C", "")
:}

-- =====================================================================
-- SEED THE CONTROL MAP (#55, 2026-07-27) — the corpus-wide half of the
-- untouched-control fix.
--
-- A bare "^NN" whose CC has never arrived yields NO EVENTS, and `#` cannot
-- emit without a right-hand value, so the stream goes SILENT (not "gets a
-- default of 0" — silent). `orDef` guards the boot-level helpers, but it
-- cannot reach the 1860 places across 343 track files that apply controls
-- directly, e.g.
--     # crushbus 41 (range 16 3.5 "^53")
--     # legato       (range 0.05 2 "^52")
-- Editing those is #46-scale work. Seeding is the one move that fixes the
-- whole corpus at once, touching no track file: give every LCXL control a
-- value at boot so nothing is ever in the "never received" state. A real
-- CC overwrites its seed the moment the knob moves.
--
-- Defaults follow the authoring convention `range <neutral> <extreme>`,
-- so 0 == neutral for effect knobs:
--   A/B knobs (13-20, 29-36)  -> 0    effect amount off
--   C1-C3     (49,50,51)      -> 0.5  DJF CENTRE = true bypass
--   C4-C8     (52-56)         -> 0    effect amount off
--   fader 77                  -> 0.769  (*1.3 == unity master gain)
--   faders 78-84              -> 1.0  channel gains OPEN, i.e. audible
--   E/F buttons               -> 0    not pressed
--   93 (panic toggle)         -> 0    not armed
--
-- Known outlier, left alone deliberately: piment_bresilien's
-- `legato (range 0.05 2 "^52")` seeds to legato 0.05 (clicky 5%-length
-- notes) because its `range` is written low-to-high with a non-neutral
-- low end. That is an authoring bug in the track, not in the seed policy
-- — fix it there (swap to `range 1 2`), don't special-case it here.
:{
let _seed = concat
      [ [ (show n, 0)     | n <- [13..20] ++ [29..36] ]  -- A/B knobs
      , [ (show n, 0.5)   | n <- [49,50,51] ]            -- C1-C3 = DJF centre
      , [ (show n, 0)     | n <- [52..56] ]              -- C4-C8 effects
      , [ ("77", 0.769) ]                                -- master -> unity
      , [ (show n, 1)     | n <- [78..84] ]              -- channel gains open
      , [ (show n, 0)     | n <- [41..44] ++ [57..60] ]  -- E buttons
      , [ (show n, 0)     | n <- [73..76] ++ [89..92] ]  -- F buttons
      , [ ("93", 0) ]                                    -- panic toggle
      -- Non-LCXL stragglers found by diffing the seed set against every
      -- "^NN" in live/ (49 seeded vs 54 used). ^1 is an external keyboard
      -- modwheel (hello_world, jazz_tech_piano); ^21 is a one-off in
      -- collab/igor. ^45 needs no seed — it goes through midiOn, so orDef
      -- already covers it. ^6 and ^61 are commented out at both call sites.
      , [ ("1", 0), ("21", 0) ]
      ] :: [(String, Double)]
:}

-- ONE STATEMENT PER BLANK-LINE-DELIMITED BLOCK. THIS IS NOT STYLE (#79).
--
-- Pulsar does not feed this file to ghci verbatim. pulsar-tidalcycles splits it
-- on BLANK LINES, strips the `:{`/`:}` it finds, and re-wraps each chunk in its
-- own `:{ ... :}` (repl.js tidalSendExpression). A `:{ ... :}` accepts exactly
-- ONE statement — so when `let _seed = ...`, the `mapM_` and the `putStrLn` sat
-- in one blank-line block, they were fused into a single statement and the whole
-- thing died with "parse error (possibly incorrect indentation or mismatched
-- brackets)".
--
-- Consequence: THE SEED NEVER RAN. Not once between 2026-07-27, when it was
-- written, and 2026-07-29. Every boot left the control map empty, so 23 orbits
-- across the OPAL set emitted NOTHING until a knob was physically moved — which
-- is exactly what PLN reported: "when i move the knob C5 it starts sounding".
--
-- It typechecked perfectly the whole time. check-boot.sh proved the Haskell was
-- valid Haskell, which it was; the failure was in the SEAM — how the file is
-- chunked and fed. Verified green code that never executes is this rig's
-- signature failure, and this is the purest example of it yet.
--
-- The blank lines below are load-bearing. Do not tidy them away.
mapM_ (\(k, v) -> setF k (pure v)) _seed

putStrLn ("[BootTidal] seeded " ++ show (length _seed) ++ " LCXL controls (#55) — untouched knobs no longer silence their stream")

:set prompt "tidal> "
-- :set prompt-cont ""
