// Keys Highway 3D — RS+-style falling-note piano highway.
//
// Notation-format-fed (sloppak-spec §5.3): consumes the `notation_info` +
// `notation_measures` highway-WS stream over a private per-instance socket
// (same pattern as Staff View) and flattens measure → staff → voice → beat →
// note into a falling-note list `{midi, t, durSec, hand}`. No guitar-wire
// `midi = s*24+f` indirection — that legacy path stays with the 2D piano
// plugin.
//
// Visual contract is the frame analysis on slopsmith#824 (RS+ reference):
// 3D perspective highway to a vanishing point, notes landing on a real 3D
// keyboard, per-key Synthesia-style PITCH-CLASS colors (hand is only a
// secondary brightness cue), active-range key highlighting with letters,
// a glowing hit-line, bevelled cuboid notes sized by durSec, floating bar
// numbers, and key-depress + flame feedback driven by the LIVE MIDI input
// path (not the chart).
//
// Reuses verbatim from highway_3d / drum_highway_3d: Three.js loader, world
// scale (K), fog and light rig — so the three highways read as one family.

(function () {
    'use strict';

    /* ======================================================================
     *  Verbatim from highway_3d — keep in sync if upstream tweaks them
     * ====================================================================== */

    const THREE_URL = '/static/vendor/three/three.module.min.js';
    const THREE_CDN = 'https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.min.js';

    const SCALE = 2.25;
    const K = SCALE / 300;

    const FOG_COLOR = 0x1a1a2e;
    const FOG_START = 850 * K;   // fog pushed past the runway end — far notes stay
    const FOG_END = 1600 * K;    // visible, only the extreme distance fades

    let T = null;
    let threeLoadPromise = null;
    function loadThree() {
        if (!threeLoadPromise) {
            threeLoadPromise = import(THREE_URL)
                .then(mod => { T = mod; return mod; })
                .catch(() => import(THREE_CDN)
                    .then(mod => { T = mod; return mod; })
                    .catch(e => {
                        console.error('[Keys-Hwy3D] Three.js load failed:', e);
                        threeLoadPromise = null;
                        throw e;
                    }));
        }
        return threeLoadPromise;
    }

    /* ======================================================================
     *  Keys-specific constants
     * ====================================================================== */

    // World scroll speed (units / second) — matches the sibling highways.
    const TS = 130 * K;

    // Per-pitch-class colors (Synthesia convention observed in the RS+
    // reference frames: C=red, D=yellow, E=blue, F=light blue-grey, …).
    // Index = midi % 12 (C, C#, D, …, B). Sharps take a dimmed blend of
    // their neighbours so black-key notes stay distinguishable.
    const PITCH_CLASS_COLORS = [
        0xff3030, // C  — red
        0xb04818, // C#
        0xffd400, // D  — yellow
        0x9aa018, // D#
        0x2870ff, // E  — blue
        0x8fb8c8, // F  — light blue-grey
        0x4a7a68, // F#
        0x30c050, // G  — green
        0x807838, // G#
        0xff8020, // A  — orange
        0xa05870, // A#
        0xc050ff, // B  — violet
    ];

    // Hand cue is SECONDARY (slopsmith#824 design call): right hand renders
    // at full brightness, left hand slightly darkened — color stays the
    // pitch class.
    const HAND_BRIGHTNESS = { rh: 1.0, lh: 0.72 };

    // Selectable note-color palettes. Index = midi % 12, same contract as
    // PITCH_CLASS_COLORS — which stays byte-identical as the 'classic'
    // entry, so anyone who never touches the setting sees the stock look.
    // Two palette families:
    //   per-pitch  — every pitch class gets its own hue (classic/vivid/pastel)
    //   two-tone   — naturals share one hue, sharps a darker shade of it, so
    //                a dark gem always telegraphs "black key coming"
    //                (emerald/ice)
    const NOTE_PALETTES = {
        classic: PITCH_CLASS_COLORS,
        emerald: [
            0x3fe25f, // C  — bright green (naturals)
            0x17863a, // C# — dark green (sharps)
            0x3fe25f, // D
            0x17863a, // D#
            0x3fe25f, // E
            0x3fe25f, // F
            0x17863a, // F#
            0x3fe25f, // G
            0x17863a, // G#
            0x3fe25f, // A
            0x17863a, // A#
            0x3fe25f, // B
        ],
        vivid: [
            0xff2020, // C
            0xd45a10, // C#
            0xffe000, // D
            0xb8c010, // D#
            0x1e6aff, // E
            0x9fd0e8, // F
            0x2fae7e, // F#
            0x20e050, // G
            0xa89a20, // G#
            0xff8a00, // A
            0xd05888, // A#
            0xd040ff, // B
        ],
        pastel: [
            0xff9a9a, // C
            0xd0a078, // C#
            0xffe9a0, // D
            0xcfd08a, // D#
            0x9ec0ff, // E
            0xc8dde8, // F
            0x9ecfba, // F#
            0x9fe8b0, // G
            0xcfc79a, // G#
            0xffc890, // A
            0xd8a8ba, // A#
            0xe0b0ff, // B
        ],
        ice: [
            0x58c8ff, // C  — bright ice blue (naturals)
            0x2a6a9a, // C# — deep blue (sharps)
            0x58c8ff, // D
            0x2a6a9a, // D#
            0x58c8ff, // E
            0x58c8ff, // F
            0x2a6a9a, // F#
            0x58c8ff, // G
            0x2a6a9a, // G#
            0x58c8ff, // A
            0x2a6a9a, // A#
            0x58c8ff, // B
        ],
    };

    // Octave-based color scheme ('octaves'): every octave gets a distinct
    // hue that steps like a rainbow (clear, uniform sections — NOT a smooth
    // blend — so each octave is uniquely identifiable, but neighbouring
    // octaves stay close so the change isn't jarring). Loops if a song runs
    // past the table. Within an octave, sharps/flats take a darker shade of
    // the same hue — the same two-tone idea as 'emerald'. Octave index =
    // floor(midi/12) - 1 (so C1..B1 = octave 1). The three keys below C1
    // (A0/A#0/B0 = octave 0) and anything lower get a distinct cool slate so
    // the very bottom of the board reads apart from the red start.
    const OCTAVE_HUES = [
        0xe23a3a, // oct 1 (C1–B1)  red
        0xe2803a, // oct 2          orange
        0xe0c73a, // oct 3          yellow
        0x5fc23a, // oct 4          green
        0x3ac2a0, // oct 5          teal
        0x3a86e2, // oct 6          blue
        0x6a4ae2, // oct 7          indigo
        0xc23ae2, // oct 8 (C8)     magenta
    ];
    const OCTAVE_SUBC1_HUE = 0x8090a0;  // A0/A#0/B0 and below — cool slate
    const OCTAVE_SHARP_DARKEN = 0.5;    // sharps render at 50% of the octave hue
    function _darkenHex(hex, f) {
        const r = Math.round(((hex >> 16) & 0xff) * f);
        const g = Math.round(((hex >> 8) & 0xff) * f);
        const b = Math.round((hex & 0xff) * f);
        return (r << 16) | (g << 8) | b;
    }
    function _isBlackPc(midi) {
        return [1, 3, 6, 8, 10].indexOf(((midi % 12) + 12) % 12) !== -1;
    }
    // Which way a sharp leans to even out the naturals: toward the EDGE
    // natural next to it. +1 = up (toward the higher natural), −1 = down, 0 =
    // centred. C#/F# sit below an inner natural so they lean down to C/F;
    // D#/A# lean up to E/B; G# has an inner natural on both sides, so it can't
    // lean and stays put.
    function _sharpLeanDir(pc) {
        if (pc === 1 || pc === 6) return -1;   // C#, F#
        if (pc === 3 || pc === 10) return 1;   // D#, A#
        return 0;                              // G#
    }
    // Floor span [left,right] of a key's lane in the FLAT (piano-shaped)
    // layout, in world units, given the key's centre x (`cx`). Pure/isolated
    // on purpose — this ONE function defines the layout, so a variant is a
    // one-function swap. Zero-overlap tiling: a white lane is trimmed by
    // `sharpHalf` wherever it meets a sharp, and the sharp fills that gap. Each
    // sharp is nudged `shift` toward the edge natural beside it (see
    // _sharpLeanDir), which steals a sliver from that edge natural and widens
    // the squeezed inner natural — at shift = sharpHalf/3 the C-D-E-F-B
    // naturals come out equal. Lanes still tile edge-to-edge (no overlap, no
    // gap). With `gaps`, each B→C octave boundary opens an extra `octGap`
    // divider by shaving half of it off the B and the C (naturals only).
    // `range`, when given, gates the trim to a neighbouring sharp that is
    // itself inside `range.activeLow..range.activeHigh`. A white key at the
    // active-range boundary (see the `midi < range.activeLow ||
    // midi > range.activeHigh` skip around the lane-strip loop) may sit next
    // to a sharp pitch-class that falls just outside the active range — that
    // sharp's lane is never drawn, so trimming the white key's edge for it
    // leaves a dark, unfilled sliver. Gating on range keeps that edge full
    // while leaving the normal (fully in-range) zero-overlap tiling intact.
    // Callers that don't pass `range` (e.g. the unit tests exercising raw
    // tiling geometry) keep the unconditional trim.
    function laneSpanFlat(midi, black, cx, dims, gaps, range) {
        const { whiteW, sharpHalf, shift, octGap } = dims;
        if (black) {
            const c = cx + _sharpLeanDir(((midi % 12) + 12) % 12) * shift;
            return { left: c - sharpHalf, right: c + sharpHalf };
        }
        const neighborActive = (m) => !range || (m >= range.activeLow && m <= range.activeHigh);
        // White: each side that meets a sharp is trimmed to that (leaned) sharp's
        // near edge; a side that meets another white keeps the half-slot edge.
        let left = cx - whiteW / 2;
        let right = cx + whiteW / 2;
        if (_isBlackPc(midi - 1) && neighborActive(midi - 1)) {
            const bc = (cx - whiteW / 2) + _sharpLeanDir(((midi - 1) % 12 + 12) % 12) * shift;
            left = bc + sharpHalf;
        }
        if (_isBlackPc(midi + 1) && neighborActive(midi + 1)) {
            const bc = (cx + whiteW / 2) + _sharpLeanDir(((midi + 1) % 12 + 12) % 12) * shift;
            right = bc - sharpHalf;
        }
        const pc = ((midi % 12) + 12) % 12;
        if (gaps) {
            if (pc === 11) right -= octGap / 2; // B: gap on its right (→ C)
            if (pc === 0) left += octGap / 2;   // C: gap on its left (← B)
        }
        return { left, right };
    }
    // 'realistic' layout span: every bar sized to the physical key it lands on.
    // Naturals are the same full width (2·natHalf) centred on the key; sharps are
    // the full black-key width (2·sharpHalf) at their standard half-slot, which
    // makes them overlap — the caller draws sharps on top. A natural therefore
    // always renders full and is only covered where a sharp note actually
    // coincides in time. `gaps` widens the B→C divider (naturals only).
    function laneSpanReal(midi, black, cx, dims, gaps) {
        const half = black ? dims.sharpHalf : dims.natHalf;
        let left = cx - half, right = cx + half;
        if (gaps && !black) {
            const pc = ((midi % 12) + 12) % 12;
            if (pc === 11) right -= dims.octGap / 2;
            if (pc === 0) left += dims.octGap / 2;
        }
        return { left, right };
    }
    // Color (24-bit int) for a midi note under the octave scheme: hue by
    // octave, darker for sharps. Pure (no THREE) so it is unit-testable.
    function octaveNoteColor(midi) {
        const oct = Math.floor(midi / 12) - 1;      // C1..B1 => 1
        let hex = (oct <= 0)
            ? OCTAVE_SUBC1_HUE
            : OCTAVE_HUES[(oct - 1) % OCTAVE_HUES.length];
        if (_isBlackPc(midi)) hex = _darkenHex(hex, OCTAVE_SHARP_DARKEN);
        return hex;
    }
    // Every valid palette id: the 12-entry pitch-class tables PLUS the
    // procedural 'octaves' scheme (which is not a 12-array, so it lives
    // outside NOTE_PALETTES and is validated through this list).
    const PALETTE_IDS = [...Object.keys(NOTE_PALETTES), 'octaves'];

    // Note block cross-section height and bevel (world units). The bevel
    // turns the flat slabs into glossy gem-like blocks that catch the light
    // on their edges — the RS+ reference look.
    const NOTE_H = 4 * K;
    const NOTE_BEVEL = 0.55 * K;
    // Resting note glow. Raised from the original 0.08 in the hit-FX parity
    // pass — combined with the vibrancy-driven opacity it fixes the
    // washed-out, semi-transparent look the note gems had.
    const NOTE_EMISSIVE_BASE = 0.22;
    const CONSUME_GLOW = 5.0;          // peak glow as a note is eaten at the hit-line
    const LABEL_FADE_DIST = 80 * K;    // note-name fades out over this distance past the hit-line (~0.6s)
    // Gem vertical gradient (bottom shade → top highlight), baked per-vertex into
    // the note geometry so a block reads as a lit 3D gem instead of a flat fill —
    // same approach as the bundled guitar highway_3d (`gNoteGrad`). The ramp is
    // greyscale so one geometry serves every pitch-class color; the material
    // multiplies its color by it via vertexColors.
    const GEM_SHADE_BOT = 0.12, GEM_SHADE_TOP = 1.1; // strong gem gradient (top slightly blows toward a highlight)

    const NOTE_NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];

    // Key-range padding.
    const RANGE_PAD = 2; // semitones of plain keys beyond the active range

    const NOTATION_CHUNK_TIMEOUT_MS = 20000;

    /* ======================================================================
     *  MIDI scoring constants (mirrors the 2D piano plugin)
     * ====================================================================== */

    // ±100 ms hit window — same as the 2D piano plugin's HIT_TOLERANCE so
    // users get identical timing across both keys visualisations.
    const HIT_TOLERANCE_S = 0.10;

    // Persisted settings, `keys3d_` prefix (piano plugin STORE_KEYS pattern).
    const STORE_KEYS = {
        midiPick: 'keys3d_midi_pick',       // {id, name} JSON (drum-h3d v2 pattern)
        midiChannel: 'keys3d_midi_ch',      // -1 = all channels
        transpose: 'keys3d_transpose',      // semitones added to incoming notes
    };

    // Inputs whose name matches this are skipped by auto-connect (loopbacks
    // and passthroughs, same blocklist as drum_highway_3d).
    const _MIDI_BLOCKLIST_RE = /midi through|^thru\b|^iac\b/i;

    // Key-depress feedback: ~4° tilt around the key's back edge, settled in
    // ~120 ms (exponential spring, tau ≈ 30 ms → 98% in 4τ).
    const KEY_PRESS_ANGLE = Math.PI / 45;
    const KEY_PRESS_TAU_MS = 30;

    // Wrong-note red key flash duration.
    const WRONG_FLASH_MS = 250;

    // Flame flare lifetime + pool size (pooled additive sprites — no
    // per-hit allocations).
    const FLAME_MS = 400;
    const FLAME_POOL_SIZE = 24;

    // Capability identity for the note-detection / audio-input domains.
    const ND_PROVIDER_ID = 'keys-midi';
    const PLUGIN_ID = 'keys_highway_3d';

    /* ======================================================================
     *  Notation data layer (pure — exported via createFactory.__test)
     * ====================================================================== */

    // Derive a beat's sounding duration in seconds from its written duration
    // and the running tempo:  base = (60/tempo) * (4/dur), dots multiply by
    // 2 − 2^(−dot), tuplets by tu[1]/tu[0].
    function beatDurSec(beat, tempo) {
        const dur = Number(beat && beat.dur);
        if (!Number.isFinite(dur) || dur <= 0 || !Number.isFinite(tempo) || tempo <= 0) return null;
        const base = (60 / tempo) * (4 / dur);
        const dot = Number(beat.dot) || 0;
        const dotFactor = 2 - Math.pow(2, -dot);
        const tu = Array.isArray(beat.tu) && beat.tu.length === 2
            && Number(beat.tu[0]) > 0 && Number(beat.tu[1]) > 0
            ? Number(beat.tu[1]) / Number(beat.tu[0]) : 1;
        return base * dotFactor * tu;
    }

    // Flatten accumulated notation (info + measures) into a render-ready,
    // time-sorted note list: [{midi, t, durSec, hand, measureIdx}].
    //
    // - `hand` = staff id (rh/lh per sloppak-spec; any other staff id is
    //   passed through and rendered with rh brightness).
    // - durSec: derived from dur/dot/tu at the running tempo (tempo state
    //   carries across measures; measures may omit `tempo` when unchanged).
    //   Tied notes (`tied: true`) extend the previous sounding note of the
    //   same midi in the same staff+voice instead of emitting a new block.
    //   Fallback when no tempo has been seen: gap to the next onset in the
    //   same voice (or +2s for the final beat).
    //   Sanity clamp: never longer than the gap to the next same-staff
    //   same-midi onset (prevents overlap artifacts from tempo-map drift);
    //   floor at 0.05s so 32nds stay visible.
    // - Grace beats (typed `grace` field) render as ordinary short notes at
    //   their written duration — good enough for a falling-note highway.
    function flattenNotation(measures) {
        const out = [];
        let tempo = null;
        // staff|voice → array of beats (for next-onset fallback + ties)
        const lastNoteByKey = new Map(); // `${staff}|${voice}|${midi}` → out[] index

        // First pass: collect every beat with its staff/voice context in
        // measure order so next-onset lookups can be done per voice.
        const beatsByVoice = new Map(); // `${staff}|${voice}` → [{beat, measure}]
        for (const measure of Array.isArray(measures) ? measures : []) {
            if (!measure || typeof measure !== 'object') continue;
            const staves = measure.staves && typeof measure.staves === 'object' ? measure.staves : {};
            for (const staffId of Object.keys(staves)) {
                const staff = staves[staffId];
                const voices = staff && Array.isArray(staff.voices) ? staff.voices : [];
                for (const voice of voices) {
                    if (!voice || !Array.isArray(voice.beats)) continue;
                    const vid = `${staffId}|${voice.v != null ? voice.v : 1}`;
                    if (!beatsByVoice.has(vid)) beatsByVoice.set(vid, []);
                    const list = beatsByVoice.get(vid);
                    for (const beat of voice.beats) {
                        if (!beat || typeof beat !== 'object') continue;
                        list.push({ beat, measure, staffId, vid });
                    }
                }
            }
        }
        for (const list of beatsByVoice.values()) {
            list.sort((a, b) => (Number(a.beat.t) || 0) - (Number(b.beat.t) || 0));
        }

        // Second pass in global measure order so the tempo state machine sees
        // tempo changes when they happen.
        for (const measure of Array.isArray(measures) ? measures : []) {
            if (!measure || typeof measure !== 'object') continue;
            const mTempo = Number(measure.tempo);
            if (Number.isFinite(mTempo) && mTempo > 0) tempo = mTempo;
            const measureIdx = Number(measure.idx) || 0;
            const staves = measure.staves && typeof measure.staves === 'object' ? measure.staves : {};
            for (const staffId of Object.keys(staves)) {
                const staff = staves[staffId];
                const voices = staff && Array.isArray(staff.voices) ? staff.voices : [];
                for (const voice of voices) {
                    if (!voice || !Array.isArray(voice.beats)) continue;
                    const vid = `${staffId}|${voice.v != null ? voice.v : 1}`;
                    const voiceBeats = beatsByVoice.get(vid) || [];
                    for (const beat of voice.beats) {
                        if (!beat || typeof beat !== 'object' || beat.rest) continue;
                        const t = Number(beat.t);
                        if (!Number.isFinite(t)) continue;
                        const notes = Array.isArray(beat.notes) ? beat.notes : [];
                        let durSec = beatDurSec(beat, tempo);
                        if (durSec == null) {
                            // No tempo seen — fall back to next onset in voice.
                            const i = voiceBeats.findIndex(e => e.beat === beat);
                            const next = i >= 0 ? voiceBeats.slice(i + 1).find(e => !e.beat.rest) : null;
                            durSec = next ? Math.max(0.05, Number(next.beat.t) - t) : 2.0;
                        }
                        for (const note of notes) {
                            if (!note || typeof note !== 'object') continue;
                            const midi = Number(note.midi);
                            if (!Number.isFinite(midi) || midi < 0 || midi > 127) continue;
                            const key = `${vid}|${midi}`;
                            if (note.tied && lastNoteByKey.has(key)) {
                                // Extend the previous sounding note; no new block.
                                const prev = out[lastNoteByKey.get(key)];
                                prev.durSec = Math.max(prev.durSec, (t - prev.t) + durSec);
                                continue;
                            }
                            out.push({
                                midi,
                                t,
                                durSec: Math.max(0.05, durSec),
                                hand: staffId,
                                measureIdx,
                            });
                            lastNoteByKey.set(key, out.length - 1);
                        }
                    }
                }
            }
        }

        out.sort((a, b) => a.t - b.t || a.midi - b.midi);

        // Overlap clamp: a note never rings past the next onset of the same
        // midi on the same hand.
        const lastByHandMidi = new Map();
        for (let i = 0; i < out.length; i++) {
            const note = out[i];
            const key = `${note.hand}|${note.midi}`;
            if (lastByHandMidi.has(key)) {
                const prev = out[lastByHandMidi.get(key)];
                if (prev.t + prev.durSec > note.t) {
                    prev.durSec = Math.max(0.05, note.t - prev.t);
                }
            }
            lastByHandMidi.set(key, i);
        }
        return out;
    }

    // Active key range with padding, clamped to the 88-key piano (A0–C8).
    // `activeLow`/`activeHigh` are the actual chart extremes (the keys that
    // tint, print their letter, and keep bright lanes); `low`/`high` add
    // RANGE_PAD semitones of plain keys around them. Keeping the active span
    // explicit (instead of re-deriving it as low+RANGE_PAD) stays correct at
    // the 88-key clamp edges.
    function keyRange(notes) {
        if (!notes.length) {
            // Empty chart: render a neutral two-octave keyboard with an
            // empty active span (activeLow > activeHigh — nothing lights).
            return { low: 48, high: 72, activeLow: 60, activeHigh: 59 };
        }
        let low = 127, high = 0;
        for (const n of notes) {
            if (n.midi < low) low = n.midi;
            if (n.midi > high) high = n.midi;
        }
        return {
            low: Math.max(21, low - RANGE_PAD),
            high: Math.min(108, high + RANGE_PAD),
            activeLow: low,
            activeHigh: high,
        };
    }

    // Measure markers [{idx, t}] for floating bar numbers.
    function measureMarkers(measures) {
        const out = [];
        for (const m of Array.isArray(measures) ? measures : []) {
            if (!m || typeof m !== 'object') continue;
            const t = Number(m.t);
            const idx = Number(m.idx);
            if (Number.isFinite(t) && Number.isFinite(idx)) out.push({ idx, t });
        }
        return out;
    }

    // Letter printed on a key top (C, C#, D, …) — pure.
    function noteLetter(midi) {
        return NOTE_NAMES[((midi % 12) + 12) % 12];
    }

    // World-space Z of a chart event at song-time `now`: the event sits at
    // `hitZ` exactly when now === eventT, approaching from -Z at `speed`
    // world-units per second. Shared by the note blocks (front edge) and the
    // floating bar-number sprites so they scroll in lockstep.
    function scrollZ(eventT, now, hitZ, speed) {
        return hitZ - (eventT - now) * speed;
    }

    /* ======================================================================
     *  Pure scoring logic (exported via createFactory.__test)
     * ====================================================================== */

    // Dedupe key for a chart note — same convention as the piano plugin's
    // _noteKey (time quantised to ms so float drift can't double-count).
    function noteKey(t, midi) {
        return t.toFixed(3) + '|' + midi;
    }

    // Score/accuracy formula MUST mirror the guitar notedetect path
    // (static/v3/stats-recorder.js, which itself mirrors lib/song_score.py):
    //   accuracy = hits / max(1, hits + misses)         (0..1 fraction)
    //   score    = round(hits * 100 * accuracy)         (monotonic in both)
    function accuracyOf(hits, misses) {
        return hits / Math.max(1, hits + misses);
    }
    function scoreOf(hits, misses) {
        return Math.round(hits * 100 * accuracyOf(hits, misses));
    }

    // Judge a played MIDI note against the time-sorted flattened note list
    // (piano plugin _checkHit port, minus the guitar-wire chord path — keys
    // chords are already flattened to simultaneous notes). Returns the
    // dedupe key of the matched un-hit chart note, or null when the play is
    // a wrong note / outside every window / a duplicate. The caller owns
    // adding the key to hitKeys and all hit/miss bookkeeping.
    function judgeHit(notes, playedMidi, t, hitKeys, tol) {
        if (!Array.isArray(notes) || !notes.length) return null;
        for (const n of notes) {
            if (n.t > t + tol + 0.5) break;
            if (n.t < t - tol - 0.5) continue;
            if (n.midi !== playedMidi) continue;
            if (Math.abs(n.t - t) > tol) continue;
            const key = noteKey(n.t, n.midi);
            if (hitKeys.has(key)) continue;
            return key;
        }
        return null;
    }

    // Missed-note sweep (piano _updateMissedNotes, rebuilt on a monotonic
    // cursor): walk notes whose window has fully elapsed and mark unhit
    // ones as missed. `cursor` ({idx}) makes the sweep O(elapsed) and
    // stall-proof — unlike a fixed look-back tail, a backgrounded tab or a
    // multi-second render hitch can't let elapsed notes slip past
    // uncounted (which would inflate accuracy for the rest of the run).
    // `floor` excludes notes that elapsed before a MIDI device was
    // connected so a mid-song connect doesn't retroactively count
    // everything as missed (the cursor still advances past them). Calls
    // onMiss(note) for each newly-missed note; returns the count.
    function sweepMissed(notes, t, hitKeys, missedKeys, tol, floor, onMiss, cursor) {
        if (!Array.isArray(notes)) return 0;
        const cutoff = t - tol - 0.05;
        let i = cursor ? cursor.idx : 0;
        let count = 0;
        for (; i < notes.length; i++) {
            const n = notes[i];
            if (n.t > cutoff) break;
            // Notes at or before the mid-song connect instant must not be
            // swept retroactively — a device connecting exactly as an onset
            // passes should not count that onset as a miss.
            if (floor != null && n.t <= floor) continue;
            const key = noteKey(n.t, n.midi);
            if (hitKeys.has(key) || missedKeys.has(key)) continue;
            missedKeys.add(key);
            count += 1;
            if (onMiss) onMiss(n);
        }
        if (cursor) cursor.idx = i;
        return count;
    }

    /* ======================================================================
     *  Notation fetch — private per-instance WS (Staff View pattern)
     * ====================================================================== */

    function fetchNotation(filename, arrangementIndex) {
        return new Promise((resolve, reject) => {
            let info = null;
            const measures = [];
            const url = (location.protocol === 'https:' ? 'wss://' : 'ws://')
                + location.host + '/ws/highway/' + encodeURIComponent(filename)
                + '?arrangement=' + encodeURIComponent(arrangementIndex);
            let settled = false;
            const ws = new WebSocket(url);
            const timer = setTimeout(() => {
                if (settled) return;
                settled = true;
                try { ws.close(); } catch (_) {}
                reject(new Error('notation stream timed out'));
            }, NOTATION_CHUNK_TIMEOUT_MS);
            const finish = () => {
                if (settled) return;
                settled = true;
                clearTimeout(timer);
                try { ws.close(); } catch (_) {}
                resolve({ info, measures });
            };
            ws.onmessage = (ev) => {
                let msg = null;
                try { msg = JSON.parse(ev.data); } catch (_) { return; }
                if (!msg || typeof msg !== 'object') return;
                if (msg.error) {
                    if (!settled) {
                        settled = true;
                        clearTimeout(timer);
                        try { ws.close(); } catch (_) {}
                        reject(new Error(String(msg.error)));
                    }
                    return;
                }
                if (msg.type === 'song_info' && !msg.has_notation) {
                    // Nothing to stream for this arrangement.
                    finish();
                    return;
                }
                if (msg.type === 'notation_info') {
                    info = msg;
                    if (!msg.total) finish();
                    return;
                }
                if (msg.type === 'notation_measures') {
                    for (const m of Array.isArray(msg.data) ? msg.data : []) measures.push(m);
                    if (info && measures.length >= (Number(msg.total) || 0)) finish();
                    return;
                }
                // `anchors` streams right after the notation block — if we see
                // it, the notation section is over regardless of count.
                if (msg.type === 'anchors' && info) finish();
            };
            ws.onerror = () => {
                if (settled) return;
                settled = true;
                clearTimeout(timer);
                reject(new Error('notation stream socket error'));
            };
            ws.onclose = () => { if (info) finish(); };
        });
    }

    /* ======================================================================
     *  Texture helpers (CanvasTexture — lazy, cached, never per-frame)
     * ====================================================================== */

    // Key-top letter textures, cached module-level by letter+style. Bounded
    // (12 pitch classes × 2 styles) and shared across factory instances;
    // CanvasTexture GPU copies are re-uploaded transparently after a renderer
    // dispose, so the cache survives teardown safely.
    const _glyphTexCache = new Map();
    function _glyphTexture(letter, dark) {
        const key = letter + '|' + (dark ? 'd' : 'l');
        let tex = _glyphTexCache.get(key);
        if (tex) return tex;
        const cnv = document.createElement('canvas');
        cnv.width = 96;
        cnv.height = 96;
        const ctx = cnv.getContext('2d');
        ctx.clearRect(0, 0, 96, 96);
        ctx.font = '600 48px system-ui, sans-serif';
        ctx.textAlign = 'center';
        ctx.textBaseline = 'middle';
        ctx.fillStyle = dark ? '#23232e' : '#e8e8f2';
        ctx.fillText(letter, 48, 50);
        tex = new T.CanvasTexture(cnv);
        tex.anisotropy = 4;
        _glyphTexCache.set(key, tex);
        return tex;
    }

    /* ======================================================================
     *  Web MIDI input (module-scope singleton — one MIDI access per tab)
     *
     *  Port of the piano plugin's MIDI block with drum_highway_3d's 3D-plugin
     *  refinements: id+name saved pick (Chrome regenerates ids per load),
     *  loopback blocklist, init-in-flight dedupe, and the _midiActive gate so
     *  an access promise resolving after destroy() can't wire a dead handler.
     * ====================================================================== */

    function _readStore(k) {
        try { return localStorage.getItem(k); } catch (_) { return null; }
    }
    function _writeStore(k, v) {
        try { localStorage.setItem(k, v); } catch (_) {}
    }

    const _cfg = {
        midiChannel: parseInt(_readStore(STORE_KEYS.midiChannel) || '-1', 10),
        transpose: parseInt(_readStore(STORE_KEYS.transpose) || '0', 10),
    };
    if (!Number.isFinite(_cfg.midiChannel)) _cfg.midiChannel = -1;
    if (!Number.isFinite(_cfg.transpose)) _cfg.transpose = 0;

    // MIDI is sourced from the core `midi-input` capability domain
    // (window.slopsmith.midiInput) rather than a private requestMIDIAccess() —
    // one device-access boundary shared with piano/drums/onboarding.
    let _midiReady = false;      // discover() has run
    let _midiHandle = null;      // live domain session handle (addListener/removeListener)
    let _midiListener = null;    // addListener callback wrapping _midiOnMessage
    let _midiStateSub = false;   // subscribed to midi-input:sources-changed
    let _midiInput = null;       // selected source descriptor { id, name }
    // Set by _midiConnect; the focused instance reads + clears it to skip
    // retroactive miss-counting for notes that elapsed while disconnected.
    let _midiJustConnected = false;
    // Gates the live listener wiring across the async connect.
    let _midiActive = false;
    // Routes incoming MIDI to the focused renderer instance.
    let _activeInstance = null;
    const _instances = new Set();
    let _midiInitInFlight = null;
    let _midiConnectSeq = 0;     // generation guard for async _midiConnect races

    // The core midi-input domain, if present (it ships with core).
    function _mi() {
        const m = window.slopsmith && window.slopsmith.midiInput;
        return (m && m.version === 1) ? m : null;
    }
    // Domain sources shaped like the old MIDIInput list: { id, name }.
    // sourceId == the old MIDIInput.id, so saved picks stay compatible.
    function _midiSources() {
        const mi = _mi();
        if (!mi) return [];
        return mi.listSources().map(s => ({ id: s.sourceId, name: s.label, key: s.logicalSourceKey }));
    }
    // Detach the live listener + release the domain session.
    function _midiDetach() {
        // Invalidate any in-flight _midiConnect open: a detach driven by device
        // removal (sources-changed) or an opt-out must supersede a pending open
        // so it can't resume and install a handle for a now-gone source.
        _midiConnectSeq += 1;
        if (_midiHandle && _midiListener) { try { _midiHandle.removeListener(_midiListener); } catch (_) { /* best-effort */ } }
        const mi = _mi();
        if (mi && _midiInput) { try { mi.close({ requester: PLUGIN_ID, logicalSourceKey: _midiInput.key || ('web-midi::' + _midiInput.id) }); } catch (_) { /* best-effort */ } }
        _midiHandle = null;
        _midiListener = null;
        _midiInput = null;
    }

    function _midiInit() {
        if (_midiReady) {
            // Only (re)connect when there's no live session. A repeated init
            // (settings panel open, extra splitscreen instance) must NOT re-enter
            // _midiConnect on an active handle — that tears down the live session
            // and releases held keys for no reason. After a full release the
            // handle is null, so reconnect happens then.
            if (!_midiHandle) _midiAutoConnect();
            return Promise.resolve();
        }
        if (_midiInitInFlight) return _midiInitInFlight;
        const mi = _mi();
        if (!mi) return Promise.resolve();
        _midiInitInFlight = (async () => {
            try {
                const r = await mi.discover();   // permission boundary (requestMIDIAccess, in core)
                // Only latch ready on a successful discovery — a denied/unavailable
                // outcome must NOT latch, or reopening never retries the prompt.
                if (!r || r.outcome !== 'handled') return;
                _midiReady = true;
                // Replug/unplug refresh (replaces MIDIAccess.onstatechange): the
                // domain re-discovers and emits sources-changed; refresh the list
                // and re-run auto-connect so a saved device reattaches.
                if (!_midiStateSub && window.slopsmith && typeof window.slopsmith.on === 'function') {
                    _midiStateSub = true;
                    window.slopsmith.on('midi-input:sources-changed', () => {
                        _midiNotifyDeviceListChanged();
                        if (!_midiInput) _midiAutoConnect(false);   // recovery: saved device only
                    });
                }
                _midiAutoConnect();
                _midiNotifyDeviceListChanged();
            } catch (e) {
                console.warn('[Keys-Hwy3D] MIDI access denied:', e);
            } finally {
                _midiInitInFlight = null;
            }
        })();
        return _midiInitInFlight;
    }

    function _readSavedPick() {
        try {
            const raw = _readStore(STORE_KEYS.midiPick);
            if (raw) {
                const obj = JSON.parse(raw);
                if (obj && typeof obj === 'object') {
                    return { id: String(obj.id || ''), name: String(obj.name || ''), key: String(obj.key || '') };
                }
            }
        } catch (_) {}
        return null;
    }

    function _writeSavedPick(id, name, key) {
        _writeStore(STORE_KEYS.midiPick, JSON.stringify({ id: id || '', name: name || '', key: key || '' }));
    }

    // Pure decision logic (exported via __test): pick which device to
    // auto-connect to from the current source list, the domain-wide selection
    // (`globalKey`, from Settings → Input Setup), and this plugin's own legacy
    // saved pick. Returns null for "connect to nothing" (explicit None opt-out,
    // or the configured device currently absent during hotplug recovery).
    //
    // The domain-wide selection is the SOURCE OF TRUTH (checked first): a device
    // configured globally must never be overridden by a stale plugin-local pick
    // or an arbitrary first-device fallback — that override was the bug. The
    // local pick is retained only as a fallback BELOW the global (and for
    // name-recovery when the global's logicalSourceKey went stale, e.g. a
    // browser that regenerates MIDI port ids across reloads). Auto-connect no
    // longer writes the local pick, so it only ever holds a value an explicit
    // selection put there (or a stale one from a pre-fix build — the global
    // still wins over it).
    function _pickMidiTarget(inputs, saved, globalKey, allowFallback) {
        if (!inputs.length) return null;
        const notBlocked = (i) => !!i && !_MIDI_BLOCKLIST_RE.test(i.name || '');
        // Explicit "None" opt-out (set only via the device-select API).
        if (saved && saved.id === '' && saved.name === '') return null;

        // 1. Domain-wide selection (Settings → Input Setup) — source of truth.
        if (globalKey) {
            const g = inputs.find(i => i.key === globalKey);
            if (notBlocked(g)) return g;
        }

        // 2. Legacy plugin-local pick, as a fallback below the global. Prefer the
        // globally-unique logicalSourceKey, then the legacy bare sourceId, then
        // case-insensitive name (Chrome on Linux regenerates ids per page load).
        let target = null;
        if (saved && saved.key) target = inputs.find(i => i.key === saved.key) || null;
        if (!target && saved && saved.id) target = inputs.find(i => i.id === saved.id) || null;
        if (!target && saved && saved.name) {
            const n = saved.name.toLowerCase();
            target = inputs.find(i => (i.name || '').toLowerCase() === n) || null;
        }
        // Never honour a saved pick that resolves to a loopback / "Midi Through"
        // port — it carries no device input, so it silently eats every note.
        if (target && !notBlocked(target)) target = null;
        if (target) return target;

        // 3. Nothing configured resolved to a present device. In recovery
        // (allowFallback=false) with a configured preference — a global pick or a
        // saved pick — that's currently absent, preserve it rather than switching
        // to an arbitrary device on a transient multi-device unplug. With no
        // preference at all, a first-device grab is the intended first-hotplug
        // auto-connect, allowed even in recovery.
        const hasPreference = !!(globalKey || (saved && (saved.key || saved.id || saved.name)));
        if (!allowFallback && hasPreference) return null;
        // Connect to nothing rather than a loopback: if every present device is
        // blocklisted, a first-device grab would attach to a "Midi Through"/IAC
        // port that carries no input and silently eats every note.
        return inputs.find(notBlocked) || null;
    }

    function _midiAutoConnect(allowFallback) {
        // Recovery (sources-changed after unplug) passes false: never switch to a
        // fallback input on a transient multi-device unplug (the configured
        // device returns on replug and reconnects then). Auto-connect is
        // non-persisting (persist omitted → false): it opens the resolved device
        // for this session WITHOUT writing the plugin-local pick or the shared
        // domain selection, so opening this highway can't clobber the user's
        // globally-configured device.
        if (allowFallback === undefined) allowFallback = true;
        const inputs = _midiSources();
        const saved = _readSavedPick();
        const mi = _mi();
        const globalKey = mi && typeof mi.getSelected === 'function' ? mi.getSelected() : null;
        const target = _pickMidiTarget(inputs, saved, globalKey, allowFallback);
        if (!target) return;
        _midiConnect(target.id, target.name, target.key);
    }

    // `persist` gates the two preference writes. Only an EXPLICIT device
    // selection (the device-select API) persists: it writes the plugin-local
    // pick AND the shared domain selection (`mi.select`, so the user's choice
    // becomes the global default). Auto-connect and programmatic opens pass
    // falsy — they open the resolved device for this session only, never
    // touching either store, so they can't clobber a globally-configured device.
    async function _midiConnect(id, name, key, persist) {
        // Capture our generation AFTER _midiDetach()'s own bump, so a later
        // detach (device removal / new connect / opt-out) reliably supersedes us.
        _midiDetach();
        const myGen = ++_midiConnectSeq;
        // Connecting (or opting out) invalidates per-instance held state —
        // clear EVERY live instance, not just the focused one, so no panel
        // shows stuck keys when it later takes focus (piano-plugin lesson).
        for (const inst of _instances) {
            if (inst && typeof inst._releaseAllHeld === 'function') inst._releaseAllHeld();
        }
        if (persist) _writeSavedPick(id || '', name || '', key || '');
        const mi = _mi();
        if ((id || key) && mi) {
            // Prefer the globally-unique logicalSourceKey so two providers that
            // expose the same provider-local sourceId stay distinguishable; fall
            // back to the legacy sourceId match.
            const src = (key && _midiSources().find(s => s.key === key))
                || (id && _midiSources().find(s => s.id === id))
                || null;
            if (src) {
                const lkey = src.key || ('web-midi::' + src.id);
                _midiInput = { id: src.id, name: src.name, key: lkey };
                _midiJustConnected = true;
                // Only an explicit selection writes the shared global default;
                // open takes the logicalSourceKey directly, so select() is not
                // needed to open — it exists purely to set the global. Persist it
                // BEFORE the no-instance early return so a settings-panel pick with
                // no live renderer still updates the shared default (best-effort:
                // a select hiccup must not abort the connect).
                if (persist) { try { await mi.select(lkey); } catch (_) { /* best-effort */ } }
                // No live renderer to consume OR release a session — don't hold one
                // open (settings-only ensure-init, or the last instance was torn
                // down during async discovery). A later renderer mount re-runs
                // auto-connect and opens for real, releasing on destroy.
                if (_instances.size === 0) { _midiNotifyDeviceListChanged(); return; }
                try {
                    const res = await mi.open({ requester: PLUGIN_ID, logicalSourceKey: lkey });
                    // A newer _midiConnect (device switch / None / replug) ran while
                    // we awaited open — discard this stale session so we don't wire a
                    // listener for a device the user already moved off of.
                    if (myGen !== _midiConnectSeq) {
                        if (!_midiInput || _midiInput.key !== lkey) { try { mi.close({ requester: PLUGIN_ID, logicalSourceKey: lkey }); } catch (_) { /* best-effort */ } }
                        return;
                    }
                    if (res && res.handle) {
                        _midiHandle = res.handle;
                        // The domain handle delivers raw MIDI data; adapt to the
                        // old MIDIMessageEvent shape so _midiOnMessage is unchanged.
                        _midiListener = (data) => _midiOnMessage({ data });
                        if (_midiActive) _midiHandle.addListener(_midiListener);
                    } else {
                        // Open yielded no live handle (device vanished post-discovery,
                        // or denied/unavailable). Clear the selection so the render
                        // loop's connected-device gate doesn't sweep phantom misses.
                        _midiInput = null;
                    }
                } catch (e) {
                    console.warn('[Keys-Hwy3D] MIDI open failed:', e);
                    // Only clear if we're still the current connect — a stale older
                    // open's rejection must not wipe a newer connect's installed
                    // _midiInput/_midiHandle (which would also leak the live handle,
                    // since closes are gated on _midiInput).
                    if (myGen === _midiConnectSeq) _midiInput = null;
                }
            }
        }
        _midiNotifyDeviceListChanged();
    }

    function _midiResume() {
        // Idempotent: a second live renderer instance (splitscreen/overlapping
        // lifetimes) calls this while already active. The domain handle's
        // addListener is Set-backed, but don't rely on the provider de-duping —
        // re-adding here could double-deliver one MIDI note to the focused
        // instance and score a hit plus duplicate misses.
        if (_midiActive) return;
        _midiActive = true;
        if (_midiHandle && _midiListener) { try { _midiHandle.addListener(_midiListener); } catch (_) { /* best-effort */ } }
    }
    // Called when the LAST live instance is torn down: fully release the shared
    // midi-input domain session (via _midiDetach: close + null + generation bump),
    // not just the listener, so the device/provider session isn't held open after
    // the visualization is gone. Re-mount's _midiInit auto-connects from the saved
    // pick, so _midiReady is intentionally left latched.
    function _midiReleaseSession() {
        _midiActive = false;
        _midiDetach();
    }

    function _midiOnMessage(e) {
        if (!_activeInstance) return;
        const data = e.data;
        if (!data || data.length < 2) return;
        const status = data[0];
        const ch = status & 0x0f;
        if (_cfg.midiChannel >= 0 && ch !== _cfg.midiChannel) return;
        const cmd = status & 0xf0;
        const note = data[1];
        const vel = data.length > 2 ? data[2] : 0;
        // Raw note number goes to the instance; transpose is applied there
        // and remembered per note-on so a transpose change between note-on
        // and note-off can't strand a held key (piano-plugin invariant).
        if (cmd === 0x90 && vel > 0) {
            _activeInstance._handleNoteOn(note, vel);
        } else if (cmd === 0x80 || (cmd === 0x90 && vel === 0)) {
            _activeInstance._handleNoteOff(note);
        } else if (cmd === 0xb0 && note === 64) {
            _activeInstance._handleSustain(vel >= 64);  // CC64 sustain pedal
        }
    }

    function _midiNotifyDeviceListChanged() {
        // Clear a vanished selection so miss-sweeping stops. No note-off
        // can ever arrive for keys that were down when the device went
        // away, so clear held state on every instance too — otherwise
        // those keys stay visually depressed for the rest of the session.
        if (_midiInput && !_midiSources().some(s => s.id === _midiInput.id)) {
            _midiDetach();
            for (const inst of _instances) {
                if (inst && typeof inst._releaseAllHeld === 'function') inst._releaseAllHeld();
            }
        }
        _aiRefreshSources();
        try { window.dispatchEvent(new CustomEvent('keys3d:midi_devices')); } catch (_) {}
    }

    function _midiListInputs() {
        return _midiSources().map(s => ({ id: s.id, name: s.name || s.id }));
    }

    /* ── Built-in synth — hear your playing (WebAudio, offline-safe, no CDN) ── */
    let _synthCtx = null, _synthMaster = null;
    const _synthVoices = new Map();   // midi → { o1, o2, g }
    function _synthResume() {
        if (!_synthCtx) {
            try {
                const AC = window.AudioContext || window.webkitAudioContext;
                if (!AC) return;
                _synthCtx = new AC();
                _synthMaster = _synthCtx.createGain();
                _synthMaster.gain.value = 0.45;
                _synthMaster.connect(_synthCtx.destination);
            } catch (_) { _synthCtx = null; return; }
        }
        if (_synthCtx.state === 'suspended') _synthCtx.resume().catch(() => {});
    }
    // Autoplay policy: an AudioContext can only start from a real user gesture
    // (a MIDI event doesn't count), so prime/resume it on any page input.
    // Guard addEventListener — the test harness provides a bare vm window
    // without DOM event support, and the synth is never invoked there.
    if (typeof window !== 'undefined' && typeof window.addEventListener === 'function') {
        ['pointerdown', 'keydown'].forEach(ev =>
            window.addEventListener(ev, _synthResume, { passive: true }));
    }
    function _synthNoteOn(midi, vel) {
        _synthResume();
        if (!_synthCtx || _synthCtx.state !== 'running') return;
        if (_synthVoices.has(midi)) _synthNoteOff(midi);
        const now = _synthCtx.currentTime;
        const f = 440 * Math.pow(2, (midi - 69) / 12);
        const o1 = _synthCtx.createOscillator(); o1.type = 'triangle'; o1.frequency.value = f;
        const o2 = _synthCtx.createOscillator(); o2.type = 'sine';     o2.frequency.value = f; o2.detune.value = -6;
        const lp = _synthCtx.createBiquadFilter(); lp.type = 'lowpass'; lp.frequency.value = Math.min(9000, f * 7);
        const g = _synthCtx.createGain();
        const peak = 0.12 + 0.16 * (Math.max(1, Math.min(127, vel || 96)) / 127);
        g.gain.setValueAtTime(0.0001, now);
        g.gain.exponentialRampToValueAtTime(peak, now + 0.006);        // attack
        g.gain.exponentialRampToValueAtTime(peak * 0.55, now + 0.35);  // decay → sustain
        o1.connect(lp); o2.connect(lp); lp.connect(g); g.connect(_synthMaster);
        o1.start(now); o2.start(now);
        _synthVoices.set(midi, { o1, o2, g });
    }
    function _synthNoteOff(midi) {
        const v = _synthVoices.get(midi);
        if (!v || !_synthCtx) return;
        _synthVoices.delete(midi);
        const now = _synthCtx.currentTime;
        try {
            v.g.gain.cancelScheduledValues(now);
            v.g.gain.setValueAtTime(Math.max(0.0001, v.g.gain.value), now);
            v.g.gain.exponentialRampToValueAtTime(0.0001, now + 0.22); // release
            v.o1.stop(now + 0.25); v.o2.stop(now + 0.25);
        } catch (_) {}
    }
    function _synthAllOff() {
        for (const midi of Array.from(_synthVoices.keys())) _synthNoteOff(midi);
    }

    /* ── Device/settings control API (settings UI + console) ───────────── */
    window.keysH3dEnsureMidiInit = function () { return _midiInit(); };
    window.keysH3dListMidiInputs = function () { return _midiListInputs(); };
    window.keysH3dGetMidiInputId = function () { return _midiInput ? _midiInput.id : ''; };
    window.keysH3dSetMidiInput = function (id) {
        // `id` may be a logicalSourceKey (new host calls) or a legacy sourceId.
        // Explicit user selection → persist (local pick + shared global default).
        const src = id
            ? (_midiSources().find(s => s.key === id) || _midiSources().find(s => s.id === id))
            : null;
        _midiConnect(src ? src.id : (id || ''), src ? src.name : '', src ? src.key : '', true);
        return true;
    };
    window.keysH3dGetMidiChannel = function () { return _cfg.midiChannel; };
    window.keysH3dSetMidiChannel = function (ch) {
        const v = parseInt(ch, 10);
        _cfg.midiChannel = Number.isFinite(v) ? Math.max(-1, Math.min(15, v)) : -1;
        _writeStore(STORE_KEYS.midiChannel, String(_cfg.midiChannel));
    };
    window.keysH3dGetTranspose = function () { return _cfg.transpose; };
    window.keysH3dSetTranspose = function (semis) {
        const v = parseInt(semis, 10);
        _cfg.transpose = Number.isFinite(v) ? Math.max(-24, Math.min(24, v)) : 0;
        _writeStore(STORE_KEYS.transpose, String(_cfg.transpose));
    };

    // Classify a hit's timing against its matched chart note. delta =
    // note.t - now: positive → struck before the note crossed the line
    // (EARLY), negative → after (LATE); the inner 40% of the hit window
    // reads as on-time. PORTED FROM drum_highway_3d (same proportions as
    // highway_3d's timing verdicts) — keep in sync.
    function _classifyTiming(delta, tol) {
        if (!Number.isFinite(delta) || !Number.isFinite(tol)) return 'OK';
        if (Math.abs(delta) <= tol * 0.4) return 'OK';
        return delta > 0 ? 'EARLY' : 'LATE';
    }

    // Host splitscreen state (PORTED FROM highway_3d _ssActive, minus the
    // focus-API checks the guitar needs for input routing — here it only
    // gates GPU cost, so "is a split active at all" is the right question;
    // a mixed split (this viz + another renderer) must count too).
    function _ssActive() {
        const ss = window.feedBackSplitscreen;
        return !!(ss && typeof ss.isActive === 'function' && ss.isActive());
    }

    /* ======================================================================
     *  Visual-FX settings — guitar-highway parity controls
     * ====================================================================== */

    // Defaults for the graphics/FX controls this plugin exposes. Keys mirror
    // the guitar highway's `h3d_bg_*` vocabulary under this plugin's own
    // `keys3d_bg_*` localStorage prefix; later parity PRs (sparks, themes,
    // background styles, score FX) extend this object with their own keys.
    // Everything defaults ON — the settings screen is the opt-out.
    const FX_DEFAULTS = {
        bloom: true,
        sparks: true,     // pooled hit-spark bursts at the struck key
        timingFx: true,   // early/late/on-time coloring of the sparks
        streakFx: true,   // consecutive-hit escalation (bigger bursts)
        hitFx: 0.7,       // 0–1 master intensity for the hit-line kick
        vibrancy: 0.85,   // note-gem opacity + lane-guide strength
        cinematic: true,  // rebalanced lighting (dimmer ambient, stronger key)
        glow: 0.5,        // 0–1 emissive multiplier (0.5 = the stock look)
        scoreFx: true,    // 2D overlay: +N pops, combo rings, streak-break wash
        bgIntensity: 0.5, // background-ambience density/strength
        bgReactive: true, // background reacts to the audio analyser
        // Highway-layout options (apply on the next chart build via init()'s
        // fx re-read). The sharp LAYOUT is a separate string setting
        // (keys3d_bg_sharpMode); these two are the booleans.
        octaveGaps: true,      // ON: wider divider gap at each B→C octave boundary
        laneOpacity: 0.0,      // 0–1: lane-color strength. 0 (default) = dark floor +
        //                        block guide lines (E→F, B→C); 1 = full colored lanes; crossfades.
        octaveContrast: 0.5,   // 0–1: how strongly the B→C octave line stands out. It
        //                        auto-darkens with lane opacity and brightens as it fades.
        // Camera base-rig fine-tune. These shift the BASE vantage point the
        // auto-pan/zoom follow-motion is built on (they multiply/offset the
        // active CAM_PRESET before the per-frame pan + dolly), so the camera
        // still tracks the notes — just from a nudged height/distance/tilt.
        camHeight: 1.0,   // ×preset camera height (higher = more overhead)
        camDist: 1.0,     // ×preset camera distance (larger = further back)
        camTilt: 0.0,     // aim offset up(+)/down(−); 0 = neutral — the tuned overhead aim lives in CAM_PRESETS.overhead, so this fine-tune only nudges from a preset (and Classic + tilt 0 == the historical rig)
    };
    // Numeric FX keys clamp to a declared [min, max]; keys absent from this
    // table keep the historical 0–1 slider range. The camera fine-tune knobs
    // are multipliers/offsets centred on 1 (or 0), so they need headroom and a
    // floor a 0–1 range couldn't express.
    const FX_RANGES = {
        camHeight: [0.4, 2.2],
        camDist: [0.4, 2.2],
        camTilt: [-1.0, 1.0],
    };
    function _fxClamp(key, n) {
        const r = FX_RANGES[key] || [0, 1];
        return Math.min(r[1], Math.max(r[0], n));
    }
    const FX_LS_PREFIX = 'keys3d_bg_';
    // Theme id lives OUTSIDE FX_DEFAULTS (string, not bool/number) — its own
    // localStorage key + validation against BG_THEMES.
    const FX_LS_THEME = 'keys3d_bg_theme';
    // Background-ambience style — string-valued like the theme, so it gets
    // its own validated key + setter rather than an FX_DEFAULTS slot.
    // butterchurn/image/video from the guitar are deliberately out of scope.
    const BG_STYLE_IDS = ['off', 'particles', 'lights', 'geometric'];
    const FX_LS_STYLE = 'keys3d_bg_style';
    function readBgStyleSetting() {
        try {
            const id = localStorage.getItem(FX_LS_STYLE);
            if (id && BG_STYLE_IDS.indexOf(id) !== -1) return id;
        } catch (_) {}
        return 'particles';
    }
    window.keys3dSetBgStyle = function (id) {
        if (BG_STYLE_IDS.indexOf(id) === -1) return;
        try { localStorage.setItem(FX_LS_STYLE, id); } catch (_) {}
        try {
            window.dispatchEvent(new CustomEvent('keys3d:settings', { detail: { bgStyle: id } }));
        } catch (_) { /* dispatch unavailable — persisted value applies next init */ }
    };

    /* ======================================================================
     *  Audio analyser bridge — module singletons, shared across instances.
     *  PORTED FROM highway_3d _bgGetAnalyser/_bgReadBands via
     *  drum_highway_3d (keep in sync; the guitar's diagnostics plumbing is
     *  dropped).
     * ====================================================================== */
    const BG_FREQ_BINS = 128;
    const BG_ZERO_BANDS = { bass: 0, mid: 0, treble: 0 };
    let _bgAudio = null;       // {ctx, analyser, freq, source} | {failed, permanent}
    let _bgAudioCore = null;   // remembered core tap (one-shot per element)
    let _bgAudioFailedAt = 0;
    const _BG_AUDIO_RETRY_MS = 1000;
    function _bgGetAnalyser() {
        // Prefer the stems plugin's side-chain analyser: on sloppaks the
        // #audio element is a silent virtual transport. Per-song node —
        // cache keyed on identity so a song switch re-adopts automatically.
        const stemsApi = window.feedBack && window.feedBack.stems;
        const stemsAnalyser = (stemsApi && typeof stemsApi.getAnalyser === 'function')
            ? stemsApi.getAnalyser() : null;
        if (stemsAnalyser) {
            if (!_bgAudio || _bgAudio.source !== 'stems' || _bgAudio.analyser !== stemsAnalyser) {
                _bgAudio = {
                    ctx: stemsAnalyser.context,
                    analyser: stemsAnalyser,
                    freq: new Uint8Array(Math.max(BG_FREQ_BINS, stemsAnalyser.frequencyBinCount)),
                    source: 'stems',
                };
            }
            return _bgAudio;
        }
        if (_bgAudio && _bgAudio.source === 'stems') _bgAudio = _bgAudioCore;
        if (_bgAudio && !_bgAudio.failed) return _bgAudio;
        if (_bgAudio && _bgAudio.failed) {
            if (_bgAudio.permanent) return null;
            if (performance.now() - _bgAudioFailedAt < _BG_AUDIO_RETRY_MS) return null;
        }
        const audio = document.getElementById('audio');
        if (!audio) return null;
        // Never tap #audio before the page has user activation: a fresh
        // AudioContext would start suspended and route LIVE playback into
        // silence until the next play gesture (createMediaElementSource is
        // one-shot — not undoable). Called per frame, so this just retries
        // once activation exists. (Improvement over the guitar's copy, which
        // only resumes after the fact — candidate to port back.)
        const ua = navigator.userActivation;
        if (ua && ua.hasBeenActive === false) return null;
        // Shared tap: createMediaElementSource is one-shot per element, so
        // the FIRST visualizer to tap #audio publishes it at
        // window.__feedBackAudioTap and every later one (this plugin, the
        // drum highway — the guitar is a port-back candidate) adopts it
        // instead of throwing InvalidStateError in a mixed splitscreen.
        const shared = window.__feedBackAudioTap;
        if (shared && shared.analyser && shared.mediaEl === audio) {
            _bgAudio = {
                ctx: shared.ctx,
                analyser: shared.analyser,
                freq: new Uint8Array(Math.max(BG_FREQ_BINS, shared.analyser.frequencyBinCount)),
                source: 'core',
            };
            _bgAudioCore = _bgAudio;
            return _bgAudio;
        }
        let ctx = null;
        try {
            const Ctx = window.AudioContext || window.webkitAudioContext;
            if (!Ctx) throw new Error('Web Audio API not available');
            ctx = new Ctx();
            const source = ctx.createMediaElementSource(audio);
            const analyser = ctx.createAnalyser();
            analyser.fftSize = 256;
            source.connect(analyser);
            analyser.connect(ctx.destination);
            _bgAudio = { ctx, analyser, freq: new Uint8Array(Math.max(BG_FREQ_BINS, analyser.frequencyBinCount)), source: 'core' };
            _bgAudioCore = _bgAudio;
            try { window.__feedBackAudioTap = { ctx, analyser, mediaEl: audio }; } catch (_) {}
            const resume = () => {
                if (ctx.state === 'suspended' && typeof ctx.resume === 'function') {
                    ctx.resume().catch(() => { /* no gesture yet, retry on next play */ });
                }
            };
            resume();
            audio.addEventListener('play', resume);
            return _bgAudio;
        } catch (e) {
            if (ctx && typeof ctx.close === 'function') {
                try { ctx.close(); } catch (_) {}
            }
            console.warn('[Keys-Hwy3D] failed to set up audio analyser:', e);
            _bgAudio = { failed: true, permanent: !!(e && e.name === 'InvalidStateError') };
            _bgAudioFailedAt = performance.now();
            return null;
        }
    }
    const _BG_BANDS_CACHE_MS = 5;
    let _bgBandsLastT = -Infinity;
    const _bgBandsCache = { bass: 0, mid: 0, treble: 0 };
    function _bgReadBands() {
        const a = _bgGetAnalyser();
        if (!a) return BG_ZERO_BANDS;
        const t = performance.now();
        if (t - _bgBandsLastT < _BG_BANDS_CACHE_MS) return _bgBandsCache;
        _bgBandsLastT = t;
        a.analyser.getByteFrequencyData(a.freq);
        let bass = 0, mid = 0, treble = 0;
        for (let i = 0; i < 8; i++) bass += a.freq[i];
        for (let i = 8; i < 40; i++) mid += a.freq[i];
        for (let i = 40; i < 128; i++) treble += a.freq[i];
        _bgBandsCache.bass = bass / (8 * 255);
        _bgBandsCache.mid = mid / (32 * 255);
        _bgBandsCache.treble = treble / (88 * 255);
        return _bgBandsCache;
    }

    // Scene color themes — PORTED FROM highway_3d BG_THEMES (keep the ids,
    // names and values in sync so a user's look carries across instruments).
    // One pick drives clear/fog + the highway floor + the lane-edge rails;
    // pitch-class note/key colors are identity and never themed.
    //   clear/fog — background gradient anchor + distance fog
    //   board     — the highway floor plane
    //   laneDim   — the thin lane-edge rails (lane itself is pitch-colored)
    const BG_THEMES = {
        // 'default' is THIS plugin's original palette (fog 0x1a1a2e, floor
        // 0x141422, rail 0x2a2a3e) — byte-identical for anyone who never
        // touches the setting. Every OTHER id matches the guitar's table.
        default:    { clear: 0x1a1a2e, fog: 0x1a1a2e, board: 0x141422, laneDim: 0x2a2a3e },
        midnight:   { clear: 0x0a0e1a, fog: 0x0a0e1a, board: 0x080d1c, lane: 0x244fae, laneDim: 0x122a5e },
        charcoal:   { clear: 0x16181c, fog: 0x16181c, board: 0x141417, lane: 0x525a66, laneDim: 0x282d34 },
        deeppurple: { clear: 0x140a1e, fog: 0x140a1e, board: 0x0b0610, lane: 0x3a1f6e, laneDim: 0x1f1040 },
        forest:     { clear: 0x0a1614, fog: 0x0a1614, board: 0x06100c, lane: 0x15602a, laneDim: 0x0a3318 },
        warmslate:  { clear: 0x1c130b, fog: 0x1c130b, board: 0x0e0805, lane: 0x5e3a12, laneDim: 0x341f0a },
        deepfocus:  { clear: 0x0c0c0d, fog: 0x0c0c0d, board: 0x060606, lane: 0x2f7fa0, laneDim: 0x163c4e },
        deepsea:    { clear: 0x06222b, fog: 0x06222b, board: 0x03141a, lane: 0x0e5a63, laneDim: 0x063338 },
        cathode:    { clear: 0x140b03, fog: 0x140b03, board: 0x0c0702, lane: 0x6e4a0e, laneDim: 0x3a2806 },
        cathodegreen: { clear: 0x07301a, fog: 0x07301a, board: 0x031a0c, lane: 0x0e6e2a, laneDim: 0x073a18 },
        hearth:     { clear: 0x280806, fog: 0x280806, board: 0x1a0606, lane: 0x7a2410, laneDim: 0x3f1409 },
    };
    function _bgThemeColors(id) { return BG_THEMES[id] || BG_THEMES.default; }
    function readThemeSetting() {
        try {
            const id = localStorage.getItem(FX_LS_THEME);
            if (id && BG_THEMES[id]) return id;
        } catch (_) {}
        return 'default';
    }
    window.keys3dSetTheme = function (id) {
        if (!BG_THEMES[id]) return;
        try { localStorage.setItem(FX_LS_THEME, id); } catch (_) {}
        try {
            window.dispatchEvent(new CustomEvent('keys3d:settings', { detail: { theme: id } }));
        } catch (_) { /* dispatch unavailable — persisted value applies next init */ }
    };

    // Note-color palette id — string-valued like the theme, so it gets its
    // own validated key + setter rather than an FX_DEFAULTS slot.
    const FX_LS_PALETTE = 'keys3d_bg_palette';
    function readPaletteSetting() {
        try {
            const id = localStorage.getItem(FX_LS_PALETTE);
            if (id && PALETTE_IDS.indexOf(id) !== -1) return id;
        } catch (_) {}
        // Default: the octave scheme (each octave its own color, darker
        // sharps) — the plug-and-play piano look. Emerald/classic/etc. remain
        // selectable.
        return 'octaves';
    }
    window.keys3dSetPalette = function (id) {
        if (PALETTE_IDS.indexOf(id) === -1) return;
        try { localStorage.setItem(FX_LS_PALETTE, id); } catch (_) {}
        try {
            window.dispatchEvent(new CustomEvent('keys3d:settings', { detail: { palette: id } }));
        } catch (_) { /* dispatch unavailable — persisted value applies next init */ }
    };

    // Sharp-display layout id — string-valued (3-way), its own validated key +
    // setter. 'floating' = the original raised-plane sharps with white-only
    // lanes; 'flat' = every note on one plane with piano-shaped tiled lanes
    // (sharps leaned to even the naturals); 'realistic'
    // = one plane with note bars sized like the physical keys (full naturals,
    // full sharps overlapping on top). Geometry-time — applied on the next chart
    // build via init()'s re-read.
    const FX_LS_SHARPMODE = 'keys3d_bg_sharpMode';
    const SHARP_MODES = ['floating', 'flat', 'realistic'];
    function readSharpModeSetting() {
        try {
            const id = localStorage.getItem(FX_LS_SHARPMODE);
            if (id && SHARP_MODES.indexOf(id) !== -1) return id;
        } catch (_) {}
        return 'realistic'; // default layout: physical-key-sized bars on one plane
    }
    window.keys3dSetSharpMode = function (id) {
        if (SHARP_MODES.indexOf(id) === -1) return;
        try { localStorage.setItem(FX_LS_SHARPMODE, id); } catch (_) {}
        try {
            window.dispatchEvent(new CustomEvent('keys3d:settings', { detail: { sharpMode: id } }));
        } catch (_) { /* dispatch unavailable — persisted value applies next init */ }
    };

    // Camera-rig presets. 'classic' is the original low, near-telephoto rig
    // (numerically identical to the historical constants, so 'classic' with the
    // neutral camTilt default reproduces the exact stock framing). y/z/lookY/lookZ
    // are in pre-K world units — the instance multiplies by K at the use sites,
    // same as the old constants did. Zoom scales position AND look-at every
    // frame, so all presets inherit the adaptive dolly behaviour unchanged.
    // Each preset carries its OWN tuned aim in lookY: 'overhead' bakes in the
    // plug-and-play downward tilt (the −0.6 × CAM_TILT_UNITS = −33 that used to
    // ship as the camTilt default) so the default look is unchanged while
    // camTilt now defaults to 0 (a neutral nudge from whatever preset is picked).
    const CAM_PRESETS = {
        classic:  { fov: 40, y: 46,  z: 112, lookY: 8,   lookZ: -165 },
        elevated: { fov: 44, y: 78,  z: 118, lookY: 4,   lookZ: -150 },
        overhead: { fov: 48, y: 118, z: 74,  lookY: -33, lookZ: -115 },
    };
    // Camera preset id — string-valued like the theme, so it gets its own
    // validated key + setter rather than an FX_DEFAULTS slot.
    const FX_LS_CAMERA = 'keys3d_bg_camera';
    function readCameraSetting() {
        try {
            const id = localStorage.getItem(FX_LS_CAMERA);
            if (id && CAM_PRESETS[id]) return id;
        } catch (_) {}
        // Default: the overhead reading rig — its lookY already carries the
        // tuned downward aim, so with the neutral camTilt default it gives the
        // plug-and-play piano view out of the box. Others selectable ('classic'
        // + neutral tilt = the exact historical rig).
        return 'overhead';
    }
    window.keys3dSetCamera = function (id) {
        if (!CAM_PRESETS[id]) return;
        try { localStorage.setItem(FX_LS_CAMERA, id); } catch (_) {}
        try {
            window.dispatchEvent(new CustomEvent('keys3d:settings', { detail: { camera: id } }));
        } catch (_) { /* dispatch unavailable — persisted value applies next init */ }
    };

    function readFxSettings() {
        const fx = Object.assign({}, FX_DEFAULTS);
        try {
            for (const k of Object.keys(FX_DEFAULTS)) {
                const raw = localStorage.getItem(FX_LS_PREFIX + k);
                if (raw === null) continue;
                if (typeof FX_DEFAULTS[k] === 'boolean') {
                    // Explicit values only — anything else (corrupt/foreign
                    // write) keeps the default rather than silently
                    // disabling an effect.
                    if (raw === '1' || raw === 'true') fx[k] = true;
                    else if (raw === '0' || raw === 'false') fx[k] = false;
                } else {
                    const n = parseFloat(raw);
                    // Numeric FX keys clamp to their declared range
                    // (default 0-1) so a corrupt/foreign write can't
                    // overdrive opacities or the geometry multipliers.
                    if (Number.isFinite(n)) fx[k] = _fxClamp(k, n);
                }
            }
        } catch (_) { /* localStorage unavailable — use defaults */ }
        return fx;
    }

    // Single setter for every FX key — settings.html calls
    // window.keys3dSetFx('bloom', checked). Coerces to the default's type so
    // a slider string can't poison a boolean toggle.
    window.keys3dSetFx = function (key, value) {
        if (!(key in FX_DEFAULTS)) return;
        let v;
        if (typeof FX_DEFAULTS[key] === 'boolean') {
            // Same accepted representations as readFxSettings so the
            // setter/reader round-trip is consistent ('0'/'false' → false).
            v = value === true || value === 1 || value === '1' || value === 'true';
        } else {
            v = Number(value);
            if (!Number.isFinite(v)) return;
            v = _fxClamp(key, v);   // declared range, default 0-1
        }
        try {
            localStorage.setItem(FX_LS_PREFIX + key, typeof v === 'boolean' ? (v ? '1' : '0') : String(v));
        } catch (_) {}
        try {
            window.dispatchEvent(new CustomEvent('keys3d:settings', { detail: { fx: { [key]: v } } }));
        } catch (_) { /* dispatch unavailable — persisted value applies next init */ }
    };

    /* ======================================================================
     *  Capability wiring — note-detection + audio-input domains
     *
     *  Everything here is guarded: on servers without the capability hosts
     *  (the spec-009 note-detection owner / the audio-session input owner)
     *  every call degrades to a silent no-op.
     * ====================================================================== */

    function _capsApi() {
        const c = window.slopsmith && window.slopsmith.capabilities;
        return (c && c.version === 1 && typeof c.command === 'function') ? c : null;
    }

    async function _capCommand(domain, name, payload, reason) {
        const caps = _capsApi();
        if (!caps) return null;
        try {
            const r = await caps.command(domain, name, {
                requester: PLUGIN_ID,
                source: PLUGIN_ID,
                origin: 'system',
                reason: reason || ('Keys Highway 3D ' + domain + '.' + name),
                payload: payload || {},
            });
            return (r && r.outcome === 'handled') ? (r.payload || {}) : null;
        } catch (_) { return null; }
    }

    // ── note-detection: this plugin is a Web-MIDI exact-verdict provider ──
    let _ndProviderRegistered = false;
    async function _ndEnsureProvider() {
        if (_ndProviderRegistered) return;
        const p = await _capCommand('note-detection', 'register-provider', {
            providerId: ND_PROVIDER_ID,
            label: 'Keys Highway MIDI',
            kind: 'midi',
            primitives: ['verify.target'],
        }, 'Register the Web MIDI keys detector');
        if (p) _ndProviderRegistered = true;
    }

    // Hit/miss observability events (consumers own judgment; the domain
    // only carries the result — spec 009 doctrine).
    function _ndReport(hit, midi, bindingId) {
        const nd = window.slopsmith && window.slopsmith.noteDetection;
        if (!nd || nd.version !== 1) return;
        try {
            (hit ? nd.reportHit : nd.reportMiss)({
                bindingId: bindingId || null,
                providerId: ND_PROVIDER_ID,
                midi,
                hit,
            });
        } catch (_) { /* observability must never break scoring */ }
    }

    // ── audio-input: enumerate Web MIDI inputs as input sources ─────────
    //
    // Labels are PSEUDONYMIZED ('midi-input-1', …) — device names never
    // leave the plugin (the audio-input contract surfaces labels verbatim
    // in redaction-safe diagnostics).
    let _aiRegisteredCount = 0;

    function _aiSourceSpec(index) {
        const n = index + 1;
        return {
            version: 1,
            providerId: ND_PROVIDER_ID,
            ownerPluginId: PLUGIN_ID,
            sourceId: ND_PROVIDER_ID + ':input-' + n,
            logicalSourceKey: ND_PROVIDER_ID + ':input-' + n,
            label: 'midi-input-' + n,
            labelSafe: true,
            kind: 'instrument',
            availability: 'available',
            operations: ['source.enumerate', 'source.describe', 'source.open', 'source.close'],
            operationHandlers: {
                'source.enumerate': _aiEnumerate,
                'source.describe': _aiDescribe,
                'source.open': _aiOpen,
                'source.close': _aiClose,
            },
        };
    }

    function _aiEnumerate() {
        const sources = _midiListInputs().map((_inp, i) => {
            const spec = _aiSourceSpec(i);
            delete spec.operationHandlers;  // host re-merges handlers per provider
            return spec;
        });
        return { outcome: 'handled', payload: { sources } };
    }

    function _aiDescribe(req) {
        const idx = _aiIndexFor(req && (req.sourceId || req.logicalSourceKey));
        const inputs = _midiListInputs();
        if (idx == null || idx >= inputs.length) {
            return { outcome: 'degraded', reason: 'Unknown MIDI input source' };
        }
        const spec = _aiSourceSpec(idx);
        delete spec.operationHandlers;
        return { outcome: 'handled', payload: { source: spec } };
    }

    function _aiIndexFor(key) {
        const m = /:input-(\d+)$/.exec(String(key || ''));
        if (!m) return null;
        const idx = parseInt(m[1], 10) - 1;
        return (Number.isFinite(idx) && idx >= 0) ? idx : null;
    }

    function _aiOpen(req) {
        // Opening a MIDI source connects the corresponding Web MIDI input.
        // Programmatic open (audio-input source.open) — non-persisting: it must
        // not rewrite the user's saved pick or the shared global default.
        const idx = _aiIndexFor(req && (req.sourceId || req.logicalSourceKey));
        const inputs = _midiSources();   // carries .key (logicalSourceKey), unlike _midiListInputs()
        if (idx == null || idx >= inputs.length) {
            return { outcome: 'failed', reason: 'MIDI input source is no longer present' };
        }
        _midiConnect(inputs[idx].id, inputs[idx].name, inputs[idx].key);
        return { outcome: 'handled', payload: {} };
    }

    function _aiClose() {
        // Closing the session must actually stop note events flowing —
        // detach the input WITHOUT persisting an opt-out (the saved device
        // pick is user state, not session state, so a later open or a
        // device statechange can re-attach it).
        if (_midiInput || _midiHandle) {
            _midiDetach();
            // No note-off can ever arrive for keys that were down at close,
            // so release held/sustained state on every instance — otherwise
            // those keys stay visually depressed until a later reconnect or
            // teardown. (_midiNotifyDeviceListChanged only releases when the
            // device VANISHED from the source list, which a deliberate
            // close is not, so we release explicitly here.)
            for (const inst of _instances) {
                if (inst && typeof inst._releaseAllHeld === 'function') inst._releaseAllHeld();
            }
            _midiNotifyDeviceListChanged();
        }
        return { outcome: 'handled', payload: {} };
    }

    function _aiRefreshSources() {
        // MIDI is no longer surfaced into the audio-input domain — keys MIDI now
        // lives in the dedicated midi-input domain. Exporting pseudonymized
        // 'midi-input-N' sources here polluted audio-input device pickers (e.g.
        // the onboarding guitar input dropdown) with non-audio entries. Drop any
        // left over from an older build, and register none going forward.
        if (!_capsApi()) return;
        // Iterate a fixed bound over the KNOWN sourceId pattern, not a module-local
        // counter: after an in-page upgrade _aiRegisteredCount is reset to 0, so a
        // count-based loop would skip the prior build's leftovers entirely. The
        // sourceId/logicalSourceKey are the real (unpseudonymized) keys this plugin
        // registered, so unregister resolves them directly; an absent source is a
        // harmless no-op. 32 comfortably exceeds any realistic MIDI input count.
        const MAX_LEGACY_MIDI_SOURCES = 32;
        for (let n = 1; n <= MAX_LEGACY_MIDI_SOURCES; n++) {
            const key = ND_PROVIDER_ID + ':input-' + n;
            _capCommand('audio-input', 'unregister-source',
                { providerId: ND_PROVIDER_ID, sourceId: key, logicalSourceKey: key },
                'MIDI no longer exported to audio-input');
        }
        _aiRegisteredCount = 0;
    }

    /* ======================================================================
     *  Camera Director bridge resolver (pure — exported via createFactory.__test)
     * ====================================================================== */

    /**
     * The active splitscreen API, defensive on the global-name rename in flight
     * (feedBackSplitscreen is canonical; slopsmithSplitscreen is the legacy alias).
     * @returns {object|null} the splitscreen API, or null when not present
     */
    function _ssApi() { return window.feedBackSplitscreen || window.slopsmithSplitscreen || null; }

    /**
     * Resolve the Camera Director camera for a canvas: this panel's camera under
     * splitscreen, else the global, else null (Camera Director absent → 100% stock
     * framing). Throw-safe on panelIndexFor so a misbehaving splitscreen build
     * can't break framing.
     * @param {HTMLCanvasElement} canvas this renderer's highway canvas
     * @param {object|null} ss the splitscreen API (see _ssApi)
     * @param {object|null} panelsMap window.__h3dCamCtlPanels (per-panel cameras by index)
     * @param {object|null} globalCam window.__h3dCamCtl (single global camera)
     * @returns {object|null} the resolved free-camera bridge, or null
     */
    function _resolveFreeCam(canvas, ss, panelsMap, globalCam) {
        if (panelsMap && ss && typeof ss.panelIndexFor === 'function') {
            try {
                const i = ss.panelIndexFor(canvas);
                // Only a non-negative integer indexes the panel map — a non-int /
                // negative / string index (or a prototype key) must not resolve an
                // unintended/inherited property; fall through to the global then.
                if (Number.isInteger(i) && i >= 0 && panelsMap[i]) return panelsMap[i];
            } catch (e) { /* ignore */ }
        }
        return globalCam || null;
    }

    /* ======================================================================
     *  Renderer factory
     * ====================================================================== */

    function createFactory() {
        let highwayCanvas = null;
        let ren = null, scene = null, cam = null;
        let _camX = 0, _camTargetX = 0;  // camera x pan-follow state (updateScene)
        let _camZoom = 1, _camTargetZoom = 1;  // adaptive dolly-zoom to the note span
        let notesGroup = null, keyboardGroup = null, markersGroup = null, hitLine = null;
        let noteMeshes = [];   // [{mesh, note, len}]
        let markerSprites = []; // [{sprite, t}]
        let keyMeshes = new Map(); // midi → mesh
        let _isReady = false;
        let _notation = null;  // {notes, range, markers}
        let _loadSeq = 0;
        let _songHandler = null;
        // Per-chart geometry/material caches. One bevelled geometry per
        // (width, length-bucket) and one material per (pitch class, hand)
        // keeps per-note cost down to a mesh; rebuilt per chart, disposed on
        // rebuild + teardown.
        const _noteGeoCache = new Map();
        const _noteMatCache = new Map();
        const _barTexCache = new Map(); // measure idx → CanvasTexture
        let _glowTex = null;            // hit-line gradient (lazy, reused)
        const _hitGlowMats = [];        // additive glow materials pulsed in draw()

        // ── Visual FX state (guitar-highway parity) ─────────────────────
        let fx = readFxSettings();      // live snapshot, mutated by 'keys3d:settings'
        let _fxHandler = null;
        let _fxThemeHandler = null;
        // Host adaptive-quality scale (bundle.renderScale, 0.25–1) —
        // multiplied into the device pixel ratio like highway_3d does.
        let _renderScale = 1;
        // Auto-resize fallback state (PORTED FROM highway_3d — keep in sync).
        // The splitscreen host resizes the panel canvas but overrides
        // hw.resize and never calls our resize(w,h), so draw() self-detects
        // size drift. _lastHwW/H track the backing store last seen; _appliedW/H
        // track the logical size last handed to applySize(); the countdown
        // throttles the per-frame clientWidth/Height read.
        let _lastHwW = 0, _lastHwH = 0;
        let _appliedW = 0, _appliedH = 0;
        let _boxCheckCountdown = 0;
        // Bloom composer state (PORTED FROM highway_3d/screen.js — keep in sync).
        let _composer = null;
        let _bloomPass = null;
        let _bloomLoad = null;
        let _bloomW = 0, _bloomH = 0;
        let _bloomGen = 0;   // bumped by _bloomDispose so stale loads no-op
        // DOM HUD (combo / accuracy / best streak — drum_highway_3d pattern).
        let _hudEl = null;
        let _hudParentOrigPosition = null;

        // Hit-FX state. Sparks are PORTED FROM highway_3d (keep in sync);
        // pool 96 — keys hits also fire a flame sprite, so sparks are the
        // accent, not the whole show.
        const _SPARK_N = 96;
        let _sparkPts = null, _sparkPos = null, _sparkCol = null, _sparkVel = null, _sparkLife = null;
        let _fxLastWall = 0;       // wall clock for FX integration
        let _hitGlowKick = 0;      // hit-line brightness kick, decays exp(-t*6)
        const _laneGuideMats = []; // lane guide materials (vibrancy slider)

        // Theme/material handles (built by buildScene/buildKeyboardAndHighway;
        // _applyTheme / _applyCinematic / the glow slider retune them live).
        let _theme = readThemeSetting();
        let _palette = readPaletteSetting();
        let _sharpMode = readSharpModeSetting(); // 'floating' | 'flat' | 'realistic'
        let ambLight = null, dirLight = null;
        let _floorMat = null;
        const _railMats = [];      // lane-edge rail materials (theme laneDim)
        let _envRT = null;         // PMREM render target backing scene.environment
        let _bgTex = null;         // vertical-gradient background texture

        // Background ambience (PORTED FROM highway_3d BG_STYLES subset via
        // drum_highway_3d — keep in sync).
        let _bgStyle = readBgStyleSetting();
        let bgGroup = null;
        let _bgState = null;

        // Score-FX overlay (2D canvas sibling; drum_highway_3d pattern).
        let _fxCanvas = null, _fxCtx = null;
        let _fxDpr = 1;                    // backing-store scale (CSS→device px)
        let _fxParentOrigPosition = null;  // parent position to restore on teardown
        let _scorePops = Array.from({ length: 12 }, () => ({ active: false, x: 0, z: 0, bornMs: 0, text: '' }));
        const _SCORE_BURST_N = 36;
        let _scoreBursts = Array.from({ length: 2 }, () => ({
            active: false, bornMs: 0,
            px: new Float32Array(_SCORE_BURST_N), py: new Float32Array(_SCORE_BURST_N),
            vx: new Float32Array(_SCORE_BURST_N), vy: new Float32Array(_SCORE_BURST_N),
        }));
        let _scoreRingMs = -1e9;
        let _scoreBreakMs = -1e9;
        let _probe = null;

        // ── MIDI scoring + live feedback state ──────────────────────────
        let _layoutInfo = null;            // {layout, whiteCount} of current chart
        let _hits = 0, _misses = 0, _streak = 0, _bestStreak = 0;
        const _hitNoteKeys = new Set();    // noteKey(t, midi) of hit chart notes
        const _missedNoteKeys = new Set(); // …and swept-missed ones
        const _sweepCursor = { idx: 0 };   // monotonic miss-sweep position
        let _latestTime = 0;               // song time from the last draw bundle
        let _missFloor = null;             // no retroactive misses before this t
        const _rawToPlayed = new Map();    // raw midi → transposed midi (held)
        const _heldNotes = new Set();      // transposed midis currently down
        const _sustainedNotes = new Set(); // released while CC64 held
        let _sustainOn = false;
        const _keyAnim = new Map();        // midi → true while a key is animating
        const _keyFlash = new Map();       // midi → wall ms of wrong-note flash
        let _lastWallMs = 0;               // wall clock of the previous draw
        let _runMeta = null;               // {filename, arrangement} of this run
        let _recordedThisRun = false;      // /api/stats posted exactly once
        let _ndBindingId = null;           // open note-detection binding
        // Flame pool (built with the scene, reused for every hit).
        let _flamesGroup = null;
        const _flamePool = [];             // [{sprite, mat, start, baseY}]
        let _flameIdx = 0;
        const _flameTexCache = new Map();  // pitch class → CanvasTexture
        let _endHandler = null, _stopHandler = null;

        function _emitDomain(event, payload) {
            const caps = window.slopsmith && window.slopsmith.capabilities;
            if (caps && caps.version === 1 && typeof caps.emitEvent === 'function') {
                try { caps.emitEvent('visualization', event, payload || {}); } catch (_) {}
            }
        }

        function isBlackKey(midi) {
            return [1, 3, 6, 8, 10].includes(((midi % 12) + 12) % 12);
        }

        function _paletteColor(pc) {
            return (NOTE_PALETTES[_palette] || PITCH_CLASS_COLORS)[pc];
        }

        // Base color (24-bit int, no hand dimming) for a midi note under the
        // active palette — the octave scheme is procedural, every other
        // palette is a 12-entry pitch-class table.
        function _noteHex(midi) {
            if (_palette === 'octaves') return octaveNoteColor(midi);
            return _paletteColor(((midi % 12) + 12) % 12);
        }

        function noteColor(midi, hand) {
            const base = new T.Color(_noteHex(midi));
            const b = HAND_BRIGHTNESS[hand] != null ? HAND_BRIGHTNESS[hand] : 1.0;
            base.multiplyScalar(b);
            return base;
        }

        // X position of a key's centre. White keys advance one slot each;
        // black keys sit between, narrower and raised.
        function keyLayout(range) {
            const layout = new Map();
            let whiteIndex = 0;
            for (let midi = range.low; midi <= range.high; midi++) {
                if (isBlackKey(midi)) continue;
                layout.set(midi, { slot: whiteIndex, black: false });
                whiteIndex += 1;
            }
            for (let midi = range.low; midi <= range.high; midi++) {
                if (!isBlackKey(midi)) continue;
                const left = layout.get(midi - 1);
                layout.set(midi, { slot: left ? left.slot + 0.5 : 0, black: true });
            }
            return { layout, whiteCount: whiteIndex };
        }

        const WHITE_W = 12 * K, WHITE_L = 46 * K, WHITE_H = 5 * K;
        const BLACK_W = 6.4 * K, BLACK_L = 28 * K, BLACK_H = 6.5 * K;
        const HIGHWAY_LEN = 1150 * K; // longer runway → ~8.8s of lookahead visible
        // 'flat' piano-shaped-lane geometry (see laneSpanFlat). Zero-overlap tiling:
        // white lanes are trimmed by FLAT_SHARP_HALF where they meet a sharp and
        // the sharp fills the gap, so nothing overlaps. To keep the naturals
        // even, each sharp is nudged FLAT_SHARP_SHIFT toward the EDGE natural
        // beside it (C#→C, D#→E, F#→F, A#→B; G# stays centred, no edge to lean
        // on) — that steals a sliver from the edge natural and hands it to the
        // squeezed inner natural. At shift = sharpHalf/3 the C-D-E-F-B naturals
        // come out exactly equal; G/A land a hair smaller (G# can't lean). The
        // sharps ride the same flat plane (no lift — they never overlap a
        // natural). OCT_GAP is the extra divider opened at each octave boundary
        // when the octaveGaps option is on.
        const FLAT_SHARP_HALF = 2.2 * K;                 // sharp half-width (4.4K wide)
        const FLAT_SHARP_SHIFT = FLAT_SHARP_HALF / 3;    // sharp lean that evens the naturals
        const OCT_GAP = 0.9 * K;
        const LANE_DIMS_FLAT = {
            whiteW: WHITE_W, sharpHalf: FLAT_SHARP_HALF, shift: FLAT_SHARP_SHIFT, octGap: OCT_GAP,
        };
        // 'realistic' layout (laneSpanReal): every note bar is the size of the
        // physical key it lands on — naturals the full white-key width (always
        // rendered full, only occluded where a sharp note actually overlaps in
        // time) and sharps the full black-key width at their standard positions,
        // drawn on top with a hair of REAL_SHARP_LIFT (anti z-fight).
        const REAL_NAT_HALF = WHITE_W * 0.47;   // natural bar ≈ physical white key (~11.3K)
        const REAL_SHARP_HALF = BLACK_W / 2;    // sharp bar = physical black key (6.4K)
        const REAL_SHARP_LIFT = 0.3 * K;
        const LANE_DIMS_REAL = { natHalf: REAL_NAT_HALF, sharpHalf: REAL_SHARP_HALF, octGap: OCT_GAP };
        // Lane span for the active non-floating sharp mode. `range`
        // (activeLow/activeHigh) is optional and only consulted by the flat
        // layout, to gate the boundary-key edge trim (see laneSpanFlat).
        const _flatMode = () => _sharpMode === 'flat' || _sharpMode === 'realistic';
        function laneSpanFor(midi, black, cx, gaps, range) {
            return _sharpMode === 'realistic'
                ? laneSpanReal(midi, black, cx, LANE_DIMS_REAL, gaps)
                : laneSpanFlat(midi, black, cx, LANE_DIMS_FLAT, gaps, range);
        }

        // Camera — the default 'classic' preset is a low, near-telephoto rig
        // (RS+-style): a narrow FOV from low and back gives a deep receding
        // runway and frames ~2 octaves instead of cramming the whole note
        // range full-width. The x position pans to follow the active notes
        // (see updateScene), so wide pieces stay zoomed in on the played hand
        // rather than shrinking every key. The rig numbers now come from the
        // user-selectable CAM_PRESETS table; switching applies live because
        // position/lookAt are re-derived every frame.
        let _camPreset = CAM_PRESETS[readCameraSetting()] || CAM_PRESETS.classic;
        // Pan-follow: a slow ease toward a wide, gently-weighted centroid so the
        // camera glides with the melody instead of darting as notes enter/leave.
        const CAM_PAN_LERP = 0.022;      // per-frame ease (~1s glide @60fps)
        // Adaptive dolly-zoom: frame the FULL pitch span of the incoming notes and
        // centre on its MIDPOINT, so both the lowest (LH) and highest (RH) target
        // keys stay on the keyboard in view. Pull back for wide spreads (both
        // hands), push in for tight passages. Camera dollies along its view ray
        // (offset × zoom). BASE_KEYS is calibrated at the keyboard plane (the
        // binding constraint — it sits in a narrower cone than the far notes), so
        // framing the key span here keeps the target keys visible, not just the notes.
        const CAM_ZOOM_AHEAD = 3.5;      // seconds of notes considered for framing
        const CAM_ZOOM_PAD = 9 * WHITE_W;// total margin beyond the span (~4.5 keys/side)
        const CAM_ZOOM_BASE_KEYS = 11;   // white keys framed at zoom = 1
        const CAM_ZOOM_MIN = 0.9, CAM_ZOOM_MAX = 4.8;
        const CAM_ZOOM_LERP = 0.025;     // smooth zoom ease
        // Full-swing of the camTilt aim offset (pre-K units) at slider ±1.
        const CAM_TILT_UNITS = 55;

        // Base rig with the live fine-tune knobs applied — height/distance
        // multiply the preset, tilt offsets the aim height. Returns the
        // effective {y, z, lookY, lookZ} in pre-K units; the caller scales by
        // K and the auto-zoom. Keeps the pan/dolly follow-motion intact —
        // these only move the vantage point it orbits around. Writes into a
        // reusable object (returned live) so the per-frame camera update stays
        // allocation-free — the callers read it synchronously and never retain
        // it, so a single shared instance is safe.
        const _rigOut = { y: 0, z: 0, lookY: 0, lookZ: 0 };
        function _rig() {
            _rigOut.y = _camPreset.y * fx.camHeight;
            _rigOut.z = _camPreset.z * fx.camDist;
            _rigOut.lookY = _camPreset.lookY + fx.camTilt * CAM_TILT_UNITS;
            _rigOut.lookZ = _camPreset.lookZ;
            return _rigOut;
        }

        /**
         * Camera Director bridge for THIS panel — delegates to the pure, unit-
         * tested _resolveFreeCam / _ssApi (resolver block above the factory).
         * Reads the live globals: per-panel map __h3dCamCtlPanels → this panel's
         * camera, else the global __h3dCamCtl, else null (stock framing).
         * @param {HTMLCanvasElement} canvas this panel's highway canvas
         * @returns {object|null} the resolved free-camera bridge, or null
         */
        function _freeCamFor(canvas) {
            return _resolveFreeCam(canvas, _ssApi(), window.__h3dCamCtlPanels, window.__h3dCamCtl);
        }
        // Per-key approach glow: a key lights in its pitch-class color ONLY while a
        // note is heading for it, ramping up the closer that note gets to the hit-line.
        const KEY_GLOW_AHEAD = 2.0;      // seconds before the hit-line a key starts to light
        const KEY_GLOW_STRENGTH = 1.15;  // peak emissive intensity (note at the hit-line)

        function keyX(layoutEntry, whiteCount) {
            return (layoutEntry.slot - (whiteCount - 1) / 2) * WHITE_W;
        }

        /* -- background ambience (PORTED FROM highway_3d BG_STYLES subset
         *    via drum_highway_3d — keep formulas in sync) -- */
        const BG_STYLES = {
            off: {
                build() { return null; },
                update() {},
                teardown() {},
            },
            particles: {
                build(group, settings) {
                    const N = Math.max(20, Math.floor(80 + 200 * settings.intensity));
                    const positions = new Float32Array(N * 3);
                    for (let i = 0; i < N; i++) {
                        positions[i * 3] = (Math.random() - 0.5) * 800 * K;
                        positions[i * 3 + 1] = (Math.random() - 0.4) * 80 * K;
                        positions[i * 3 + 2] = -FOG_START - Math.random() * (FOG_END - FOG_START) * 0.85;
                    }
                    const geo = new T.BufferGeometry();
                    geo.setAttribute('position', new T.BufferAttribute(positions, 3));
                    const mat = new T.PointsMaterial({
                        color: 0xa0c0ff, size: 5 * K, transparent: true,
                        blending: T.AdditiveBlending, depthWrite: false, sizeAttenuation: true,
                    });
                    const points = new T.Points(geo, mat);
                    points.renderOrder = -1;
                    points.frustumCulled = false;
                    group.add(points);
                    return { points, geo, mat, N };
                },
                update(s, bands, dt) {
                    const positions = s.geo.attributes.position.array;
                    const dx = dt * (3 + bands.mid * 12) * K;
                    for (let i = 0; i < s.N; i++) {
                        positions[i * 3] += dx;
                        if (positions[i * 3] > 400 * K) positions[i * 3] -= 800 * K;
                    }
                    s.geo.attributes.position.needsUpdate = true;
                    s.mat.opacity = 0.55 + bands.treble * 0.45;
                },
                teardown(s) {
                    if (!s) return;
                    if (s.points.parent) s.points.parent.remove(s.points);
                    s.geo.dispose();
                    s.mat.dispose();
                },
            },
            lights: {
                build(group, settings) {
                    const N = Math.floor(6 + 8 * settings.intensity);
                    const lights = [];
                    const palette = settings.palette || [0xa0c0ff];
                    for (let i = 0; i < N; i++) {
                        const color = palette[i % palette.length];
                        const geo = new T.PlaneGeometry(30 * K, 30 * K);
                        const mat = new T.MeshBasicMaterial({
                            color, transparent: true,
                            blending: T.AdditiveBlending, depthWrite: false,
                        });
                        const mesh = new T.Mesh(geo, mat);
                        mesh.renderOrder = -1;
                        mesh.position.set(
                            (Math.random() - 0.5) * 600 * K,
                            (Math.random() - 0.3) * 80 * K,
                            -FOG_START - Math.random() * (FOG_END - FOG_START) * 0.85
                        );
                        group.add(mesh);
                        lights.push({ mesh, geo, mat, baseScale: 1 + Math.random() * 0.5, phase: Math.random() * Math.PI * 2 });
                    }
                    return { lights };
                },
                update(s, bands, dt, t) {
                    for (const L of s.lights) {
                        const pulse = 1 + bands.bass * 1.5 + Math.sin(t * 1.5 + L.phase) * 0.2;
                        L.mesh.scale.set(L.baseScale * pulse, L.baseScale * pulse, 1);
                        L.mat.opacity = 0.55 + bands.treble * 0.4;
                    }
                },
                teardown(s) {
                    if (!s) return;
                    for (const L of s.lights) {
                        if (L.mesh.parent) L.mesh.parent.remove(L.mesh);
                        L.geo.dispose();
                        L.mat.dispose();
                    }
                },
            },
            geometric: {
                build(group, settings) {
                    const meshes = [];
                    const op = 0.45 + 0.25 * settings.intensity;
                    const ico = new T.Mesh(
                        new T.IcosahedronGeometry(30 * K, 1),
                        new T.MeshBasicMaterial({ color: 0x6080c0, wireframe: true, transparent: true, opacity: op, depthWrite: false }),
                    );
                    ico.position.set(-100 * K, 30 * K, -FOG_END * 0.65);
                    ico.renderOrder = -1;
                    group.add(ico);
                    meshes.push(ico);
                    const torus = new T.Mesh(
                        new T.TorusGeometry(22 * K, 4 * K, 6, 12),
                        new T.MeshBasicMaterial({ color: 0xc06080, wireframe: true, transparent: true, opacity: op * 0.9, depthWrite: false }),
                    );
                    torus.position.set(120 * K, 20 * K, -FOG_END * 0.75);
                    torus.renderOrder = -1;
                    group.add(torus);
                    meshes.push(torus);
                    return { meshes };
                },
                update(s, bands, dt) {
                    const speed = 0.2 + bands.mid * 0.4;
                    const pulse = 1 + bands.bass * 0.25;
                    for (const m of s.meshes) {
                        m.rotation.x += dt * speed * 0.3;
                        m.rotation.y += dt * speed * 0.4;
                        m.scale.setScalar(pulse);
                    }
                },
                teardown(s) {
                    if (!s) return;
                    for (const m of s.meshes) {
                        if (m.parent) m.parent.remove(m);
                        m.geometry.dispose();
                        m.material.dispose();
                    }
                },
            },
        };

        function _bgMountStyle() {
            if (!scene || !T) return;
            if (!bgGroup) {
                bgGroup = new T.Group();
                scene.add(bgGroup);
            }
            if (_bgState && _bgState._style && BG_STYLES[_bgState._style]) {
                try { BG_STYLES[_bgState._style].teardown(_bgState.s); } catch (_) {}
            }
            const style = BG_STYLES[_bgStyle] || BG_STYLES.off;
            let s = null;
            try {
                s = style.build(bgGroup, { intensity: Math.min(1, Math.max(0, fx.bgIntensity)), palette: PITCH_CLASS_COLORS });
            } catch (e) {
                console.warn('[Keys-Hwy3D] bg style build failed', e);
                s = null;
            }
            _bgState = { _style: _bgStyle, s };
        }
        function _bgTeardownStyle() {
            if (_bgState && _bgState._style && BG_STYLES[_bgState._style]) {
                try { BG_STYLES[_bgState._style].teardown(_bgState.s); } catch (_) {}
            }
            _bgState = null;
            bgGroup = null;
        }

        /* -- score-FX overlay (drum_highway_3d pattern — keep in sync) -- */
        function _ensureFxCanvas() {
            if (_fxCanvas || !highwayCanvas) return;
            const parent = highwayCanvas.parentElement;
            if (!parent) return;
            const cur = parent.style.position || getComputedStyle(parent).position;
            if (cur === 'static' || !cur) {
                // Record the original inline value (usually '') so teardown
                // can undo this layout mutation — same contract as the HUD.
                _fxParentOrigPosition = parent.style.position;
                parent.style.position = 'relative';
            }
            _fxCanvas = document.createElement('canvas');
            _fxCanvas.className = 'keys-h3d-fx';
            _fxCanvas.style.cssText = 'position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none;z-index:5';
            parent.appendChild(_fxCanvas);
            _fxCtx = _fxCanvas.getContext('2d');
            _sizeFxCanvas();
        }
        function _sizeFxCanvas() {
            if (!_fxCanvas || !highwayCanvas) return;
            const w = highwayCanvas.clientWidth | 0, h = highwayCanvas.clientHeight | 0;
            if (!w || !h) return;
            const r = Math.min(window.devicePixelRatio || 1, 2);
            if (_fxCanvas.width !== (w * r | 0) || _fxCanvas.height !== (h * r | 0)) {
                _fxCanvas.width = w * r | 0;
                _fxCanvas.height = h * r | 0;
            }
            // The draw path works in CSS pixels and scales the context by
            // this — fixed font/line sizes stay physically consistent on
            // HiDPI instead of shrinking with the backing store.
            _fxDpr = r;
        }
        function _removeFxCanvas() {
            if (_fxCanvas && _fxCanvas.parentNode) {
                const parent = _fxCanvas.parentNode;
                parent.removeChild(_fxCanvas);
                // Restore position only if _ensureFxCanvas changed it (and
                // nothing else — e.g. the HUD — still needs it; the HUD sets
                // and restores its own copy of the same guard first).
                if (_fxParentOrigPosition !== null) {
                    parent.style.position = _fxParentOrigPosition;
                    _fxParentOrigPosition = null;
                }
            }
            _fxCanvas = null;
            _fxCtx = null;
        }
        function _scoreSpawnPop(midi) {
            if (!_layoutInfo) return;
            const entry = _layoutInfo.layout.get(midi);
            if (!entry) return;
            const nowMs = performance.now();
            for (const pop of _scorePops) {
                if (pop.active) continue;
                pop.active = true;
                pop.x = keyX(entry, _layoutInfo.whiteCount);
                pop.z = -WHITE_L / 2;
                pop.bornMs = nowMs;
                pop.text = '+1';
                return;
            }
        }
        function _scoreSpawnBurst(nowMs) {
            for (const b of _scoreBursts) {
                if (b.active) continue;
                b.active = true;
                b.bornMs = nowMs;
                for (let j = 0; j < _SCORE_BURST_N; j++) {
                    const a = (j / _SCORE_BURST_N) * Math.PI * 2;
                    const sp = 2 + (j % 5) * 0.8;
                    b.px[j] = 0; b.py[j] = 0;
                    b.vx[j] = Math.cos(a) * sp;
                    b.vy[j] = Math.sin(a) * sp - 1.2;
                }
                return;
            }
        }
        function _scoreOnHit(midi) {
            if (!fx.scoreFx) return;
            const nowMs = performance.now();
            _scoreSpawnPop(midi);
            if (_streak > 0 && _streak % 10 === 0) _scoreRingMs = nowMs;
            if (_streak === 25 || _streak === 50 || _streak === 100) _scoreSpawnBurst(nowMs);
        }
        function _scoreOnBreak(prevStreak) {
            if (!fx.scoreFx) return;
            if (prevStreak >= 3) _scoreBreakMs = performance.now();
        }
        function _drawScoreFx() {
            if (!_fxCtx || !_fxCanvas || !cam) return;
            // CSS-pixel coordinate space; the context transform applies the
            // DPR so strokes/fonts render at their intended physical size.
            const r = _fxDpr || 1;
            const W = _fxCanvas.width / r, H = _fxCanvas.height / r;
            _fxCtx.setTransform(r, 0, 0, r, 0, 0);
            const nowMs = performance.now();
            let anyPop = false;
            for (const pop of _scorePops) if (pop.active) { anyPop = true; break; }
            let anyBurst = false;
            for (const b of _scoreBursts) if (b.active) { anyBurst = true; break; }
            const ringAge = nowMs - _scoreRingMs;
            const breakAge = nowMs - _scoreBreakMs;
            if (!anyPop && !anyBurst && ringAge >= 600 && breakAge >= 350) {
                if (_fxCanvas._dirty) { _fxCtx.clearRect(0, 0, W, H); _fxCanvas._dirty = false; }
                return;
            }
            _fxCanvas._dirty = true;
            if (!_probe) _probe = new T.Vector3();
            const ctx = _fxCtx;
            ctx.clearRect(0, 0, W, H);
            ctx.save();
            if (breakAge < 350) {
                ctx.fillStyle = '#ef4444';
                ctx.globalAlpha = 0.10 * (1 - breakAge / 350);
                ctx.fillRect(0, 0, W, H);
                ctx.globalAlpha = 1;
            }
            let cx = W / 2, cy = H * 0.72, centerOk = false;
            _probe.set(0, WHITE_H + 0.5 * K, -WHITE_L / 2);
            _probe.project(cam);
            if (_probe.z >= -1 && _probe.z <= 1) {
                cx = (_probe.x * 0.5 + 0.5) * W;
                cy = (-_probe.y * 0.5 + 0.5) * H;
                centerOk = true;
            }
            if (centerOk && ringAge < 600) {
                const t = ringAge / 600;
                const ease = 1 - Math.pow(1 - t, 2);
                ctx.beginPath();
                ctx.arc(cx, cy, 20 + ease * Math.min(W, H) * 0.28, 0, Math.PI * 2);
                ctx.strokeStyle = _streak >= 30 ? '#fde047' : '#86efac';
                ctx.globalAlpha = 0.6 * (1 - t);
                ctx.lineWidth = 3;
                ctx.stroke();
                ctx.globalAlpha = 1;
            }
            if (anyBurst && centerOk) {
                for (const b of _scoreBursts) {
                    if (!b.active) continue;
                    const age = nowMs - b.bornMs;
                    if (age >= 900) { b.active = false; continue; }
                    const t = age / 900;
                    ctx.globalAlpha = 1 - t;
                    for (let j = 0; j < _SCORE_BURST_N; j++) {
                        b.px[j] += b.vx[j];
                        b.py[j] += b.vy[j];
                        b.vy[j] += 0.08;
                        ctx.fillStyle = (j & 1) ? '#fde047' : '#86efac';
                        ctx.fillRect(cx + b.px[j] - 2, cy + b.py[j] - 2, 4, 4);
                    }
                    ctx.globalAlpha = 1;
                }
            }
            if (anyPop) {
                ctx.textAlign = 'center';
                ctx.textBaseline = 'middle';
                for (const pop of _scorePops) {
                    if (!pop.active) continue;
                    const age = nowMs - pop.bornMs;
                    if (age >= 800) { pop.active = false; continue; }
                    _probe.set(pop.x, WHITE_H + 2 * K, pop.z);
                    _probe.project(cam);
                    if (_probe.z < -1 || _probe.z > 1) continue;
                    const t = age / 800;
                    const sx = (_probe.x * 0.5 + 0.5) * W;
                    const sy = (-_probe.y * 0.5 + 0.5) * H - t * 30;
                    ctx.globalAlpha = t < 0.4 ? 1 : 1 - (t - 0.4) / 0.6;
                    ctx.font = 'bold 15px system-ui, sans-serif';
                    ctx.lineWidth = 4;
                    ctx.strokeStyle = 'rgba(0,0,0,0.8)';
                    ctx.strokeText(pop.text, sx, sy);
                    ctx.fillStyle = '#86efac';
                    ctx.fillText(pop.text, sx, sy);
                }
                ctx.globalAlpha = 1;
            }
            ctx.restore();
        }

        // Emissive multiplier from the Glow slider — 0.5 is neutral (the
        // stock look), so `base * _glowMul()` leaves defaults byte-identical.
        function _glowMul() {
            return Math.min(1, Math.max(0, fx.glow)) * 2;
        }

        // Cinematic lighting: dimmer ambient + stronger key light. Off = the
        // plugin's pre-parity values.
        function _applyCinematic() {
            if (!ambLight || !dirLight) return;
            ambLight.intensity = fx.cinematic ? 0.55 : 0.75;
            dirLight.intensity = fx.cinematic ? 1.3 : 1.1;
        }

        // Procedural "studio" environment for image-based lighting — the
        // anti-plastic core: the clearcoat note gems and the piano-black
        // keys need something to reflect. PORTED FROM drum_highway_3d
        // _makeStudioEnv — keep in sync. Returns the PMREM RT or null.
        function _makeStudioEnv(ThreeLib, renderer) {
            if (!renderer) return null;
            try {
                const envScene = new ThreeLib.Scene();
                const own = [];
                const shellGeo = new ThreeLib.BoxGeometry(100, 100, 100);
                const shellMat = new ThreeLib.MeshBasicMaterial({ color: 0x11131c, side: ThreeLib.BackSide });
                envScene.add(new ThreeLib.Mesh(shellGeo, shellMat));
                own.push(shellGeo, shellMat);
                const strip = (w, h, hex, intensity, x, y, z, rx, ry) => {
                    const g = new ThreeLib.PlaneGeometry(w, h);
                    // DoubleSide: orientation-proof — the bake only runs once,
                    // so the extra faces are free insurance.
                    const m = new ThreeLib.MeshBasicMaterial({ color: hex, side: ThreeLib.DoubleSide });
                    m.color.multiplyScalar(intensity);
                    const mesh = new ThreeLib.Mesh(g, m);
                    mesh.position.set(x, y, z);
                    mesh.rotation.set(rx, ry, 0);
                    envScene.add(mesh);
                    own.push(g, m);
                };
                // Deliberate delta from drum_highway_3d: the strips run at
                // a third of the drum scene's intensity — this scene is
                // dominated by a WHITE keyboard and a sheened floor, and the
                // drum-strength strips overexpose it into a milky bloom
                // flood (screenshot-verified). The blacks' glints survive.
                strip(60, 8, 0xdfe8ff, 6, 0, 45, 0, Math.PI / 2, 0);       // overhead key
                strip(30, 50, 0xffd9a8, 2.5, -48, 5, 0, 0, Math.PI / 2);   // warm left wall
                strip(30, 50, 0x9fd8ff, 2.5, 48, 5, 0, 0, -Math.PI / 2);   // cool right wall
                const pmrem = new ThreeLib.PMREMGenerator(renderer);
                const rt = pmrem.fromScene(envScene, 0.04);
                pmrem.dispose();
                for (const r of own) r.dispose();
                return rt;
            } catch (e) {
                console.warn('[Keys-Hwy3D] studio env failed (continuing without IBL)', e);
                return null;
            }
        }

        function _disposeEnv() {
            if (_envRT) { try { _envRT.dispose(); } catch (_) {} _envRT = null; }
            if (_bgTex) { try { _bgTex.dispose(); } catch (_) {} _bgTex = null; }
        }

        // Vertical-gradient background: lighter above the horizon, the theme
        // clear at the midline, darker toward the keyboard — depth the flat
        // clear color never had. sRGB-tagged so the composer's output
        // transform reads it correctly.
        function _makeBgTexture(clearHex) {
            const cnv = document.createElement('canvas');
            cnv.width = 2; cnv.height = 256;
            const ctx = cnv.getContext('2d');
            const c = new T.Color(clearHex);
            const top = c.clone().lerp(new T.Color(0xffffff), 0.10);
            const bottom = c.clone().multiplyScalar(0.55);
            const grad = ctx.createLinearGradient(0, 0, 0, 256);
            grad.addColorStop(0, '#' + top.getHexString());
            grad.addColorStop(0.55, '#' + c.getHexString());
            grad.addColorStop(1, '#' + bottom.getHexString());
            ctx.fillStyle = grad;
            ctx.fillRect(0, 0, 2, 256);
            const tex = new T.CanvasTexture(cnv);
            tex.colorSpace = T.SRGBColorSpace;
            return tex;
        }

        // Apply the active theme to the live scene. Pitch-class note/key
        // colors are identity — themes own the scene, not the notes.
        function _applyTheme() {
            const c = _bgThemeColors(_theme);
            if (scene) {
                if (_bgTex) { try { _bgTex.dispose(); } catch (_) {} }
                _bgTex = _makeBgTexture(c.clear);
                scene.background = _bgTex;
                if (scene.fog) scene.fog.color.setHex(c.fog);
            }
            if (ren) ren.setClearColor(c.clear, 1);
            if (_floorMat) _floorMat.color.setHex(c.board);
            for (const m of _railMats) m.color.setHex(c.laneDim != null ? c.laneDim : 0x2a2a3e);
        }

        function buildScene() {
            scene = new T.Scene();
            // Theme-driven gradient background (an explicit scene.background
            // is also what keeps the composer's ACES output from washing out
            // a bare clear color) + matching fog.
            const themeCols = _bgThemeColors(_theme);
            _bgTex = _makeBgTexture(themeCols.clear);
            scene.background = _bgTex;
            scene.fog = new T.Fog(themeCols.fog, FOG_START, FOG_END);
            if (ren) ren.setClearColor(themeCols.clear, 1);

            // Studio environment map — image-based lighting for the
            // clearcoat gems / piano-black keys. Renderer-bound; disposed in
            // teardown via _disposeEnv.
            _envRT = _makeStudioEnv(T, ren);
            if (_envRT) scene.environment = _envRT.texture;

            cam = new T.PerspectiveCamera(_camPreset.fov, 1, 0.1, 2000 * K);
            _camX = 0; _camTargetX = 0; _camZoom = 1; _camTargetZoom = 1;
            { const r = _rig(); cam.position.set(0, r.y * K, r.z * K); cam.lookAt(0, r.lookY * K, r.lookZ * K); }

            ambLight = new T.AmbientLight(0xffffff, 0.75);
            dirLight = new T.DirectionalLight(0xffffff, 1.1);
            dirLight.position.set(60 * K, 200 * K, 80 * K);
            scene.add(ambLight, dirLight);
            _applyCinematic();

            keyboardGroup = new T.Group();
            notesGroup = new T.Group();
            markersGroup = new T.Group();
            _flamesGroup = new T.Group();
            scene.add(keyboardGroup, notesGroup, markersGroup, _flamesGroup);
            _buildFlamePool();

            // Hit sparks (PORTED FROM highway_3d): pooled additive Points
            // cloud, burst at the struck key, integrated in draw(). Same
            // coordinate space as the flames (keyX / hit-line z).
            _sparkPos = new Float32Array(_SPARK_N * 3); _sparkCol = new Float32Array(_SPARK_N * 3);
            _sparkVel = new Float32Array(_SPARK_N * 3); _sparkLife = new Float32Array(_SPARK_N);
            {
                const sg = new T.BufferGeometry();
                sg.setAttribute('position', new T.BufferAttribute(_sparkPos, 3).setUsage(T.DynamicDrawUsage));
                sg.setAttribute('color', new T.BufferAttribute(_sparkCol, 3).setUsage(T.DynamicDrawUsage));
                const sm = new T.PointsMaterial({ size: 1.0 * K, vertexColors: true, transparent: true, opacity: 0.8, depthWrite: false, blending: T.AdditiveBlending, sizeAttenuation: true });
                _sparkPts = new T.Points(sg, sm); _sparkPts.frustumCulled = false; _sparkPts.renderOrder = 8;
                scene.add(_sparkPts);
            }

            // Background ambience behind everything (renderOrder -1).
            _bgMountStyle();
        }

        // Drop every child of a group (recursively — key glyphs are children
        // of their key mesh so depress animation carries them), disposing
        // geometry + material. Cached resources (note geometries/materials,
        // bar/glyph textures) may be disposed again via their caches —
        // three.js dispose() is safe to call more than once. Texture maps
        // held by caches are NOT disposed here.
        function _disposeDeep(obj) {
            while (obj.children && obj.children.length) {
                _disposeDeep(obj.children.pop());
            }
            if (obj.geometry) obj.geometry.dispose();
            if (obj.material && obj.material.dispose) obj.material.dispose();
        }
        function _clearGroup(group) {
            if (!group) return;
            while (group.children.length) {
                _disposeDeep(group.children.pop());
            }
        }

        function _clearNoteCaches() {
            for (const g of _noteGeoCache.values()) g.dispose();
            _noteGeoCache.clear();
            for (const m of _noteMatCache.values()) m.dispose();
            _noteMatCache.clear();
        }

        function _clearBarTextures() {
            for (const t of _barTexCache.values()) t.dispose();
            _barTexCache.clear();
        }

        // Bevelled "gem" geometry for a note block: a rectangular cross-
        // section (w × NOTE_H) extruded along Z for the note's length with a
        // small bevel, so edges catch the light. Cached per (width,
        // length-bucket) — lengths quantised to 0.01 world units so charts
        // share geometries instead of allocating one per note.
        // Rounded-rectangle shape (centred) — reused for gem cross-sections and
        // rounded key footprints.
        function _roundedRectShape(w, h, r) {
            r = Math.max(0, Math.min(r, w / 2 - 0.001, h / 2 - 0.001));
            const hw = w / 2, hh = h / 2, s = new T.Shape();
            s.moveTo(-hw + r, -hh);
            s.lineTo(hw - r, -hh);
            s.quadraticCurveTo(hw, -hh, hw, -hh + r);
            s.lineTo(hw, hh - r);
            s.quadraticCurveTo(hw, hh, hw - r, hh);
            s.lineTo(-hw + r, hh);
            s.quadraticCurveTo(-hw, hh, -hw, hh - r);
            s.lineTo(-hw, -hh + r);
            s.quadraticCurveTo(-hw, -hh, -hw + r, -hh);
            return s;
        }

        // Rounded box (w×h×d, centred) — rounded-rect footprint extruded along
        // height with a bevel, so vertical corners are rounded and the top/bottom
        // edges softened. Core-three only (no RoundedBoxGeometry addon). Used for
        // the piano keys.
        function _roundedBoxGeo(w, h, d, r, bev) {
            bev = Math.max(0.001, Math.min(bev, h / 2 - 0.001));
            const g = new T.ExtrudeGeometry(_roundedRectShape(w, d, r), {
                depth: Math.max(h - 2 * bev, 0.001),
                bevelEnabled: true, bevelThickness: bev, bevelSize: bev,
                bevelSegments: 3, curveSegments: 5,
            });
            g.computeBoundingBox();
            const bz = g.boundingBox;
            g.translate(0, 0, -(bz.min.z + bz.max.z) / 2); // centre the height axis
            g.rotateX(-Math.PI / 2);                       // stand up: height → +Y
            return g;
        }

        function _noteGeometry(w, len) {
            const key = w.toFixed(4) + '|' + len.toFixed(2);
            let geo = _noteGeoCache.get(key);
            if (geo) return geo;
            const bevel = Math.min(NOTE_BEVEL, w * 0.25, len * 0.25, NOTE_H * 0.35);
            const hw = w / 2 - bevel;
            const hh = NOTE_H / 2 - bevel;
            // Lightly rounded cross-section — soften the long edges without the
            // pill/plastic look.
            const shape = _roundedRectShape(2 * hw, 2 * hh, Math.min(hw, hh) * 0.3);
            const depth = Math.max(len - 2 * bevel, bevel);
            geo = new T.ExtrudeGeometry(shape, {
                depth,
                bevelEnabled: true,
                bevelThickness: bevel,
                bevelSize: bevel,
                bevelSegments: 3,
                curveSegments: 5,
            });
            // Extrusion spans z ∈ [-bevel, depth + bevel]; centre it.
            geo.translate(0, 0, -depth / 2);
            // Bake a vertical brightness ramp into vertex colors (bottom shade →
            // top highlight) so the gem reads 3D; the material multiplies its
            // pitch-class color by this (vertexColors).
            geo.computeBoundingBox();
            const y0 = geo.boundingBox.min.y, yr = (geo.boundingBox.max.y - y0) || 1;
            const pos = geo.attributes.position;
            const cols = new Float32Array(pos.count * 3);
            for (let i = 0; i < pos.count; i++) {
                const t = (pos.getY(i) - y0) / yr;            // 0 bottom .. 1 top
                const v = GEM_SHADE_BOT + (GEM_SHADE_TOP - GEM_SHADE_BOT) * (t * t * (3 - 2 * t));
                cols[i * 3] = cols[i * 3 + 1] = cols[i * 3 + 2] = v;
            }
            geo.setAttribute('color', new T.BufferAttribute(cols, 3));
            _noteGeoCache.set(key, geo);
            return geo;
        }

        // Glossy note material, cached per resolved color. Keying by the
        // final color int (hand brightness already baked in by noteColor)
        // works for every palette — including 'octaves', where two notes of
        // the same pitch class in different octaves are DIFFERENT colors and
        // must not share a material (a pitch-class key would collide them).
        function _noteMaterial(midi, hand) {
            const col = noteColor(midi, hand);
            const key = col.getHex();
            let mat = _noteMatCache.get(key);
            if (mat) return mat;
            // MeshPhysicalMaterial with a clearcoat: lacquered glass-gem
            // look — a sharp coat highlight over a colored body, lit by the
            // studio env map. This is the "not plastic" ask: the old matte
            // MeshStandard (roughness 0.78, no envMap) had a dead surface.
            // (~20-30% more fragment cost than MeshStandard on note pixels;
            // acceptable — notes cover a modest screen fraction and all
            // share one shader program.)
            mat = new T.MeshPhysicalMaterial({
                color: col,
                vertexColors: true,         // multiply color by the baked gem ramp
                emissive: col,
                emissiveIntensity: NOTE_EMISSIVE_BASE * _glowMul(),
                roughness: 0.32,
                metalness: 0.0,
                clearcoat: 1.0,
                clearcoatRoughness: 0.18,
                envMapIntensity: 0.9,
                transparent: true,
                // Vibrancy-driven: 0.72 (airy, keys clearly visible through
                // the gems) → 0.94 (solid, saturated). The old fixed 0.8
                // read washed-out against the dim floor.
                opacity: _noteOpacity(),
            });
            _noteMatCache.set(key, mat);
            return mat;
        }

        function _noteOpacity() {
            return 0.72 + 0.22 * Math.min(1, Math.max(0, fx.vibrancy));
        }
        function _laneGuideOpacity() {
            // Vibrancy sets the ceiling (much brighter than the old subtle
            // 0.10–0.22 range); the laneOpacity slider then scales 0 → ceiling.
            const vib = 0.32 + 0.52 * Math.min(1, Math.max(0, fx.vibrancy)); // ~0.32..0.84
            return vib * Math.min(1, Math.max(0, fx.laneOpacity));
        }

        // Live vibrancy slider: retint everything already built — the
        // per-note clones, the material cache (future clones), and the lane
        // guides — without a chart rebuild.
        function _applyVibrancy() {
            const op = _noteOpacity();
            for (const m of _noteMatCache.values()) m.opacity = op;
            for (const nm of noteMeshes) {
                if (nm.mesh && nm.mesh.material) nm.mesh.material.opacity = op;
            }
            const lop = _laneGuideOpacity();
            for (const m of _laneGuideMats) m.opacity = lop;
        }

        // Live palette switch: recolor everything already built — cached
        // note materials (future clones), per-note clones, key emissives
        // (incl. the wrong-flash restore state), lane guides — and drop the
        // pitch-class flame textures so the next spawn bakes the new hues.
        // Same no-rebuild approach as _applyVibrancy.
        function _applyPalette() {
            // The base-material cache is keyed by resolved color, so old
            // entries are simply stale under a new palette — drop them and let
            // the next build re-cache. The live per-note clones below are
            // retinted directly from each note's midi (palette-correct).
            for (const m of _noteMatCache.values()) m.dispose();
            _noteMatCache.clear();
            for (const nm of noteMeshes) {
                if (!nm.mesh || !nm.mesh.material) continue;
                const col = noteColor(nm.note.midi, nm.note.hand);
                nm.mesh.material.color.copy(col);
                nm.mesh.material.emissive.copy(col);
            }
            for (const [midi, km] of keyMeshes) {
                const col = noteColor(midi, 'rh');
                km.material.emissive.copy(col);
                km.userData.origEmissive = col.getHex();
            }
            for (const m of _laneGuideMats) {
                if (m.userData.midi != null) m.color.copy(noteColor(m.userData.midi, 'rh'));
            }
            _clearFlameTextures();
            // Re-arm the pool so no slot keeps rendering a disposed texture
            // (a flame mid-flight briefly re-tints — next spawn sets its
            // true pitch texture).
            for (const s of _flamePool) s.mat.map = _flameTexture(0);
        }

        function _barNumberTexture(idx) {
            let tex = _barTexCache.get(idx);
            if (tex) return tex;
            const cnv = document.createElement('canvas');
            cnv.width = 128;
            cnv.height = 64;
            const ctx = cnv.getContext('2d');
            ctx.clearRect(0, 0, 128, 64);
            ctx.font = '600 38px system-ui, sans-serif';
            ctx.textAlign = 'center';
            ctx.textBaseline = 'middle';
            ctx.fillStyle = 'rgba(232,232,242,0.88)';
            ctx.fillText(String(idx), 64, 34);
            tex = new T.CanvasTexture(cnv);
            _barTexCache.set(idx, tex);
            return tex;
        }

        // Vertical gradient for the hit-line glow: white-hot core fading to
        // transparent cyan at the edges. Lazy, reused for both glow planes.
        function _glowTexture() {
            if (_glowTex) return _glowTex;
            const cnv = document.createElement('canvas');
            cnv.width = 16;
            cnv.height = 64;
            const ctx = cnv.getContext('2d');
            const grad = ctx.createLinearGradient(0, 0, 0, 64);
            grad.addColorStop(0.0, 'rgba(64,255,208,0)');
            grad.addColorStop(0.5, 'rgba(224,255,248,1)');
            grad.addColorStop(1.0, 'rgba(64,255,208,0)');
            ctx.fillStyle = grad;
            ctx.fillRect(0, 0, 16, 64);
            _glowTex = new T.CanvasTexture(cnv);
            return _glowTex;
        }

        // Vertical flame texture for hit flares / held-key halos: white-hot
        // base fading up into the note's color, with a horizontal falloff.
        // Cached per resolved color (bounded — 12 for pitch-class palettes,
        // up to ~one-per-octave for 'octaves'), so a flare always matches the
        // struck note's color whatever the palette.
        function _flameTexture(midi) {
            const c = _noteHex(midi);
            let tex = _flameTexCache.get(c);
            if (tex) return tex;
            const r = (c >> 16) & 0xff, g = (c >> 8) & 0xff, b = c & 0xff;
            const cnv = document.createElement('canvas');
            cnv.width = 64;
            cnv.height = 128;
            const ctx = cnv.getContext('2d');
            const grad = ctx.createLinearGradient(0, 128, 0, 0); // bottom → top
            grad.addColorStop(0.0, 'rgba(255,255,255,1)');       // white-hot base
            grad.addColorStop(0.3, `rgba(${r},${g},${b},0.95)`);
            grad.addColorStop(1.0, `rgba(${r},${g},${b},0)`);
            ctx.fillStyle = grad;
            ctx.fillRect(0, 0, 64, 128);
            const falloff = ctx.createLinearGradient(0, 0, 64, 0);
            falloff.addColorStop(0.0, 'rgba(0,0,0,0)');
            falloff.addColorStop(0.5, 'rgba(0,0,0,1)');
            falloff.addColorStop(1.0, 'rgba(0,0,0,0)');
            ctx.globalCompositeOperation = 'destination-in';
            ctx.fillStyle = falloff;
            ctx.fillRect(0, 0, 64, 128);
            tex = new T.CanvasTexture(cnv);
            _flameTexCache.set(c, tex);
            return tex;
        }

        function _clearFlameTextures() {
            for (const t of _flameTexCache.values()) t.dispose();
            _flameTexCache.clear();
        }

        // Pooled flame sprites — allocated once with the scene; spawning a
        // flame only mutates a slot.
        function _buildFlamePool() {
            _flamePool.length = 0;
            _flameIdx = 0;
            for (let i = 0; i < FLAME_POOL_SIZE; i++) {
                const mat = new T.SpriteMaterial({
                    map: _flameTexture(0),
                    transparent: true,
                    depthWrite: false,
                    blending: T.AdditiveBlending,
                });
                const sprite = new T.Sprite(mat);
                sprite.visible = false;
                _flamesGroup.add(sprite);
                _flamePool.push({ sprite, mat, start: -1, baseY: 0 });
            }
        }

        // Hit sparks (PORTED FROM highway_3d _sparkBurst/_sparkUpdate — keep
        // in sync): pooled additive Points; a timing-colored burst fires at
        // the struck key alongside the flame sprite.
        function _sparkBurst(x, y, z, hex, count) {
            if (!_sparkPts || count <= 0) return;
            const r = ((hex >> 16) & 255) / 255, g = ((hex >> 8) & 255) / 255, b = (hex & 255) / 255;
            let made = 0;
            for (let i = 0; i < _SPARK_N && made < count; i++) {
                if (_sparkLife[i] > 0) continue;
                const j = i * 3, ang = Math.random() * Math.PI * 2, sp = (5 + Math.random() * 12) * K;
                _sparkPos[j] = x; _sparkPos[j + 1] = y; _sparkPos[j + 2] = z;
                _sparkVel[j] = Math.cos(ang) * sp; _sparkVel[j + 1] = (12 + Math.random() * 24) * K; _sparkVel[j + 2] = Math.sin(ang) * sp * 0.55;
                _sparkCol[j] = r; _sparkCol[j + 1] = g; _sparkCol[j + 2] = b;
                _sparkLife[i] = 0.30 + Math.random() * 0.16; made++;
            }
        }
        function _sparkUpdate(dt) {
            if (!_sparkPts) return;
            const grav = 55 * K; let any = false;
            for (let i = 0; i < _SPARK_N; i++) {
                if (_sparkLife[i] <= 0) continue;
                const j = i * 3;
                _sparkLife[i] -= dt;
                if (_sparkLife[i] <= 0) { _sparkCol[j] = _sparkCol[j + 1] = _sparkCol[j + 2] = 0; continue; }
                any = true;
                _sparkVel[j + 1] -= grav * dt;
                _sparkPos[j] += _sparkVel[j] * dt; _sparkPos[j + 1] += _sparkVel[j + 1] * dt; _sparkPos[j + 2] += _sparkVel[j + 2] * dt;
                const fade = 1 - Math.min(1, dt * 3.2);
                _sparkCol[j] *= fade; _sparkCol[j + 1] *= fade; _sparkCol[j + 2] *= fade;
            }
            _sparkPts.geometry.attributes.position.needsUpdate = true;
            _sparkPts.geometry.attributes.color.needsUpdate = true;
            _sparkPts.visible = any;
        }

        // Timing → color (PORTED FROM highway_3d _timingHex — keep in sync).
        function _timingHex(ts) {
            if (!fx.timingFx || !ts || ts === 'OK') return 0x22ff88;
            if (ts === 'EARLY') return 0x35d6ff;
            if (ts === 'LATE')  return 0xffb84d;
            return 0x22ff88;
        }

        function _spawnSparks(midi, ts) {
            if (!fx.sparks || !_sparkPts || !_layoutInfo) return;
            const entry = _layoutInfo.layout.get(midi);
            if (!entry) return;
            let count = 8;
            if (fx.streakFx) count += Math.round(8 * Math.min(1, _streak / 16));
            const y = (entry.black ? BLACK_H + WHITE_H * 0.6 : WHITE_H) + 1 * K;
            _sparkBurst(keyX(entry, _layoutInfo.whiteCount), y, -WHITE_L / 2, _timingHex(ts), count);
        }

        function _spawnFlame(midi, wallNow) {
            if (!_layoutInfo || !_flamePool.length) return;
            const entry = _layoutInfo.layout.get(midi);
            if (!entry) return;
            const slot = _flamePool[_flameIdx];
            _flameIdx = (_flameIdx + 1) % _flamePool.length;
            slot.mat.map = _flameTexture(midi);
            slot.start = wallNow;
            slot.baseY = entry.black ? BLACK_H + WHITE_H * 0.6 : WHITE_H;
            slot.sprite.position.x = keyX(entry, _layoutInfo.whiteCount);
            slot.sprite.position.z = -WHITE_L / 2; // at the hit-line
            slot.sprite.visible = true;
        }

        function buildKeyboardAndHighway() {
            _clearGroup(keyboardGroup);
            _hitGlowMats.length = 0;
            _laneGuideMats.length = 0;
            _railMats.length = 0;
            keyMeshes = new Map();
            _keyAnim.clear();
            _keyFlash.clear();
            const range = _notation.range;
            const { layout, whiteCount } = keyLayout(range);
            _layoutInfo = { layout, whiteCount };
            const totalW = whiteCount * WHITE_W;
            const floorW = totalW + 8 * WHITE_W;
            const hitZ = -WHITE_L / 2;

            // Highway floor receding to the vanishing point.
            _floorMat = new T.MeshStandardMaterial({
                color: _bgThemeColors(_theme).board,
                // Stage-floor sheen (was matte 0.9): the env strips give the
                // deck a soft reflection lane without mirroring the notes.
                roughness: 0.55,
                metalness: 0.15,
                envMapIntensity: 0.25,
            });
            const floor = new T.Mesh(
                new T.PlaneGeometry(floorW, HIGHWAY_LEN),
                _floorMat,
            );
            floor.rotation.x = -Math.PI / 2;
            floor.position.set(0, -0.5 * K, -HIGHWAY_LEN / 2 + WHITE_L);
            keyboardGroup.add(floor);

            // Active-range pop: dim the scroll lanes outside the active span
            // with subtle dark overlays so the playable range reads bright.
            let activeMinX = Infinity, activeMaxX = -Infinity;
            for (const [midi, entry] of layout) {
                if (midi < range.activeLow || midi > range.activeHigh) continue;
                const w = entry.black ? BLACK_W : WHITE_W;
                const x = keyX(entry, whiteCount);
                if (x - w / 2 < activeMinX) activeMinX = x - w / 2;
                if (x + w / 2 > activeMaxX) activeMaxX = x + w / 2;
            }
            if (activeMinX < activeMaxX) {
                const laneLen = HIGHWAY_LEN - WHITE_L;
                const dimMat = new T.MeshBasicMaterial({
                    color: 0x000000, transparent: true, opacity: 0.42, depthWrite: false,
                });
                const zones = [
                    [-floorW / 2, activeMinX],
                    [activeMaxX, floorW / 2],
                ];
                for (const [x0, x1] of zones) {
                    const zoneW = x1 - x0;
                    if (zoneW <= 0.01 * K) continue;
                    const dim = new T.Mesh(new T.PlaneGeometry(zoneW, laneLen), dimMat);
                    dim.rotation.x = -Math.PI / 2;
                    dim.position.set((x0 + x1) / 2, 0.1 * K, hitZ - laneLen / 2);
                    keyboardGroup.add(dim);
                }
            }

            // Lane guides: a faint color strip running up the runway from each
            // active key, in that key's pitch-class color. A falling note shares
            // its target key's color, so the player can trace it straight down
            // its lane to the right key even when it sits near the frame edge.
            //
            // The lanes sit at the NOTES' travel height (coplanar), not on the
            // deck. The camera looks down the y–z plane, so a note elevated above
            // an on-floor lane projects to a different screen column (parallax) —
            // putting the lane at the note's y makes the note ride exactly in its
            // lane, perfectly aligned with the lane and its key.
            const guideLen = HIGHWAY_LEN - WHITE_L;
            const laneY = WHITE_H + NOTE_H / 2 + 0.5 * K; // == white-note travel height
            const gaps = fx.octaveGaps;
            const floating = _sharpMode === 'floating';
            const t = Math.min(1, Math.max(0, fx.laneOpacity));       // lane-color opacity
            const octC = Math.min(1, Math.max(0, fx.octaveContrast)); // 0..1 line-contrast
            const themeLaneDim = (() => { const c = _bgThemeColors(_theme); return c.laneDim != null ? c.laneDim : 0x2a2a3e; })();
            // A vertical guide line running the full runway at world x (skips
            // near-transparent lines so the crossfade never builds dead meshes).
            const addLine = (x, color, opacity, wpx, trackTheme) => {
                if (opacity < 0.02) return;
                const m = new T.MeshBasicMaterial({ color, transparent: true, opacity, depthWrite: false });
                if (trackTheme) _railMats.push(m); // theme retint tracks these; fixed guides stay put
                const line = new T.Mesh(new T.PlaneGeometry(wpx, guideLen), m);
                line.rotation.x = -Math.PI / 2;
                line.position.set(x, laneY + 0.06 * K, hitZ - guideLen / 2);
                keyboardGroup.add(line);
            };
            for (const [midi, entry] of layout) {
                if (midi < range.activeLow || midi > range.activeHigh) continue;
                // Floating: white-only lanes (blacks float, lane-less). Flat/
                // realistic: every key gets a piano-shaped lane.
                if (entry.black && floating) continue;
                // Lane footprint per mode.
                let left, right, stripY = laneY;
                if (floating) {
                    const cx = keyX(entry, whiteCount);
                    left = cx - WHITE_W / 2; right = cx + WHITE_W / 2;
                    if (gaps) {
                        const pc = ((midi % 12) + 12) % 12;
                        if (pc === 11) right -= OCT_GAP / 2; // B → C boundary
                        if (pc === 0) left += OCT_GAP / 2;
                    }
                } else {
                    const span = laneSpanFor(midi, entry.black, keyX(entry, whiteCount), gaps, range);
                    left = span.left; right = span.right;
                    if (_sharpMode === 'realistic' && entry.black) stripY = laneY + REAL_SHARP_LIFT;
                }
                const center = (left + right) / 2;
                // Colored lane strip + a subtle per-lane separator — fade in with
                // lane opacity. (As lanes fade, the block/octave lines below take
                // over as the guide.)
                if (t > 0.02) {
                    // Floating keeps the historical 0.84-wide white strip; the
                    // piano-shaped lanes inset a touch for a dark separator.
                    const stripW = floating ? (right - left) - WHITE_W * 0.16 : (right - left) * 0.9;
                    const gmat = new T.MeshBasicMaterial({
                        color: noteColor(midi, 'rh'), transparent: true,
                        opacity: _laneGuideOpacity(), depthWrite: false, // includes lane opacity
                    });
                    gmat.userData.midi = midi; // palette retint needs the lane's pitch
                    _laneGuideMats.push(gmat);
                    const strip = new T.Mesh(new T.PlaneGeometry(stripW, guideLen), gmat);
                    strip.rotation.x = -Math.PI / 2;
                    strip.position.set(center, stripY, hitZ - guideLen / 2);
                    keyboardGroup.add(strip);
                    // Per-lane separator, fading with the strips. Skip realistic
                    // sharps (they overlap the white columns).
                    if (!(entry.black && _sharpMode === 'realistic')) {
                        addLine(left, themeLaneDim, 0.5 * t, 0.6 * K, true);
                    }
                }
            }
            // Structural divider lines: ONE per "block" boundary — E→F and B→C —
            // so each block of keys (C-D-E, F-G-A-B) is bounded, not every lane.
            // They crossfade IN as the lanes fade OUT. The B→C octave line is a
            // dark layer (reads over bright lanes, scales with lane opacity) plus
            // a bright layer (reads over the dark floor, scales with the inverse),
            // so it auto-shifts dark→bright as you fade lanes; octaveContrast
            // scales the whole thing.
            for (let midi = range.activeLow; midi <= range.activeHigh; midi++) {
                const pc = ((midi % 12) + 12) % 12;
                const isEF = pc === 4;   // E → F block boundary
                const isBC = pc === 11;  // B → C octave boundary
                if (!isEF && !isBC) continue;
                const boundaryX = keyX(layout.get(midi), whiteCount) + WHITE_W / 2;
                if (isBC) {
                    addLine(boundaryX, 0x05060a, octC * 0.92 * t, 1.1 * K, false);              // dark, over lanes
                    addLine(boundaryX, 0xd8dcec, (0.42 + octC * 0.5) * (1 - t), 1.1 * K, false); // bright, over floor
                } else {
                    // E→F block divider — a guide that appears as the lanes fade.
                    addLine(boundaryX, 0x6a6a7a, 0.5 * (1 - t), 0.8 * K, false);
                }
            }

            // Keys (whites first so blacks overlay). Geometries are shared
            // across keys; materials are per key (range tint / depress +
            // wrong-flash feedback are per-key state). The geometry origin
            // is translated to the key's BACK edge so the live MIDI key-
            // depress animation can pivot there (rotation.x > 0 tips the
            // front of the key down, like a real piano action).
            const whiteGeo = _roundedBoxGeo(WHITE_W * 0.94, WHITE_H, WHITE_L, WHITE_W * 0.05, WHITE_H * 0.1);
            whiteGeo.translate(0, 0, WHITE_L / 2);
            const blackGeo = _roundedBoxGeo(BLACK_W, BLACK_H, BLACK_L, BLACK_W * 0.08, BLACK_H * 0.06);
            blackGeo.translate(0, 0, BLACK_L / 2);
            const whiteGlyphGeo = new T.PlaneGeometry(WHITE_W * 0.72, WHITE_W * 0.72);
            const blackGlyphGeo = new T.PlaneGeometry(BLACK_W * 0.96, BLACK_W * 0.96);
            for (const [midi, entry] of layout) {
                const black = entry.black;
                const inRange = midi >= range.activeLow && midi <= range.activeHigh;
                const material = new T.MeshStandardMaterial({
                    color: black ? 0x070708 : 0xe8e8ee,
                    // Pitch-class color preset on emissive but OFF at rest — the key
                    // is neutral until a note approaches, when updateScene ramps the
                    // intensity up by proximity.
                    emissive: noteColor(midi, 'rh'),
                    emissiveIntensity: 0,
                    // Anti-plastic: glossy piano black with visible strip
                    // reflections; whites keep a subtle ivory sheen (they're
                    // already near-white — env light saturates them fast).
                    roughness: black ? 0.22 : 0.42,
                    envMapIntensity: black ? 1.3 : 0.3,
                });
                const mesh = new T.Mesh(black ? blackGeo : whiteGeo, material);
                // Positions place the geometry where the old centred boxes
                // sat: the mesh origin is the back-edge centre.
                mesh.position.set(
                    keyX(entry, whiteCount),
                    black ? BLACK_H / 2 + WHITE_H * 0.1 : WHITE_H / 2,
                    black ? (WHITE_L - BLACK_L) / 2 - WHITE_L / 2 - BLACK_L / 2 - 4 * K : -WHITE_L / 2,
                );
                mesh.userData.midi = midi;
                // Wrong-note flash restore state.
                mesh.userData.origEmissive = material.emissive.getHex();
                mesh.userData.origEmissiveIntensity = material.emissiveIntensity;
                keyboardGroup.add(mesh);
                keyMeshes.set(midi, mesh);

                if (inRange) {
                    // RS+-style letter printed on the key top — dark text
                    // near the front edge of whites, light text on blacks.
                    // The glyph is a CHILD of the key mesh so it rides the
                    // depress animation (positions are key-local).
                    const glyph = new T.Mesh(
                        black ? blackGlyphGeo : whiteGlyphGeo,
                        new T.MeshBasicMaterial({
                            map: _glyphTexture(noteLetter(midi), !black),
                            transparent: true,
                            depthWrite: false,
                        }),
                    );
                    glyph.rotation.x = -Math.PI / 2;
                    glyph.position.set(
                        0,
                        black ? BLACK_H / 2 + 0.15 * K : WHITE_H / 2 + 0.15 * K,
                        black ? BLACK_L / 2 : WHITE_L - WHITE_W * 0.55,
                    );
                    mesh.add(glyph);
                }
            }

            // Glowing hit-line: a bright core bar plus two additive gradient
            // planes (a flat wash on the deck and an upright curtain) pulsed
            // gently in draw(). No postprocessing — just cheap blending.
            const lineW = totalW + 4 * WHITE_W;
            hitLine = new T.Mesh(
                new T.BoxGeometry(lineW, 1.2 * K, 1.2 * K),
                new T.MeshBasicMaterial({ color: 0xb8fff0 }),
            );
            hitLine.position.set(0, WHITE_H + 0.5 * K, hitZ);
            keyboardGroup.add(hitLine);

            const flatGlowMat = new T.MeshBasicMaterial({
                map: _glowTexture(), transparent: true, opacity: 0.85,
                blending: T.AdditiveBlending, depthWrite: false,
            });
            const flatGlow = new T.Mesh(new T.PlaneGeometry(lineW, 16 * K), flatGlowMat);
            flatGlow.rotation.x = -Math.PI / 2;
            flatGlow.position.set(0, 0.2 * K, hitZ - 5 * K);
            keyboardGroup.add(flatGlow);

            const upGlowMat = flatGlowMat.clone();
            const upGlow = new T.Mesh(new T.PlaneGeometry(lineW, 7 * K), upGlowMat);
            upGlow.position.set(0, WHITE_H + 1.5 * K, hitZ + 0.1 * K);
            keyboardGroup.add(upGlow);
            _hitGlowMats.push(flatGlowMat, upGlowMat);
        }

        function buildNoteMeshes() {
            _clearGroup(notesGroup);
            _clearNoteCaches();
            noteMeshes = [];
            const range = _notation.range;
            const { layout, whiteCount } = keyLayout(range);
            for (const note of _notation.notes) {
                const entry = layout.get(note.midi);
                if (!entry) continue;
                const len = Math.max(4 * K, note.durSec * TS);
                // Non-floating layouts: notes ride the naturals' plane and take
                // their piano-shaped lane's width/centre. Floating (default):
                // original elevated sharps, key-centred bars.
                let w, x, y;
                if (_flatMode()) {
                    const span = laneSpanFor(
                        note.midi, entry.black, keyX(entry, whiteCount), fx.octaveGaps, range);
                    // 'realistic' bars are full (physical-key size); 'flat' bars are
                    // inset a touch for a dark separator in the tight tiling.
                    const inset = _sharpMode === 'realistic' ? 1.0 : 0.9;
                    w = (span.right - span.left) * inset;
                    x = (span.left + span.right) / 2;
                    // Coplanar; in 'realistic' the sharps ride a hair proud so they
                    // draw over the naturals they overlap without z-fighting.
                    const lift = (_sharpMode === 'realistic' && entry.black) ? REAL_SHARP_LIFT : 0;
                    y = WHITE_H + NOTE_H / 2 + 0.5 * K + lift;
                } else {
                    w = (entry.black ? BLACK_W : WHITE_W * 0.94) * 0.9;
                    x = keyX(entry, whiteCount);
                    y = (entry.black ? BLACK_H + WHITE_H : WHITE_H) + NOTE_H / 2 + 0.5 * K;
                }
                // Clone per note so each can glow independently while being consumed.
                const mesh = new T.Mesh(_noteGeometry(w, len), _noteMaterial(note.midi, note.hand).clone());
                mesh.position.x = x;
                mesh.position.y = y;
                mesh.visible = false;
                notesGroup.add(mesh);
                // Note-name label: a camera-facing sprite (readable at this low camera
                // angle, unlike a flat top decal). Lives in notesGroup as a sibling —
                // NOT a child of the note mesh — so the consumption z-clip never
                // squashes the letter. Positioned per frame in updateScene.
                const label = new T.Sprite(new T.SpriteMaterial({
                    map: _glyphTexture(noteLetter(note.midi), false),
                    transparent: true, depthWrite: false,
                }));
                label.scale.set(6.5 * K, 6.5 * K, 1);
                label.visible = false;
                notesGroup.add(label);
                noteMeshes.push({ mesh, note, len, label });
            }
        }

        // Floating bar numbers: one camera-facing sprite per measure marker,
        // parked on the left shoulder of the highway and scrolled with the
        // notes in draw().
        function buildMarkerSprites() {
            _clearGroup(markersGroup);
            _clearBarTextures();
            markerSprites = [];
            const { whiteCount } = keyLayout(_notation.range);
            const totalW = whiteCount * WHITE_W;
            for (const marker of _notation.markers) {
                const mat = new T.SpriteMaterial({
                    map: _barNumberTexture(marker.idx),
                    transparent: true,
                    depthWrite: false,
                    opacity: 0.9,
                });
                const sprite = new T.Sprite(mat);
                sprite.scale.set(16 * K, 8 * K, 1);
                sprite.position.set(-totalW / 2 - 2.2 * WHITE_W, 3 * K, 0);
                sprite.visible = false;
                markersGroup.add(sprite);
                markerSprites.push({ sprite, t: marker.t });
            }
        }

        /* ── MIDI event handlers (called by module _midiOnMessage) ───────
         *
         * Receive the RAW midi note from the device; transpose is applied
         * here and the result stored under the raw note so note-off finds
         * it even if the transpose changed in between (piano invariant).
         */

        function _handleNoteOn(rawMidi, velocity, tOverride) {
            if (rawMidi < 0 || rawMidi > 127) return;
            const played = rawMidi + _cfg.transpose;
            if (played < 0 || played > 127) return;
            _rawToPlayed.set(rawMidi, played);
            // Retrigger while the pedal is down: the key is physically held
            // again, so it's no longer merely pedal-sustained. Without this,
            // pedal-up would un-depress (and stop tracking) a key the player
            // is still holding. _heldNotes is the visual-depress set (physical
            // OR pedal-sustained); _sustainedNotes is the pedal-only subset.
            _sustainedNotes.delete(played);
            _heldNotes.add(played);
            // Key depress starts immediately on the LIVE input path —
            // feedback must not wait for hit judgment.
            if (keyMeshes.has(played)) _keyAnim.set(played, true);
            _synthNoteOn(played, velocity);   // hear the played note
            _checkHit(played, Number.isFinite(tOverride) ? tOverride : _latestTime);
        }

        function _handleNoteOff(rawMidi) {
            if (rawMidi < 0 || rawMidi > 127) return;
            const played = _rawToPlayed.get(rawMidi);
            if (played == null) return; // stray note-off (cleared state)
            _rawToPlayed.delete(rawMidi);
            if (_sustainOn) {
                _sustainedNotes.add(played);
                return;   // pedal held → keep sounding (synth released on pedal-up)
            }
            _heldNotes.delete(played);
            _synthNoteOff(played);
        }

        function _handleSustain(down) {
            if (down) {
                _sustainOn = true;
            } else {
                _sustainOn = false;
                for (const midi of _sustainedNotes) { _heldNotes.delete(midi); _synthNoteOff(midi); }
                _sustainedNotes.clear();
            }
        }

        function _releaseAllHeld() {
            _heldNotes.clear();
            _sustainedNotes.clear();
            _rawToPlayed.clear();
            _sustainOn = false;
            _synthAllOff();
        }

        /* ── Hit detection / scoring (piano _checkHit port) ───────────── */

        function _checkHit(playedMidi, t) {
            if (!_notation || !_notation.notes.length) return;
            const key = judgeHit(_notation.notes, playedMidi, t, _hitNoteKeys, HIT_TOLERANCE_S);
            const wall = performance.now();
            if (key) {
                _hitNoteKeys.add(key);
                _hits++;
                _streak++;
                if (_streak > _bestStreak) _bestStreak = _streak;
                _keyFlash.delete(playedMidi); // a hit cancels a lingering red
                _spawnFlame(playedMidi, wall);
                // Timing verdict: noteKey() serializes the matched note's t
                // as its prefix ("<t.toFixed(3)>|<midi>"), so parseFloat
                // recovers it without changing judgeHit's tested contract.
                const ts = _classifyTiming(parseFloat(key) - t, HIT_TOLERANCE_S);
                _spawnSparks(playedMidi, ts);
                // Brief hit-line brightness kick (decays exp(-t*6) in draw).
                _hitGlowKick = 1;
                _scoreOnHit(playedMidi);
                _ndReport(true, playedMidi, _ndBindingId);
            } else {
                const prevStreak = _streak;
                _misses++;
                _streak = 0;
                _scoreOnBreak(prevStreak);
                if (keyMeshes.has(playedMidi)) _keyFlash.set(playedMidi, wall);
                _ndReport(false, playedMidi, _ndBindingId);
            }
        }

        function _resetScoring() {
            _hits = 0; _misses = 0; _streak = 0; _bestStreak = 0;
            _hitNoteKeys.clear();
            _missedNoteKeys.clear();
            _sweepCursor.idx = 0;
            _missFloor = null;
            _keyFlash.clear();
            // Fresh chart, fresh timing cursor — a note-on landing between
            // the new chart build and its first draw() must be judged at
            // the new run's start, not against the previous song's time.
            _latestTime = 0;
        }

        // Swept-miss callback — kept as a named function so the per-frame
        // sweep passes a stable reference (no closure allocation in draw()).
        function _onSweptMiss(note) {
            _scoreOnBreak(_streak);   // called before the caller zeroes it
            _ndReport(false, note.midi, _ndBindingId);
        }

        /* ── End-of-run stats POST (exactly once per run) ──────────────── */

        async function _finalizeRun() {
            if (_recordedThisRun) return;
            // When the note-detection domain is present, the consumer (notedetect
            // → core stats-recorder) owns stats + progression, posting/awarding
            // from our reported verdicts. Defer to it so we don't double-count
            // the play or double-award feedback points.
            if (window.slopsmith && window.slopsmith.noteDetection && window.slopsmith.noteDetection.version === 1) {
                _recordedThisRun = true;
                return;
            }
            if (!_runMeta || !_runMeta.filename) return;
            if (_hits + _misses <= 0) return; // nothing was scored this run
            _recordedThisRun = true;
            const accuracy = accuracyOf(_hits, _misses);
            const body = {
                filename: _runMeta.filename,
                arrangement: _runMeta.arrangement,
                score: scoreOf(_hits, _misses),
                accuracy,
                hits: _hits,
                misses: _misses,
                bestStreak: _bestStreak,
            };
            let resp = null;
            try {
                const r = await fetch('/api/stats', {
                    method: 'POST',
                    headers: { 'Content-Type': 'application/json' },
                    body: JSON.stringify(body),
                });
                resp = await r.json().catch(() => null);
            } catch (_) { /* offline / endpoint absent — non-fatal */ }
            // Fan the outcome out to the progression core (guarded — older
            // servers have neither the field nor the host).
            try { window.v3Progression?.notify(resp ? resp.progression : null); } catch (_) {}
        }

        /* ── Live feedback animation (no per-frame allocations) ────────── */

        function _animateFeedback(wallNow) {
            const dt = Math.min(Math.max(wallNow - _lastWallMs, 0), 100);
            _lastWallMs = wallNow;
            // Key depress: exponential spring toward the held/released
            // target angle, pivoting at the back edge (geometry origin).
            const k = 1 - Math.exp(-dt / KEY_PRESS_TAU_MS);
            for (const midi of _keyAnim.keys()) {
                const mesh = keyMeshes.get(midi);
                if (!mesh) { _keyAnim.delete(midi); continue; }
                const target = _heldNotes.has(midi) ? KEY_PRESS_ANGLE : 0;
                const next = mesh.rotation.x + (target - mesh.rotation.x) * k;
                mesh.rotation.x = next;
                if (target === 0 && Math.abs(next) < 0.001) {
                    mesh.rotation.x = 0;
                    _keyAnim.delete(midi);
                }
            }
            // Wrong-note red flash, fading back to the key's own emissive.
            for (const [midi, start] of _keyFlash) {
                const mesh = keyMeshes.get(midi);
                if (!mesh) { _keyFlash.delete(midi); continue; }
                const p = (wallNow - start) / WRONG_FLASH_MS;
                if (p >= 1) {
                    mesh.material.emissive.setHex(mesh.userData.origEmissive);
                    mesh.material.emissiveIntensity = mesh.userData.origEmissiveIntensity;
                    _keyFlash.delete(midi);
                } else {
                    mesh.material.emissive.setHex(0xff2020);
                    mesh.material.emissiveIntensity = 0.9 * (1 - p);
                }
            }
            // Flame flares: rise, widen and fade over FLAME_MS.
            for (let i = 0; i < _flamePool.length; i++) {
                const slot = _flamePool[i];
                if (slot.start < 0) continue;
                const age = (wallNow - slot.start) / FLAME_MS;
                if (age >= 1) {
                    slot.start = -1;
                    slot.sprite.visible = false;
                    continue;
                }
                const h = 14 * K * (0.6 + 0.9 * age);
                slot.sprite.scale.set(8 * K * (1 + 0.3 * age), h, 1);
                slot.sprite.position.y = slot.baseY + h / 2;
                slot.mat.opacity = 1 - age;
            }
        }

        function updateScene(now) {
            // Rest emissive with the Glow slider applied — computed once so
            // the per-note '!==' guards stay effective at any glow value.
            const _restEmissive = NOTE_EMISSIVE_BASE * _glowMul();
            const hitZ = -WHITE_L / 2;

            // Pan the camera x to follow the active hand: a hit-line-weighted
            // centroid of the notes around `now`. Keeps a ~2-octave window
            // framed and scrolls with the melody, RS+-style, instead of
            // statically framing the whole range.
            let minX = Infinity, maxX = -Infinity;
            for (const { mesh, note } of noteMeshes) {
                const dt = note.t - now;
                if (dt < -0.4 || dt > CAM_ZOOM_AHEAD) continue;
                const x = mesh.position.x;
                if (x < minX) minX = x;
                if (x > maxX) maxX = x;
            }
            if (maxX >= minX) {
                // Centre on the span midpoint so the lowest and highest target
                // keys are equidistant and both stay in view; zoom to fit the span.
                _camTargetX = (minX + maxX) / 2;
                const span = (maxX - minX) + CAM_ZOOM_PAD;
                const tz = span / (CAM_ZOOM_BASE_KEYS * WHITE_W);
                _camTargetZoom = Math.max(CAM_ZOOM_MIN, Math.min(CAM_ZOOM_MAX, tz));
            }
            _camX += (_camTargetX - _camX) * CAM_PAN_LERP;
            _camZoom += (_camTargetZoom - _camZoom) * CAM_ZOOM_LERP;
            {
                const r = _rig();
                let _cx = _camX, _cy = r.y * K * _camZoom, _cz = r.z * K * _camZoom;
                let _lx = _camX, _ly = r.lookY * K * _camZoom, _lz = r.lookZ * K * _camZoom;
                // Camera Director free-cam offsets (per-panel-aware), layered on top
                // of the auto-framing so pan/zoom-follow still works. Dolly/height/
                // orbit act on the camera-from-target vector; pan/pitch shift the
                // look target. NaN-safe; null/disabled bridge → stock.
                const _fc = _freeCamFor(highwayCanvas);
                if (_fc && _fc.enabled) {
                    const _dm = Number.isFinite(_fc.distMul) ? _fc.distMul : 1;
                    const _hm = Number.isFinite(_fc.heightMul) ? _fc.heightMul : 1;
                    const _yaw = Number.isFinite(_fc.yaw) ? _fc.yaw : 0;
                    let _vx = _cx - _lx, _vy = _cy - _ly, _vz = _cz - _lz;
                    _vx *= _dm; _vy *= _dm; _vz *= _dm;   // dolly (zoom)
                    _vy *= _hm;                            // height
                    const _cyw = Math.cos(_yaw), _syw = Math.sin(_yaw);
                    const _rx = _vx * _cyw - _vz * _syw, _rz = _vx * _syw + _vz * _cyw; // orbit around Y
                    _cx = _lx + _rx; _cy = _ly + _vy; _cz = _lz + _rz;
                    const _px = Number.isFinite(_fc.panX) ? _fc.panX : 0;
                    const _py = Number.isFinite(_fc.panY) ? _fc.panY : 0;
                    const _pt = Number.isFinite(_fc.pitch) ? _fc.pitch : 0;
                    _lx += _px * K; _ly += (_pt + _py) * K;
                }
                cam.position.set(_cx, _cy, _cz);
                cam.lookAt(_lx, _ly, _lz);
            }

            for (const km of keyMeshes.values()) km.userData.glow = 0;
            for (const { mesh, note, len, label } of noteMeshes) {
                const dt = note.t - now;
                // Key approach-glow: the nearest upcoming note lights its key,
                // stronger the closer it is to the hit-line.
                if (dt >= -0.05 && dt <= KEY_GLOW_AHEAD) {
                    const km = keyMeshes.get(note.midi);
                    if (km) {
                        const p = 1 - dt / KEY_GLOW_AHEAD;
                        if (p > km.userData.glow) km.userData.glow = p;
                    }
                }
                // Note front edge arrives at the hit-line at note.t.
                const z = scrollZ(note.t, now, hitZ, TS) - len / 2;
                const frontZ = z + len / 2;
                const backZ = z - len / 2;
                const past = frontZ - hitZ;

                // Note-name label: full while approaching; once the note starts being
                // consumed, fade out over LABEL_FADE_DIST. Driven by `past` (not the
                // mesh state) so it lingers and fades even after the note is eaten.
                if (label) {
                    if (frontZ < -HIGHWAY_LEN) {
                        label.visible = false;
                    } else if (past <= 0) {
                        label.visible = true;
                        label.material.opacity = 1;
                        label.position.set(mesh.position.x, mesh.position.y + NOTE_H / 2 + 1.6 * K, z);
                    } else {
                        const op = 1 - past / LABEL_FADE_DIST;
                        if (op <= 0) {
                            label.visible = false;
                        } else {
                            label.visible = true;
                            label.material.opacity = op;
                            const rem = Math.max(0, len - past);
                            label.position.set(mesh.position.x, mesh.position.y + NOTE_H / 2 + 1.6 * K, hitZ - rem / 2);
                        }
                    }
                }

                if (frontZ < -HIGHWAY_LEN || backZ > hitZ + 20 * K) {
                    mesh.visible = false;
                    continue;
                }
                mesh.visible = true;
                // True sustain consumption: once the leading edge reaches the
                // hit-line, pin the front there and shorten the note from the front
                // as it's "eaten", while the tail keeps feeding down the runway. The
                // gem geometry has length `len`, so scale Z by remaining/len and
                // recentre on the still-visible [backZ, hitZ] segment.
                if (past <= 0) {
                    if (mesh.scale.z !== 1) mesh.scale.set(1, 1, 1);
                    mesh.position.z = z;
                    if (mesh.material.emissiveIntensity !== _restEmissive) {
                        mesh.material.emissiveIntensity = _restEmissive;
                    }
                } else {
                    const remaining = len - past;        // = hitZ - backZ, length still up the runway
                    if (remaining <= 0.02 * K) { mesh.visible = false; continue; }
                    mesh.scale.set(1, 1, remaining / len);
                    mesh.position.z = hitZ - remaining / 2;
                    // Glow as it's eaten — intensifies the more of the note is consumed.
                    const consumed = Math.min(1, past / len);
                    mesh.material.emissiveIntensity =
                        (NOTE_EMISSIVE_BASE + (CONSUME_GLOW - NOTE_EMISSIVE_BASE) * (0.45 + 0.55 * consumed)) * _glowMul();
                }
            }
            // Apply the approach-glow (a wrong-note red flash owns emissive meanwhile).
            for (const [midi, km] of keyMeshes) {
                if (_keyFlash.has(midi)) continue;
                const g = km.userData.glow || 0;
                km.material.emissiveIntensity = g * g * KEY_GLOW_STRENGTH * _glowMul(); // ease → pops near the hit-line
            }
            for (const entry of markerSprites) {
                const z = scrollZ(entry.t, now, hitZ, TS);
                if (z < -HIGHWAY_LEN || z > hitZ + 6 * K) {
                    entry.sprite.visible = false;
                    continue;
                }
                entry.sprite.visible = true;
                entry.sprite.position.z = z;
            }
            // Gentle hit-line pulse — two material opacity writes, no
            // allocations. With bloom active the additive planes accumulate
            // into an HDR target and the UnrealBloomPass amplifies them —
            // full-strength they flood the whole keyboard white — so damp
            // the pulse on that path; the composer's glow makes up the
            // difference.
            // Must mirror draw()'s composer gate exactly (incl. the
            // splitscreen checks) — damping on the direct-render path would
            // leave the hit line visibly dimmer.
            const glowScale = (_bloomGateOk() && _composer) ? 0.45 : 1;
            // Base pulse + the per-hit brightness kick (decayed in draw()'s
            // wall-clock FX step, scaled by the Hit feedback slider).
            const pulse = Math.min(1,
                (0.72 + 0.18 * Math.sin(now * 5.0) + 0.5 * _hitGlowKick * fx.hitFx) * glowScale);
            for (let i = 0; i < _hitGlowMats.length; i++) _hitGlowMats[i].opacity = pulse;

            // Missed-note sweep — only while a MIDI device is connected
            // (without one, every note would count as a miss and corrupt
            // accuracy), and never retroactively across a mid-song connect.
            // Only the focused instance scores: note-ons route solely to
            // _activeInstance, so sweeping misses anywhere else would accrue
            // misses with no possible hits and corrupt that run's stats
            // (splitscreen / overlapping renderer lifetimes).
            // Gate on _midiHandle (the live wired session), NOT _midiInput: the
            // latter is set as soon as a device is picked, but the async mi.open()
            // may still be pending (slow / permission prompt), during which no
            // events can arrive — sweeping then would bank false misses. _midiHandle
            // is truthy only after a handle is opened and wired.
            if (_midiHandle && _notation && _activeInstance === instance) {
                if (_midiJustConnected) {
                    _missFloor = now;
                    _midiJustConnected = false;
                }
                const swept = sweepMissed(
                    _notation.notes, now, _hitNoteKeys, _missedNoteKeys,
                    HIT_TOLERANCE_S, _missFloor, _onSweptMiss, _sweepCursor,
                );
                if (swept) { _misses += swept; _streak = 0; }
            }
            _latestTime = now;
        }

        async function loadNotationForCurrentSong() {
            const song = window.slopsmith && window.slopsmith.currentSong;
            if (!song || !song.filename) return;
            const seq = ++_loadSeq;
            try {
                const { measures } = await fetchNotation(song.filename, song.arrangementIndex != null ? song.arrangementIndex : -1);
                if (seq !== _loadSeq || !_isReady) return; // superseded / torn down
                const notes = flattenNotation(measures);
                _notation = {
                    notes,
                    range: keyRange(notes),
                    markers: measureMarkers(measures),
                };
                buildKeyboardAndHighway();
                buildNoteMeshes();
                buildMarkerSprites();
                // Finalize the OUTGOING run before we clobber its scoring
                // state. The host may emit song:loaded for the next song
                // before the prior run's song:ended/stop (or never emit one
                // for a seamless switch); without this the previous run would
                // post zero times, or worse, post the new song's metadata.
                // _finalizeRun is a no-op when nothing was scored or it
                // already posted, so a normal end→load sequence is unaffected.
                // Not awaited: _finalizeRun latches _recordedThisRun and
                // snapshots the POST body synchronously BEFORE its `await
                // fetch`, so the reset below can't corrupt it — and a slow or
                // hung /api/stats must never block the next chart's load.
                void _finalizeRun();
                // Fresh run: scoring + once-per-run stats latch reset here.
                _resetScoring();
                _recordedThisRun = false;
                _runMeta = {
                    filename: song.filename,
                    arrangement: Number.isFinite(Number(song.arrangementIndex)) ? Number(song.arrangementIndex) : 0,
                };
                _ndOpenBindingForChart(_notation.range, seq);
                _emitDomain('renderer-ready', { providerId: 'keys_highway_3d' });
            } catch (e) {
                console.error('[Keys-Hwy3D] notation load failed:', e);
                _emitDomain('renderer-failed', { providerId: 'keys_highway_3d', reason: 'notation load failed' });
            }
        }

        // Per-song note-detection binding: close the previous one, register
        // the provider (idempotent) and open a binding scoped to this
        // chart's keys range. All guarded — degrades silently when the
        // spec-009 host is absent. `seq` is the chart-load sequence: a
        // rapid song switch can land a stale open-binding response after
        // the next chart is active, which would misattribute hit/miss
        // events and leak the live binding — so the write-back is gated on
        // the same supersession check the notation fetch uses, and a
        // superseded binding is closed instead of stored.
        async function _ndOpenBindingForChart(range, seq) {
            if (_ndBindingId) {
                _capCommand('note-detection', 'close-binding', { bindingId: _ndBindingId },
                    'Song changed — close the previous keys binding');
                _ndBindingId = null;
            }
            await _ndEnsureProvider();
            const p = await _capCommand('note-detection', 'open-binding', {
                providerId: ND_PROVIDER_ID,
                context: { arrangement: 'keys', midiLow: range.activeLow, midiHigh: range.activeHigh },
            }, 'Open a keys verify binding for the loaded chart');
            const bindingId = p && p.bindingId;
            if (!bindingId) return;
            if (seq !== _loadSeq || !_isReady) {
                _capCommand('note-detection', 'close-binding', { bindingId },
                    'Superseded by a newer chart load');
                return;
            }
            _ndBindingId = bindingId;
        }

        // The one condition under which the bloom composer path may render:
        // enabled, single instance of this viz, and no host splitscreen
        // (mixed splits with another renderer included). updateScene's
        // hit-line damping and draw()'s render tail must agree on this.
        function _bloomGateOk() {
            return fx.bloom && _instances.size === 1 && !_ssActive();
        }

        /* ── Bloom (PORTED FROM highway_3d/screen.js _bloomEnsure — keep in
         * sync; deliberate delta: this copy tracks pixel-ratio changes via
         * composer.setPixelRatio (here and in applySize) because renderScale
         * changes the ratio at runtime — the upstream composer never learns
         * about ratio changes after construction, a candidate fix to port
         * back to highway_3d) ── */
        // Lazy-load the vendored postprocessing addons and build an
        // EffectComposer (RenderPass -> UnrealBloomPass -> OutputPass/ACES).
        // Returns the composer once ready, or null (caller falls back to a
        // direct render — also the permanent path if the addons are missing,
        // e.g. an older self-hosted core without static/vendor/three/addons).
        function _bloomEnsure() {
            if (_composer) return _composer;
            if (_bloomLoad || !ren || !scene || !cam) return null;
            const A = '/static/vendor/three/addons/';
            const myGen = _bloomGen;   // superseded by any _bloomDispose()
            _bloomLoad = Promise.all([
                import(A + 'postprocessing/EffectComposer.js'),
                import(A + 'postprocessing/RenderPass.js'),
                import(A + 'postprocessing/UnrealBloomPass.js'),
                import(A + 'postprocessing/OutputPass.js'),
            ]).then(([EC, RP, UB, OP]) => {
                try {
                    // Torn down or superseded mid-load (a dispose clears
                    // _bloomLoad, letting a NEW load start against the new
                    // renderer — this stale completion must not also build
                    // and orphan a composer).
                    if (myGen !== _bloomGen || _composer) return;
                    if (!ren || !scene || !cam || !highwayCanvas) return;
                    const w = Math.max(2, (highwayCanvas.clientWidth || highwayCanvas.width || 1280) | 0);
                    const h = Math.max(2, (highwayCanvas.clientHeight || highwayCanvas.height || 720) | 0);
                    // Multisampled (WebGL2 MSAA) HalfFloat target so anti-aliasing
                    // survives the bloom path — EffectComposer's default target has
                    // no `samples`.
                    const rt = new T.WebGLRenderTarget(w, h, { type: T.HalfFloatType, samples: 4 });
                    const comp = new EC.EffectComposer(ren, rt);
                    comp.setPixelRatio(ren.getPixelRatio());
                    comp.addPass(new RP.RenderPass(scene, cam));
                    _bloomPass = new UB.UnrealBloomPass(new T.Vector2(w, h), 0.65, 0.5, 0.82); // strength, radius, threshold (high → only emissive blooms)
                    comp.addPass(_bloomPass);
                    comp.addPass(new OP.OutputPass());
                    comp.setSize(w, h);
                    _bloomW = w; _bloomH = h; _composer = comp;
                } catch (e) { console.warn('[Keys-Hwy3D] bloom init failed', e); _composer = null; }
            }).catch((e) => console.warn('[Keys-Hwy3D] bloom modules failed', e));
            return null;
        }

        // Drop the composer + its render targets (teardown path). Nulling
        // _bloomLoad lets _bloomEnsure rebuild lazily on a later init.
        function _bloomDispose() {
            if (_composer) {
                // EffectComposer.dispose() only frees its own read/write
                // buffers — passes own additional GPU resources (UnrealBloom
                // keeps several render targets + materials, OutputPass a
                // material), so dispose each pass explicitly first.
                try {
                    for (const p of _composer.passes || []) {
                        if (p && typeof p.dispose === 'function') p.dispose();
                    }
                } catch (_) {}
                try { _composer.dispose(); } catch (_) {}
            }
            _composer = null;
            _bloomPass = null;
            _bloomLoad = null;
            _bloomW = 0; _bloomH = 0;
            _bloomGen++;   // invalidate any in-flight addon load
        }

        /* ── HUD overlay (combo / accuracy / streak — drum_highway_3d pattern) ── */
        function _injectHud() {
            if (_hudEl || !highwayCanvas) return;
            const parent = highwayCanvas.parentElement;
            if (!parent) return;
            // Position the parent relative so the absolute HUD anchors to the
            // canvas. Read-only check first so we don't clobber an existing
            // position the host page set.
            const cur = parent.style.position || getComputedStyle(parent).position;
            if (cur === 'static' || !cur) {
                _hudParentOrigPosition = parent.style.position;
                parent.style.position = 'relative';
            }
            _hudEl = document.createElement('div');
            _hudEl.className = 'keys-h3d-hud';
            _hudEl.style.cssText = [
                // Below the host's top-left song-info block (title /
                // arrangement / tuning, ~3 lines) so the two never overlap.
                'position:absolute', 'top:96px', 'left:14px',
                'font-family:system-ui,sans-serif', 'font-size:13px',
                'color:#e2e8f0', 'pointer-events:none', 'z-index:6',
                'text-shadow:0 1px 2px rgba(0,0,0,0.8)',
                'min-width:140px', 'line-height:1.4',
            ].join(';');
            parent.appendChild(_hudEl);
            _refreshHud();
        }

        function _removeHud() {
            if (_hudEl) {
                const parent = _hudEl.parentNode;
                if (parent) {
                    parent.removeChild(_hudEl);
                    // Restore position only if _injectHud changed it.
                    if (_hudParentOrigPosition !== null) {
                        parent.style.position = _hudParentOrigPosition;
                        _hudParentOrigPosition = null;
                    }
                }
            }
            _hudEl = null;
        }

        function _refreshHud() {
            if (!_hudEl) return;
            // Only meaningful while a wired MIDI session can score — same
            // gate as the miss sweep. Without one, show nothing rather than
            // a frozen 0× combo.
            if (!_midiHandle) {
                if (_hudEl.innerHTML) _hudEl.innerHTML = '';
                return;
            }
            const total = _hits + _misses;
            const pct = total ? Math.round((_hits / total) * 100) : 0;
            const comboColor = _streak >= 30 ? '#fde047' :
                               _streak >= 10 ? '#86efac' : '#cbd5e1';
            _hudEl.innerHTML =
                `<div style="color:${comboColor};font-weight:600;font-size:18px">${_streak}× combo</div>` +
                `<div>${_hits}/${total} (${pct}%)</div>` +
                (_bestStreak ? `<div style="color:#94a3b8;font-size:11px">best ${_bestStreak}</div>` : '');
        }

        function applySize(w, h) {
            if (!ren || !cam || !w || !h) return;
            // Splitscreen: cap the base DPR harder (1.25 vs 2, mirroring
            // highway_3d) so two panels don't double the fill cost. Checks
            // the host split state (covers a mixed split with another
            // renderer) plus our own instance count (covers multi-instance
            // without host state). Before this PR no setPixelRatio was ever
            // called — HiDPI displays rendered at CSS resolution and looked
            // soft/aliased.
            const baseDPR = (_ssActive() || _instances.size > 1)
                ? Math.min(window.devicePixelRatio || 1, 1.25)
                : Math.min(window.devicePixelRatio || 1, 2);
            ren.setPixelRatio(_renderScale * baseDPR);
            ren.setSize(w, h, false);
            if (_composer) {
                // EffectComposer snapshots the renderer's pixelRatio — it must
                // be told about both ratio and box changes or bloom renders at
                // the wrong resolution.
                _composer.setPixelRatio(ren.getPixelRatio());
                _composer.setSize(w, h);
                _bloomW = w; _bloomH = h;
            }
            cam.aspect = w / h;
            cam.updateProjectionMatrix();
            // Record the logical size actually framed for, so the draw()
            // drift check can tell when the live canvas box has moved away
            // from it (PORTED FROM highway_3d).
            _appliedW = w; _appliedH = h;
            _sizeFxCanvas();
        }

        function teardown() {
            _bloomDispose();
            // HUD cleanup lives here (not only destroy): init() re-runs
            // teardown() for renderer re-initialization, possibly against a
            // different canvas — a stale _hudEl would both linger in the old
            // parent and make the next _injectHud() an early-return no-op.
            _removeHud();
            if (_fxHandler) {
                window.removeEventListener('keys3d:settings', _fxHandler);
                _fxHandler = null;
            }
            if (_fxThemeHandler) {
                window.removeEventListener('keys3d:settings', _fxThemeHandler);
                _fxThemeHandler = null;
            }
            _disposeEnv();
            _bgTeardownStyle();
            _removeFxCanvas();
            _railMats.length = 0;
            ambLight = dirLight = _floorMat = null;
            const sm = window.slopsmith;
            if (sm && typeof sm.off === 'function') {
                if (_songHandler) sm.off('song:loaded', _songHandler);
                if (_endHandler) sm.off('song:ended', _endHandler);
                if (_stopHandler) sm.off('song:stop', _stopHandler);
            }
            _songHandler = _endHandler = _stopHandler = null;
            if (_ndBindingId) {
                _capCommand('note-detection', 'close-binding', { bindingId: _ndBindingId },
                    'Renderer torn down');
                _ndBindingId = null;
            }
            _clearGroup(notesGroup);
            _clearGroup(markersGroup);
            _clearGroup(keyboardGroup);
            _clearGroup(_flamesGroup);
            _flamePool.length = 0;
            if (_sparkPts) {
                try { _sparkPts.geometry.dispose(); _sparkPts.material.dispose(); } catch (_) {}
                _sparkPts = null;
                _sparkPos = _sparkCol = _sparkVel = _sparkLife = null;
            }
            _laneGuideMats.length = 0;   // owned + disposed with keyboardGroup
            _hitGlowKick = 0;
            _clearNoteCaches();
            _clearBarTextures();
            _clearFlameTextures();
            if (_glowTex) { _glowTex.dispose(); _glowTex = null; }
            _hitGlowMats.length = 0;
            if (ren) ren.dispose();
            ren = scene = cam = notesGroup = keyboardGroup = markersGroup = hitLine = null;
            _flamesGroup = null;
            noteMeshes = [];
            markerSprites = [];
            keyMeshes = new Map();
            _layoutInfo = null;
            _keyAnim.clear();
            _keyFlash.clear();
            _releaseAllHeld();
            _notation = null;
            _isReady = false;
        }

        const instance = {
            contextType: 'webgl2',

            init(canvas, _bundle) {
                if (_isReady) teardown();
                highwayCanvas = canvas;
                fx = readFxSettings();
                // Persisted string settings refresh here too — a palette,
                // camera, theme or background style saved while no instance was
                // listening (e.g. changed on the Settings screen, where the live
                // viz is torn down) must not come up stale on a later init().
                _palette = readPaletteSetting();
                _sharpMode = readSharpModeSetting();
                _camPreset = CAM_PRESETS[readCameraSetting()] || CAM_PRESETS.classic;
                _theme = readThemeSetting();
                _bgStyle = readBgStyleSetting();
                loadThree().then(() => {
                    if (!highwayCanvas) return; // destroyed before load resolved
                    try {
                        ren = new T.WebGLRenderer({ canvas: highwayCanvas, antialias: true, alpha: false });
                        ren.setClearColor(FOG_COLOR, 1);
                    } catch (e) {
                        console.error('[Keys-Hwy3D] WebGL2 init failed:', e);
                        _emitDomain('renderer-failed', { providerId: 'keys_highway_3d', reason: 'webgl2 init failed' });
                        return;
                    }
                    buildScene();
                    applySize(highwayCanvas.clientWidth, highwayCanvas.clientHeight);
                    _fxHandler = (ev) => {
                        const d = ev && ev.detail;
                        if (!d || !d.fx) return;
                        // FX toggles are consumed per-frame in draw() — no
                        // rebuild needed; the composer stays cached while off.
                        for (const k of Object.keys(d.fx)) {
                            if (k in FX_DEFAULTS) fx[k] = d.fx[k];
                        }
                        // Vibrancy is baked into built materials — retint
                        // the live scene without a chart rebuild.
                        if ('vibrancy' in d.fx) _applyVibrancy();
                        if ('cinematic' in d.fx) _applyCinematic();
                        if ('bgIntensity' in d.fx) _bgMountStyle();
                        if ('glow' in d.fx) {
                            // Material emissive bases are also per-frame
                            // (updateScene) — only the cached/cloned rest
                            // values need a nudge here.
                            const base = NOTE_EMISSIVE_BASE * _glowMul();
                            for (const m of _noteMatCache.values()) m.emissiveIntensity = base;
                        }
                    };
                    _fxThemeHandler = (ev) => {
                        const d = ev && ev.detail;
                        if (d && d.theme && BG_THEMES[d.theme]) {
                            _theme = d.theme;
                            _applyTheme();
                        }
                        if (d && d.bgStyle && BG_STYLE_IDS.indexOf(d.bgStyle) !== -1) {
                            _bgStyle = d.bgStyle;
                            _bgMountStyle();
                        }
                        if (d && d.palette && PALETTE_IDS.indexOf(d.palette) !== -1) {
                            _palette = d.palette;
                            _applyPalette();
                        }
                        if (d && d.sharpMode && SHARP_MODES.indexOf(d.sharpMode) !== -1) {
                            // Geometry-time — takes effect on the next chart build.
                            _sharpMode = d.sharpMode;
                        }
                        if (d && d.camera && CAM_PRESETS[d.camera]) {
                            _camPreset = CAM_PRESETS[d.camera];
                            // Position/lookAt re-derive next frame; only the
                            // projection needs an explicit poke.
                            if (cam) {
                                cam.fov = _camPreset.fov;
                                cam.updateProjectionMatrix();
                            }
                        }
                    };
                    window.addEventListener('keys3d:settings', _fxThemeHandler);
                    window.addEventListener('keys3d:settings', _fxHandler);
                    _injectHud();
                    _isReady = true;
                    loadNotationForCurrentSong();
                    if (window.slopsmith && typeof window.slopsmith.on === 'function') {
                        _songHandler = () => loadNotationForCurrentSong();
                        window.slopsmith.on('song:loaded', _songHandler);
                        // End-of-run stats: finalize on natural end AND on
                        // an early stop (player closed the song) — the
                        // once-per-run latch makes the pair idempotent.
                        _endHandler = () => { _finalizeRun(); };
                        _stopHandler = () => { _finalizeRun(); };
                        window.slopsmith.on('song:ended', _endHandler);
                        window.slopsmith.on('song:stop', _stopHandler);
                    }
                    // MIDI lifecycle: this instance takes focus; _midiInit
                    // is idempotent (no repeat permission prompt) and
                    // _midiActive gates the async access resolution so a
                    // destroy() before it lands can't wire a dead handler.
                    _instances.add(instance);
                    _activeInstance = instance;
                    // Re-apply size now that this instance is counted in
                    // _instances: the applySize above ran before the add, so
                    // its splitscreen DPR check (size > 1) undercounted — the
                    // second panel of a splitscreen mount would otherwise keep
                    // full 2x DPR until some later resize. The already-mounted
                    // panel is corrected by the host's own layout resize when
                    // the split activates (panels change box size).
                    if (_instances.size > 1 || _ssActive()) {
                        applySize(highwayCanvas.clientWidth, highwayCanvas.clientHeight);
                    }
                    _midiInit();
                    _midiResume();
                }).catch(() => {
                    _emitDomain('renderer-failed', { providerId: 'keys_highway_3d', reason: 'three load failed' });
                });
            },

            draw(bundle) {
                if (!_isReady || !ren || !scene || !cam) return;
                // Host adaptive quality: consume bundle.renderScale like
                // highway_3d — the host lowers it under GPU load ("Quality"
                // + "Min res" controls) and applySize folds it into the DPR.
                const newScale = (bundle && bundle.renderScale) || 1;
                if (newScale !== _renderScale) {
                    _renderScale = newScale;
                    applySize(highwayCanvas.clientWidth, highwayCanvas.clientHeight);
                }
                // Keep the render matched to the highway canvas's real box
                // (PORTED FROM highway_3d — keep in sync). Two drifts to catch:
                //  1. Backing store (canvas.width/height) changed out from under
                //     us — the splitscreen hw.resize override resizes the element
                //     but never calls renderer.resize().
                //  2. The CSS box (clientWidth/Height) drifted while the backing
                //     store held — e.g. the flex #highway box settling after a
                //     fullscreen transition, with no backing-store change and no
                //     resize() call, so branch 1 never fires. Without this the
                //     drum/keys panels stay framed for the pre-fullscreen size.
                if (highwayCanvas) {
                    const _bsChanged = highwayCanvas.width !== _lastHwW
                        || highwayCanvas.height !== _lastHwH;
                    _boxCheckCountdown = (_boxCheckCountdown + 1) % 10;
                    if (_bsChanged || _boxCheckCountdown === 0) {
                        const _bw = highwayCanvas.clientWidth | 0;
                        const _bh = highwayCanvas.clientHeight | 0;
                        if (_bsChanged) {
                            _lastHwW = highwayCanvas.width;
                            _lastHwH = highwayCanvas.height;
                            if (_bw > 0 && _bh > 0) applySize(_bw, _bh);
                        } else if (_bw > 0 && _bh > 0 &&
                                (Math.abs(_bw - _appliedW) > 1 || Math.abs(_bh - _appliedH) > 1)) {
                            applySize(_bw, _bh);
                        }
                    }
                }
                const now = (bundle && typeof bundle.currentTime === 'number') ? bundle.currentTime : 0;
                if (_notation) updateScene(now);
                _animateFeedback(performance.now());
                // Wall-clock FX step (sparks, hit-line kick decay) —
                // decoupled from song time so effects settle while paused.
                {
                    const nowMs = performance.now();
                    const fdt = _fxLastWall === 0 ? 1 / 60 : Math.min(0.05, (nowMs - _fxLastWall) / 1000);
                    _fxLastWall = nowMs;
                    _sparkUpdate(fdt);
                    if (_hitGlowKick > 0.001) _hitGlowKick *= Math.exp(-fdt * 6);
                    else if (_hitGlowKick !== 0) _hitGlowKick = 0;
                    // Background ambience: advance the active style with the
                    // audio bands (zeros when reactivity is off).
                    if (_bgState && _bgState.s && BG_STYLES[_bgState._style]) {
                        const bands = fx.bgReactive ? _bgReadBands() : BG_ZERO_BANDS;
                        try {
                            BG_STYLES[_bgState._style].update(_bgState.s, bands, fdt, nowMs / 1000);
                        } catch (_) { /* visual-only */ }
                    }
                }
                _refreshHud();
                if (fx.scoreFx) { _ensureFxCanvas(); _drawScoreFx(); }
                else if (_fxCtx && _fxCanvas && _fxCanvas._dirty) {
                    _fxCtx.clearRect(0, 0, _fxCanvas.width, _fxCanvas.height);
                    _fxCanvas._dirty = false;
                }
                // Bloom path (PORTED FROM highway_3d): composer + ACES tone
                // mapping when enabled and single-instance; direct render
                // otherwise (including the frames while addons stream in).
                const comp = _bloomGateOk() ? _bloomEnsure() : null;
                if (comp) {
                    const w = highwayCanvas.clientWidth | 0, h = highwayCanvas.clientHeight | 0;
                    if (w > 0 && h > 0 && (w !== _bloomW || h !== _bloomH)) {
                        comp.setSize(w, h); _bloomW = w; _bloomH = h;
                    }
                    if (ren.toneMapping !== T.ACESFilmicToneMapping) ren.toneMapping = T.ACESFilmicToneMapping;
                    comp.render();
                } else {
                    if (ren.toneMapping !== T.NoToneMapping) ren.toneMapping = T.NoToneMapping;
                    ren.render(scene, cam);
                }
            },

            resize(w, h) {
                if (!_isReady) return;
                applySize(w, h);
            },

            destroy() {
                _instances.delete(instance);
                // If the focused instance is going away but others remain
                // (splitscreen teardown of one panel), promote a survivor so
                // _midiOnMessage keeps routing instead of dropping every
                // event on its `if (!_activeInstance) return` guard.
                if (_activeInstance === instance) {
                    _activeInstance = null;
                    for (const inst of _instances) { _activeInstance = inst; break; }
                }
                if (_instances.size === 0) _midiReleaseSession();
                teardown();   // includes _removeHud()
                // Instances are reused across songs (destroy() → init()); stale
                // applied/backing dims would suppress the first reframe of the
                // next song (PORTED FROM highway_3d).
                _lastHwW = 0; _lastHwH = 0;
                _appliedW = 0; _appliedH = 0;
                highwayCanvas = null;
            },

            // ── Module MIDI router surface (focused-instance dispatch) ──
            _handleNoteOn,
            _handleNoteOff,
            _handleSustain,
            _releaseAllHeld,

            // ── Headless test hooks (window.__keysHwTest) ───────────────
            _injectNoteOn(midi, when) {
                // Behaves like a device note-on of `midi` at song-time
                // `when` (defaults to the last drawn bundle time).
                _handleNoteOn(midi, 100, when);
            },
            _getScore() {
                return {
                    hits: _hits,
                    misses: _misses,
                    streak: _streak,
                    bestStreak: _bestStreak,
                    accuracy: accuracyOf(_hits, _misses),
                    score: scoreOf(_hits, _misses),
                };
            },
        };
        return instance;
    }

    /* ======================================================================
     *  Register
     * ====================================================================== */

    window.slopsmithViz_keys_highway_3d = createFactory;
    // slopsmith→feedBack rename: host viz picker looks up `window.feedBackViz_<id>`.
    window.feedBackViz_keys_highway_3d = window.slopsmithViz_keys_highway_3d;
    // Static contextType so core can make canvas-swap decisions before
    // constructing a renderer instance (and gate Auto on WebGL2).
    window.slopsmithViz_keys_highway_3d.contextType = 'webgl2';
    // Auto-select on arrangements that carry notation (sloppak-spec §5.3).
    // Predicates receive the raw song_info wire frame — snake_case field.
    // Directory name `keys_highway_3d` sorts before `piano` and `staffview`
    // so Auto prefers the 3D highway when all three match.
    window.slopsmithViz_keys_highway_3d.matchesArrangement = function (songInfo) {
        return !!(songInfo && songInfo.has_notation);
    };
    // Pure data-layer + scoring hooks for headless tests.
    window.slopsmithViz_keys_highway_3d.__test = {
        _resolveFreeCam,
        _ssApi,
        beatDurSec,
        flattenNotation,
        keyRange,
        measureMarkers,
        noteLetter,
        scrollZ,
        noteKey,
        accuracyOf,
        scoreOf,
        judgeHit,
        sweepMissed,
        readFxSettings,
        readThemeSetting,
        readBgStyleSetting,
        readPaletteSetting,
        readCameraSetting,
        readSharpModeSetting,
        SHARP_MODES,
        _bgThemeColors,
        BG_THEMES,
        BG_STYLE_IDS,
        NOTE_PALETTES,
        PITCH_CLASS_COLORS,
        PALETTE_IDS,
        OCTAVE_HUES,
        octaveNoteColor,
        _isBlackPc,
        laneSpanFlat,
        laneSpanReal,
        CAM_PRESETS,
        FX_DEFAULTS,
        FX_RANGES,
        _classifyTiming,
        _pickMidiTarget,
    };

    // Headless verification hook: lets Playwright drive synthetic note-ons
    // through the full hit-detection + feedback path and read the live
    // score without a physical MIDI device.
    window.__keysHwTest = {
        injectNoteOn(midi, when) {
            if (_activeInstance && typeof _activeInstance._injectNoteOn === 'function') {
                _activeInstance._injectNoteOn(midi, when);
            }
        },
        getScore() {
            return (_activeInstance && typeof _activeInstance._getScore === 'function')
                ? _activeInstance._getScore() : null;
        },
    };
})();
