// 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 colours (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 colours (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 — colour stays the // pitch class. const HAND_BRIGHTNESS = { rh: 1.0, lh: 0.72 }; // 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 colour; the material // multiplies its colour 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 || '' })); } function _midiAutoConnect(allowFallback) { // Recovery (sources-changed after unplug) passes false: never switch to a // fallback input, because _midiConnect persists the pick and that would // overwrite the user's saved device on a transient multi-device unplug // (the original returns on replug and reconnects then). if (allowFallback === undefined) allowFallback = true; const inputs = _midiSources(); if (!inputs.length) return; const saved = _readSavedPick(); // Explicit "None" opt-out. if (saved && saved.id === '' && saved.name === '') return; // Prefer the globally-unique logicalSourceKey, then the legacy bare // sourceId, then case-insensitive name (Chrome on Linux regenerates ids // per page load), then first non-loopback. 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's a loopback / "Midi Through" port — it // carries no device input, so a stale pick silently eats every note. The // saved-pick lookups above bypass the block-list; re-apply it here. if (target && _MIDI_BLOCKLIST_RE.test(target.name || '')) target = null; if (!target) { // Skip the substitute ONLY when a saved pick exists but is currently // absent (recovery: preserve it, don't clobber on a transient unplug). // With no saved pick at all, a fallback is the intended first-hotplug // auto-connect — allow it even in recovery. const hasSavedPick = !!(saved && (saved.key || saved.id || saved.name)); if (!allowFallback && hasSavedPick) return; target = inputs.find(i => !_MIDI_BLOCKLIST_RE.test(i.name || '')) || inputs[0]; } _midiConnect(target.id, target.name, target.key); } async function _midiConnect(id, name, key) { // 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(); } _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; // 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). The pick is saved; a later renderer // mount re-runs auto-connect and opens for real, releasing on destroy. if (_instances.size === 0) { _midiNotifyDeviceListChanged(); return; } try { await mi.select(lkey); 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. 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 : ''); 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) }; 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'; // 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 */ } }; 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); // All numeric FX keys are 0-1 sliders — clamp so a // corrupt/foreign write can't overdrive opacities // or the camera pulse. if (Number.isFinite(n)) fx[k] = Math.min(1, Math.max(0, 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 = Math.min(1, Math.max(0, v)); // all numeric FX keys are 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. 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; } /* ====================================================================== * 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; // 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 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 // ── 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 noteColor(midi, hand) { const base = new T.Color(PITCH_CLASS_COLORS[((midi % 12) + 12) % 12]); 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 // Camera — 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. const CAM_FOV = 40; const CAM_Y = 46 * K, CAM_Z = 112 * K; const LOOK_Y = 8 * K, LOOK_Z = -165 * K; // 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 // Per-key approach glow: a key lights in its pitch-class colour 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; } // 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(CAM_FOV, 1, 0.1, 2000 * K); _camX = 0; _camTargetX = 0; _camZoom = 1; _camTargetZoom = 1; cam.position.set(0, CAM_Y, CAM_Z); cam.lookAt(0, LOOK_Y, LOOK_Z); 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); } } // 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 colours (bottom shade → // top highlight) so the gem reads 3D; the material multiplies its // pitch-class colour 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 (pitch class, hand). function _noteMaterial(midi, hand) { const handKey = HAND_BRIGHTNESS[hand] != null ? hand : 'rh'; const key = (((midi % 12) + 12) % 12) + '|' + handKey; let mat = _noteMatCache.get(key); if (mat) return mat; const col = noteColor(midi, hand); // 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 colour 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() { return 0.10 + 0.12 * Math.min(1, Math.max(0, fx.vibrancy)); } // 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; } 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: white-hot base fading up // into the pitch-class colour, with a horizontal falloff. Cached per // pitch class (bounded, 12 entries). function _flameTexture(pc) { let tex = _flameTexCache.get(pc); if (tex) return tex; const c = PITCH_CLASS_COLORS[pc]; 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(pc, 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 % 12) + 12) % 12); 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 colour strip running up the runway from each // active key, in that key's pitch-class colour. A falling note shares // its target key's colour, 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 for (const [midi, entry] of layout) { if (midi < range.activeLow || midi > range.activeHigh) continue; if (entry.black) continue; // one strip per semitone-slot lands on whites const gmat = new T.MeshBasicMaterial({ color: noteColor(midi, 'rh'), transparent: true, opacity: _laneGuideOpacity(), depthWrite: false, }); _laneGuideMats.push(gmat); const strip = new T.Mesh(new T.PlaneGeometry(WHITE_W * 0.84, guideLen), gmat); strip.rotation.x = -Math.PI / 2; strip.position.set(keyX(entry, whiteCount), laneY, hitZ - guideLen / 2); keyboardGroup.add(strip); // Thin brighter rails at the lane edges for crisp separation. const railMat = new T.MeshBasicMaterial({ color: (() => { const c = _bgThemeColors(_theme); return c.laneDim != null ? c.laneDim : 0x2a2a3e; })(), transparent: true, opacity: 0.5, depthWrite: false, }); _railMats.push(railMat); const rail = new T.Mesh(new T.PlaneGeometry(0.6 * K, guideLen), railMat); rail.rotation.x = -Math.PI / 2; rail.position.set(keyX(entry, whiteCount) - WHITE_W / 2, laneY + 0.05 * K, hitZ - guideLen / 2); keyboardGroup.add(rail); } // 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 colour 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); const w = (entry.black ? BLACK_W : WHITE_W * 0.94) * 0.9; // 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 = keyX(entry, whiteCount); mesh.position.y = (entry.black ? BLACK_H + WHITE_H : WHITE_H) + NOTE_H / 2 + 0.5 * K; 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 ("|"), 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; _ndReport(true, playedMidi, _ndBindingId); } else { _misses++; _streak = 0; 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) { _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; cam.position.set(_camX, CAM_Y * _camZoom, CAM_Z * _camZoom); cam.lookAt(_camX, LOOK_Y * _camZoom, LOOK_Z * _camZoom); 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 = `
${_streak}× combo
` + `
${_hits}/${total} (${pct}%)
` + (_bestStreak ? `
best ${_bestStreak}
` : ''); } 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(); } 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(); _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 theme saved // while no instance was listening must not come up stale on a // later init(). _theme = readThemeSetting(); 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 ('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(); } }; 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); } 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; } _refreshHud(); // 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() 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_`. 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 = { beatDurSec, flattenNotation, keyRange, measureMarkers, noteLetter, scrollZ, noteKey, accuracyOf, scoreOf, judgeHit, sweepMissed, readFxSettings, readThemeSetting, _bgThemeColors, BG_THEMES, FX_DEFAULTS, _classifyTiming, }; // 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; }, }; })();