mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-07-22 21:01:40 +00:00
Opening the keys highway could silently switch the user's configured MIDI device. Two coupled defects in the plugin's MIDI selection: 1. _midiAutoConnect only consulted the plugin's own localStorage pick (keys3d_midi_pick); with none saved it fell straight through to "first non-loopback device", ignoring the core midi-input domain's global selection (Settings -> Input Setup, window.slopsmith.midiInput.getSelected()). 2. _midiConnect unconditionally persisted every connect to BOTH the local pick and the shared domain selection (mi.select). So the first-device guess got frozen locally and overwrote the global default that other consumers (drums, Input Setup) rely on. Make the domain-wide selection the source of truth: _pickMidiTarget now resolves global -> legacy local pick (fallback + name-recovery for stale ids) -> first device, and gates the "don't grab a random device" recovery guard on any configured preference. Gate persistence behind an explicit `persist` flag so only a deliberate device selection writes the local pick and the shared global; auto-connect and programmatic (audio-input) opens open the resolved device for the session without touching either store. mi.select() is not needed to open (open takes the logicalSourceKey directly), so dropping it from the auto path costs nothing. Interim step toward instrument-scoped selection in the midi-input domain itself (the input_setup wizard is already per-instrument, but the domain stores a single selection); tracked as a separate core follow-up. Pure decision logic extracted to _pickMidiTarget and covered by unit tests in data_layer.test.js. Signed-off-by: gionnibgud <gionnibgud@gmail.com> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
4141 lines
206 KiB
JavaScript
4141 lines
206 KiB
JavaScript
// Keys Highway 3D — RS+-style falling-note piano highway.
|
||
//
|
||
// Notation-format-fed (sloppak-spec §5.3): consumes the `notation_info` +
|
||
// `notation_measures` highway-WS stream over a private per-instance socket
|
||
// (same pattern as Staff View) and flattens measure → staff → voice → beat →
|
||
// note into a falling-note list `{midi, t, durSec, hand}`. No guitar-wire
|
||
// `midi = s*24+f` indirection — that legacy path stays with the 2D piano
|
||
// plugin.
|
||
//
|
||
// Visual contract is the frame analysis on slopsmith#824 (RS+ reference):
|
||
// 3D perspective highway to a vanishing point, notes landing on a real 3D
|
||
// keyboard, per-key Synthesia-style PITCH-CLASS colors (hand is only a
|
||
// secondary brightness cue), active-range key highlighting with letters,
|
||
// a glowing hit-line, bevelled cuboid notes sized by durSec, floating bar
|
||
// numbers, and key-depress + flame feedback driven by the LIVE MIDI input
|
||
// path (not the chart).
|
||
//
|
||
// Reuses verbatim from highway_3d / drum_highway_3d: Three.js loader, world
|
||
// scale (K), fog and light rig — so the three highways read as one family.
|
||
|
||
(function () {
|
||
'use strict';
|
||
|
||
/* ======================================================================
|
||
* Verbatim from highway_3d — keep in sync if upstream tweaks them
|
||
* ====================================================================== */
|
||
|
||
const THREE_URL = '/static/vendor/three/three.module.min.js';
|
||
const THREE_CDN = 'https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.min.js';
|
||
|
||
const SCALE = 2.25;
|
||
const K = SCALE / 300;
|
||
|
||
const FOG_COLOR = 0x1a1a2e;
|
||
const FOG_START = 850 * K; // fog pushed past the runway end — far notes stay
|
||
const FOG_END = 1600 * K; // visible, only the extreme distance fades
|
||
|
||
let T = null;
|
||
let threeLoadPromise = null;
|
||
function loadThree() {
|
||
if (!threeLoadPromise) {
|
||
threeLoadPromise = import(THREE_URL)
|
||
.then(mod => { T = mod; return mod; })
|
||
.catch(() => import(THREE_CDN)
|
||
.then(mod => { T = mod; return mod; })
|
||
.catch(e => {
|
||
console.error('[Keys-Hwy3D] Three.js load failed:', e);
|
||
threeLoadPromise = null;
|
||
throw e;
|
||
}));
|
||
}
|
||
return threeLoadPromise;
|
||
}
|
||
|
||
/* ======================================================================
|
||
* Keys-specific constants
|
||
* ====================================================================== */
|
||
|
||
// World scroll speed (units / second) — matches the sibling highways.
|
||
const TS = 130 * K;
|
||
|
||
// Per-pitch-class colors (Synthesia convention observed in the RS+
|
||
// reference frames: C=red, D=yellow, E=blue, F=light blue-grey, …).
|
||
// Index = midi % 12 (C, C#, D, …, B). Sharps take a dimmed blend of
|
||
// their neighbours so black-key notes stay distinguishable.
|
||
const PITCH_CLASS_COLORS = [
|
||
0xff3030, // C — red
|
||
0xb04818, // C#
|
||
0xffd400, // D — yellow
|
||
0x9aa018, // D#
|
||
0x2870ff, // E — blue
|
||
0x8fb8c8, // F — light blue-grey
|
||
0x4a7a68, // F#
|
||
0x30c050, // G — green
|
||
0x807838, // G#
|
||
0xff8020, // A — orange
|
||
0xa05870, // A#
|
||
0xc050ff, // B — violet
|
||
];
|
||
|
||
// Hand cue is SECONDARY (slopsmith#824 design call): right hand renders
|
||
// at full brightness, left hand slightly darkened — color stays the
|
||
// pitch class.
|
||
const HAND_BRIGHTNESS = { rh: 1.0, lh: 0.72 };
|
||
|
||
// Selectable note-color palettes. Index = midi % 12, same contract as
|
||
// PITCH_CLASS_COLORS — which stays byte-identical as the 'classic'
|
||
// entry, so anyone who never touches the setting sees the stock look.
|
||
// Two palette families:
|
||
// per-pitch — every pitch class gets its own hue (classic/vivid/pastel)
|
||
// two-tone — naturals share one hue, sharps a darker shade of it, so
|
||
// a dark gem always telegraphs "black key coming"
|
||
// (emerald/ice)
|
||
const NOTE_PALETTES = {
|
||
classic: PITCH_CLASS_COLORS,
|
||
emerald: [
|
||
0x3fe25f, // C — bright green (naturals)
|
||
0x17863a, // C# — dark green (sharps)
|
||
0x3fe25f, // D
|
||
0x17863a, // D#
|
||
0x3fe25f, // E
|
||
0x3fe25f, // F
|
||
0x17863a, // F#
|
||
0x3fe25f, // G
|
||
0x17863a, // G#
|
||
0x3fe25f, // A
|
||
0x17863a, // A#
|
||
0x3fe25f, // B
|
||
],
|
||
vivid: [
|
||
0xff2020, // C
|
||
0xd45a10, // C#
|
||
0xffe000, // D
|
||
0xb8c010, // D#
|
||
0x1e6aff, // E
|
||
0x9fd0e8, // F
|
||
0x2fae7e, // F#
|
||
0x20e050, // G
|
||
0xa89a20, // G#
|
||
0xff8a00, // A
|
||
0xd05888, // A#
|
||
0xd040ff, // B
|
||
],
|
||
pastel: [
|
||
0xff9a9a, // C
|
||
0xd0a078, // C#
|
||
0xffe9a0, // D
|
||
0xcfd08a, // D#
|
||
0x9ec0ff, // E
|
||
0xc8dde8, // F
|
||
0x9ecfba, // F#
|
||
0x9fe8b0, // G
|
||
0xcfc79a, // G#
|
||
0xffc890, // A
|
||
0xd8a8ba, // A#
|
||
0xe0b0ff, // B
|
||
],
|
||
ice: [
|
||
0x58c8ff, // C — bright ice blue (naturals)
|
||
0x2a6a9a, // C# — deep blue (sharps)
|
||
0x58c8ff, // D
|
||
0x2a6a9a, // D#
|
||
0x58c8ff, // E
|
||
0x58c8ff, // F
|
||
0x2a6a9a, // F#
|
||
0x58c8ff, // G
|
||
0x2a6a9a, // G#
|
||
0x58c8ff, // A
|
||
0x2a6a9a, // A#
|
||
0x58c8ff, // B
|
||
],
|
||
};
|
||
|
||
// Octave-based color scheme ('octaves'): every octave gets a distinct
|
||
// hue that steps like a rainbow (clear, uniform sections — NOT a smooth
|
||
// blend — so each octave is uniquely identifiable, but neighbouring
|
||
// octaves stay close so the change isn't jarring). Loops if a song runs
|
||
// past the table. Within an octave, sharps/flats take a darker shade of
|
||
// the same hue — the same two-tone idea as 'emerald'. Octave index =
|
||
// floor(midi/12) - 1 (so C1..B1 = octave 1). The three keys below C1
|
||
// (A0/A#0/B0 = octave 0) and anything lower get a distinct cool slate so
|
||
// the very bottom of the board reads apart from the red start.
|
||
const OCTAVE_HUES = [
|
||
0xe23a3a, // oct 1 (C1–B1) red
|
||
0xe2803a, // oct 2 orange
|
||
0xe0c73a, // oct 3 yellow
|
||
0x5fc23a, // oct 4 green
|
||
0x3ac2a0, // oct 5 teal
|
||
0x3a86e2, // oct 6 blue
|
||
0x6a4ae2, // oct 7 indigo
|
||
0xc23ae2, // oct 8 (C8) magenta
|
||
];
|
||
const OCTAVE_SUBC1_HUE = 0x8090a0; // A0/A#0/B0 and below — cool slate
|
||
const OCTAVE_SHARP_DARKEN = 0.5; // sharps render at 50% of the octave hue
|
||
function _darkenHex(hex, f) {
|
||
const r = Math.round(((hex >> 16) & 0xff) * f);
|
||
const g = Math.round(((hex >> 8) & 0xff) * f);
|
||
const b = Math.round((hex & 0xff) * f);
|
||
return (r << 16) | (g << 8) | b;
|
||
}
|
||
function _isBlackPc(midi) {
|
||
return [1, 3, 6, 8, 10].indexOf(((midi % 12) + 12) % 12) !== -1;
|
||
}
|
||
// Which way a sharp leans to even out the naturals: toward the EDGE
|
||
// natural next to it. +1 = up (toward the higher natural), −1 = down, 0 =
|
||
// centred. C#/F# sit below an inner natural so they lean down to C/F;
|
||
// D#/A# lean up to E/B; G# has an inner natural on both sides, so it can't
|
||
// lean and stays put.
|
||
function _sharpLeanDir(pc) {
|
||
if (pc === 1 || pc === 6) return -1; // C#, F#
|
||
if (pc === 3 || pc === 10) return 1; // D#, A#
|
||
return 0; // G#
|
||
}
|
||
// Floor span [left,right] of a key's lane in the FLAT (piano-shaped)
|
||
// layout, in world units, given the key's centre x (`cx`). Pure/isolated
|
||
// on purpose — this ONE function defines the layout, so a variant is a
|
||
// one-function swap. Zero-overlap tiling: a white lane is trimmed by
|
||
// `sharpHalf` wherever it meets a sharp, and the sharp fills that gap. Each
|
||
// sharp is nudged `shift` toward the edge natural beside it (see
|
||
// _sharpLeanDir), which steals a sliver from that edge natural and widens
|
||
// the squeezed inner natural — at shift = sharpHalf/3 the C-D-E-F-B
|
||
// naturals come out equal. Lanes still tile edge-to-edge (no overlap, no
|
||
// gap). With `gaps`, each B→C octave boundary opens an extra `octGap`
|
||
// divider by shaving half of it off the B and the C (naturals only).
|
||
// `range`, when given, gates the trim to a neighbouring sharp that is
|
||
// itself inside `range.activeLow..range.activeHigh`. A white key at the
|
||
// active-range boundary (see the `midi < range.activeLow ||
|
||
// midi > range.activeHigh` skip around the lane-strip loop) may sit next
|
||
// to a sharp pitch-class that falls just outside the active range — that
|
||
// sharp's lane is never drawn, so trimming the white key's edge for it
|
||
// leaves a dark, unfilled sliver. Gating on range keeps that edge full
|
||
// while leaving the normal (fully in-range) zero-overlap tiling intact.
|
||
// Callers that don't pass `range` (e.g. the unit tests exercising raw
|
||
// tiling geometry) keep the unconditional trim.
|
||
function laneSpanFlat(midi, black, cx, dims, gaps, range) {
|
||
const { whiteW, sharpHalf, shift, octGap } = dims;
|
||
if (black) {
|
||
const c = cx + _sharpLeanDir(((midi % 12) + 12) % 12) * shift;
|
||
return { left: c - sharpHalf, right: c + sharpHalf };
|
||
}
|
||
const neighborActive = (m) => !range || (m >= range.activeLow && m <= range.activeHigh);
|
||
// White: each side that meets a sharp is trimmed to that (leaned) sharp's
|
||
// near edge; a side that meets another white keeps the half-slot edge.
|
||
let left = cx - whiteW / 2;
|
||
let right = cx + whiteW / 2;
|
||
if (_isBlackPc(midi - 1) && neighborActive(midi - 1)) {
|
||
const bc = (cx - whiteW / 2) + _sharpLeanDir(((midi - 1) % 12 + 12) % 12) * shift;
|
||
left = bc + sharpHalf;
|
||
}
|
||
if (_isBlackPc(midi + 1) && neighborActive(midi + 1)) {
|
||
const bc = (cx + whiteW / 2) + _sharpLeanDir(((midi + 1) % 12 + 12) % 12) * shift;
|
||
right = bc - sharpHalf;
|
||
}
|
||
const pc = ((midi % 12) + 12) % 12;
|
||
if (gaps) {
|
||
if (pc === 11) right -= octGap / 2; // B: gap on its right (→ C)
|
||
if (pc === 0) left += octGap / 2; // C: gap on its left (← B)
|
||
}
|
||
return { left, right };
|
||
}
|
||
// 'realistic' layout span: every bar sized to the physical key it lands on.
|
||
// Naturals are the same full width (2·natHalf) centred on the key; sharps are
|
||
// the full black-key width (2·sharpHalf) at their standard half-slot, which
|
||
// makes them overlap — the caller draws sharps on top. A natural therefore
|
||
// always renders full and is only covered where a sharp note actually
|
||
// coincides in time. `gaps` widens the B→C divider (naturals only).
|
||
function laneSpanReal(midi, black, cx, dims, gaps) {
|
||
const half = black ? dims.sharpHalf : dims.natHalf;
|
||
let left = cx - half, right = cx + half;
|
||
if (gaps && !black) {
|
||
const pc = ((midi % 12) + 12) % 12;
|
||
if (pc === 11) right -= dims.octGap / 2;
|
||
if (pc === 0) left += dims.octGap / 2;
|
||
}
|
||
return { left, right };
|
||
}
|
||
// Color (24-bit int) for a midi note under the octave scheme: hue by
|
||
// octave, darker for sharps. Pure (no THREE) so it is unit-testable.
|
||
function octaveNoteColor(midi) {
|
||
const oct = Math.floor(midi / 12) - 1; // C1..B1 => 1
|
||
let hex = (oct <= 0)
|
||
? OCTAVE_SUBC1_HUE
|
||
: OCTAVE_HUES[(oct - 1) % OCTAVE_HUES.length];
|
||
if (_isBlackPc(midi)) hex = _darkenHex(hex, OCTAVE_SHARP_DARKEN);
|
||
return hex;
|
||
}
|
||
// Every valid palette id: the 12-entry pitch-class tables PLUS the
|
||
// procedural 'octaves' scheme (which is not a 12-array, so it lives
|
||
// outside NOTE_PALETTES and is validated through this list).
|
||
const PALETTE_IDS = [...Object.keys(NOTE_PALETTES), 'octaves'];
|
||
|
||
// Note block cross-section height and bevel (world units). The bevel
|
||
// turns the flat slabs into glossy gem-like blocks that catch the light
|
||
// on their edges — the RS+ reference look.
|
||
const NOTE_H = 4 * K;
|
||
const NOTE_BEVEL = 0.55 * K;
|
||
// Resting note glow. Raised from the original 0.08 in the hit-FX parity
|
||
// pass — combined with the vibrancy-driven opacity it fixes the
|
||
// washed-out, semi-transparent look the note gems had.
|
||
const NOTE_EMISSIVE_BASE = 0.22;
|
||
const CONSUME_GLOW = 5.0; // peak glow as a note is eaten at the hit-line
|
||
const LABEL_FADE_DIST = 80 * K; // note-name fades out over this distance past the hit-line (~0.6s)
|
||
// Gem vertical gradient (bottom shade → top highlight), baked per-vertex into
|
||
// the note geometry so a block reads as a lit 3D gem instead of a flat fill —
|
||
// same approach as the bundled guitar highway_3d (`gNoteGrad`). The ramp is
|
||
// greyscale so one geometry serves every pitch-class color; the material
|
||
// multiplies its color by it via vertexColors.
|
||
const GEM_SHADE_BOT = 0.12, GEM_SHADE_TOP = 1.1; // strong gem gradient (top slightly blows toward a highlight)
|
||
|
||
const NOTE_NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
|
||
|
||
// Key-range padding.
|
||
const RANGE_PAD = 2; // semitones of plain keys beyond the active range
|
||
|
||
const NOTATION_CHUNK_TIMEOUT_MS = 20000;
|
||
|
||
/* ======================================================================
|
||
* MIDI scoring constants (mirrors the 2D piano plugin)
|
||
* ====================================================================== */
|
||
|
||
// ±100 ms hit window — same as the 2D piano plugin's HIT_TOLERANCE so
|
||
// users get identical timing across both keys visualisations.
|
||
const HIT_TOLERANCE_S = 0.10;
|
||
|
||
// Persisted settings, `keys3d_` prefix (piano plugin STORE_KEYS pattern).
|
||
const STORE_KEYS = {
|
||
midiPick: 'keys3d_midi_pick', // {id, name} JSON (drum-h3d v2 pattern)
|
||
midiChannel: 'keys3d_midi_ch', // -1 = all channels
|
||
transpose: 'keys3d_transpose', // semitones added to incoming notes
|
||
};
|
||
|
||
// Inputs whose name matches this are skipped by auto-connect (loopbacks
|
||
// and passthroughs, same blocklist as drum_highway_3d).
|
||
const _MIDI_BLOCKLIST_RE = /midi through|^thru\b|^iac\b/i;
|
||
|
||
// Key-depress feedback: ~4° tilt around the key's back edge, settled in
|
||
// ~120 ms (exponential spring, tau ≈ 30 ms → 98% in 4τ).
|
||
const KEY_PRESS_ANGLE = Math.PI / 45;
|
||
const KEY_PRESS_TAU_MS = 30;
|
||
|
||
// Wrong-note red key flash duration.
|
||
const WRONG_FLASH_MS = 250;
|
||
|
||
// Flame flare lifetime + pool size (pooled additive sprites — no
|
||
// per-hit allocations).
|
||
const FLAME_MS = 400;
|
||
const FLAME_POOL_SIZE = 24;
|
||
|
||
// Capability identity for the note-detection / audio-input domains.
|
||
const ND_PROVIDER_ID = 'keys-midi';
|
||
const PLUGIN_ID = 'keys_highway_3d';
|
||
|
||
/* ======================================================================
|
||
* Notation data layer (pure — exported via createFactory.__test)
|
||
* ====================================================================== */
|
||
|
||
// Derive a beat's sounding duration in seconds from its written duration
|
||
// and the running tempo: base = (60/tempo) * (4/dur), dots multiply by
|
||
// 2 − 2^(−dot), tuplets by tu[1]/tu[0].
|
||
function beatDurSec(beat, tempo) {
|
||
const dur = Number(beat && beat.dur);
|
||
if (!Number.isFinite(dur) || dur <= 0 || !Number.isFinite(tempo) || tempo <= 0) return null;
|
||
const base = (60 / tempo) * (4 / dur);
|
||
const dot = Number(beat.dot) || 0;
|
||
const dotFactor = 2 - Math.pow(2, -dot);
|
||
const tu = Array.isArray(beat.tu) && beat.tu.length === 2
|
||
&& Number(beat.tu[0]) > 0 && Number(beat.tu[1]) > 0
|
||
? Number(beat.tu[1]) / Number(beat.tu[0]) : 1;
|
||
return base * dotFactor * tu;
|
||
}
|
||
|
||
// Flatten accumulated notation (info + measures) into a render-ready,
|
||
// time-sorted note list: [{midi, t, durSec, hand, measureIdx}].
|
||
//
|
||
// - `hand` = staff id (rh/lh per sloppak-spec; any other staff id is
|
||
// passed through and rendered with rh brightness).
|
||
// - durSec: derived from dur/dot/tu at the running tempo (tempo state
|
||
// carries across measures; measures may omit `tempo` when unchanged).
|
||
// Tied notes (`tied: true`) extend the previous sounding note of the
|
||
// same midi in the same staff+voice instead of emitting a new block.
|
||
// Fallback when no tempo has been seen: gap to the next onset in the
|
||
// same voice (or +2s for the final beat).
|
||
// Sanity clamp: never longer than the gap to the next same-staff
|
||
// same-midi onset (prevents overlap artifacts from tempo-map drift);
|
||
// floor at 0.05s so 32nds stay visible.
|
||
// - Grace beats (typed `grace` field) render as ordinary short notes at
|
||
// their written duration — good enough for a falling-note highway.
|
||
function flattenNotation(measures) {
|
||
const out = [];
|
||
let tempo = null;
|
||
// staff|voice → array of beats (for next-onset fallback + ties)
|
||
const lastNoteByKey = new Map(); // `${staff}|${voice}|${midi}` → out[] index
|
||
|
||
// First pass: collect every beat with its staff/voice context in
|
||
// measure order so next-onset lookups can be done per voice.
|
||
const beatsByVoice = new Map(); // `${staff}|${voice}` → [{beat, measure}]
|
||
for (const measure of Array.isArray(measures) ? measures : []) {
|
||
if (!measure || typeof measure !== 'object') continue;
|
||
const staves = measure.staves && typeof measure.staves === 'object' ? measure.staves : {};
|
||
for (const staffId of Object.keys(staves)) {
|
||
const staff = staves[staffId];
|
||
const voices = staff && Array.isArray(staff.voices) ? staff.voices : [];
|
||
for (const voice of voices) {
|
||
if (!voice || !Array.isArray(voice.beats)) continue;
|
||
const vid = `${staffId}|${voice.v != null ? voice.v : 1}`;
|
||
if (!beatsByVoice.has(vid)) beatsByVoice.set(vid, []);
|
||
const list = beatsByVoice.get(vid);
|
||
for (const beat of voice.beats) {
|
||
if (!beat || typeof beat !== 'object') continue;
|
||
list.push({ beat, measure, staffId, vid });
|
||
}
|
||
}
|
||
}
|
||
}
|
||
for (const list of beatsByVoice.values()) {
|
||
list.sort((a, b) => (Number(a.beat.t) || 0) - (Number(b.beat.t) || 0));
|
||
}
|
||
|
||
// Second pass in global measure order so the tempo state machine sees
|
||
// tempo changes when they happen.
|
||
for (const measure of Array.isArray(measures) ? measures : []) {
|
||
if (!measure || typeof measure !== 'object') continue;
|
||
const mTempo = Number(measure.tempo);
|
||
if (Number.isFinite(mTempo) && mTempo > 0) tempo = mTempo;
|
||
const measureIdx = Number(measure.idx) || 0;
|
||
const staves = measure.staves && typeof measure.staves === 'object' ? measure.staves : {};
|
||
for (const staffId of Object.keys(staves)) {
|
||
const staff = staves[staffId];
|
||
const voices = staff && Array.isArray(staff.voices) ? staff.voices : [];
|
||
for (const voice of voices) {
|
||
if (!voice || !Array.isArray(voice.beats)) continue;
|
||
const vid = `${staffId}|${voice.v != null ? voice.v : 1}`;
|
||
const voiceBeats = beatsByVoice.get(vid) || [];
|
||
for (const beat of voice.beats) {
|
||
if (!beat || typeof beat !== 'object' || beat.rest) continue;
|
||
const t = Number(beat.t);
|
||
if (!Number.isFinite(t)) continue;
|
||
const notes = Array.isArray(beat.notes) ? beat.notes : [];
|
||
let durSec = beatDurSec(beat, tempo);
|
||
if (durSec == null) {
|
||
// No tempo seen — fall back to next onset in voice.
|
||
const i = voiceBeats.findIndex(e => e.beat === beat);
|
||
const next = i >= 0 ? voiceBeats.slice(i + 1).find(e => !e.beat.rest) : null;
|
||
durSec = next ? Math.max(0.05, Number(next.beat.t) - t) : 2.0;
|
||
}
|
||
for (const note of notes) {
|
||
if (!note || typeof note !== 'object') continue;
|
||
const midi = Number(note.midi);
|
||
if (!Number.isFinite(midi) || midi < 0 || midi > 127) continue;
|
||
const key = `${vid}|${midi}`;
|
||
if (note.tied && lastNoteByKey.has(key)) {
|
||
// Extend the previous sounding note; no new block.
|
||
const prev = out[lastNoteByKey.get(key)];
|
||
prev.durSec = Math.max(prev.durSec, (t - prev.t) + durSec);
|
||
continue;
|
||
}
|
||
out.push({
|
||
midi,
|
||
t,
|
||
durSec: Math.max(0.05, durSec),
|
||
hand: staffId,
|
||
measureIdx,
|
||
});
|
||
lastNoteByKey.set(key, out.length - 1);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
out.sort((a, b) => a.t - b.t || a.midi - b.midi);
|
||
|
||
// Overlap clamp: a note never rings past the next onset of the same
|
||
// midi on the same hand.
|
||
const lastByHandMidi = new Map();
|
||
for (let i = 0; i < out.length; i++) {
|
||
const note = out[i];
|
||
const key = `${note.hand}|${note.midi}`;
|
||
if (lastByHandMidi.has(key)) {
|
||
const prev = out[lastByHandMidi.get(key)];
|
||
if (prev.t + prev.durSec > note.t) {
|
||
prev.durSec = Math.max(0.05, note.t - prev.t);
|
||
}
|
||
}
|
||
lastByHandMidi.set(key, i);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
// Active key range with padding, clamped to the 88-key piano (A0–C8).
|
||
// `activeLow`/`activeHigh` are the actual chart extremes (the keys that
|
||
// tint, print their letter, and keep bright lanes); `low`/`high` add
|
||
// RANGE_PAD semitones of plain keys around them. Keeping the active span
|
||
// explicit (instead of re-deriving it as low+RANGE_PAD) stays correct at
|
||
// the 88-key clamp edges.
|
||
function keyRange(notes) {
|
||
if (!notes.length) {
|
||
// Empty chart: render a neutral two-octave keyboard with an
|
||
// empty active span (activeLow > activeHigh — nothing lights).
|
||
return { low: 48, high: 72, activeLow: 60, activeHigh: 59 };
|
||
}
|
||
let low = 127, high = 0;
|
||
for (const n of notes) {
|
||
if (n.midi < low) low = n.midi;
|
||
if (n.midi > high) high = n.midi;
|
||
}
|
||
return {
|
||
low: Math.max(21, low - RANGE_PAD),
|
||
high: Math.min(108, high + RANGE_PAD),
|
||
activeLow: low,
|
||
activeHigh: high,
|
||
};
|
||
}
|
||
|
||
// Measure markers [{idx, t}] for floating bar numbers.
|
||
function measureMarkers(measures) {
|
||
const out = [];
|
||
for (const m of Array.isArray(measures) ? measures : []) {
|
||
if (!m || typeof m !== 'object') continue;
|
||
const t = Number(m.t);
|
||
const idx = Number(m.idx);
|
||
if (Number.isFinite(t) && Number.isFinite(idx)) out.push({ idx, t });
|
||
}
|
||
return out;
|
||
}
|
||
|
||
// Letter printed on a key top (C, C#, D, …) — pure.
|
||
function noteLetter(midi) {
|
||
return NOTE_NAMES[((midi % 12) + 12) % 12];
|
||
}
|
||
|
||
// World-space Z of a chart event at song-time `now`: the event sits at
|
||
// `hitZ` exactly when now === eventT, approaching from -Z at `speed`
|
||
// world-units per second. Shared by the note blocks (front edge) and the
|
||
// floating bar-number sprites so they scroll in lockstep.
|
||
function scrollZ(eventT, now, hitZ, speed) {
|
||
return hitZ - (eventT - now) * speed;
|
||
}
|
||
|
||
/* ======================================================================
|
||
* Pure scoring logic (exported via createFactory.__test)
|
||
* ====================================================================== */
|
||
|
||
// Dedupe key for a chart note — same convention as the piano plugin's
|
||
// _noteKey (time quantised to ms so float drift can't double-count).
|
||
function noteKey(t, midi) {
|
||
return t.toFixed(3) + '|' + midi;
|
||
}
|
||
|
||
// Score/accuracy formula MUST mirror the guitar notedetect path
|
||
// (static/v3/stats-recorder.js, which itself mirrors lib/song_score.py):
|
||
// accuracy = hits / max(1, hits + misses) (0..1 fraction)
|
||
// score = round(hits * 100 * accuracy) (monotonic in both)
|
||
function accuracyOf(hits, misses) {
|
||
return hits / Math.max(1, hits + misses);
|
||
}
|
||
function scoreOf(hits, misses) {
|
||
return Math.round(hits * 100 * accuracyOf(hits, misses));
|
||
}
|
||
|
||
// Judge a played MIDI note against the time-sorted flattened note list
|
||
// (piano plugin _checkHit port, minus the guitar-wire chord path — keys
|
||
// chords are already flattened to simultaneous notes). Returns the
|
||
// dedupe key of the matched un-hit chart note, or null when the play is
|
||
// a wrong note / outside every window / a duplicate. The caller owns
|
||
// adding the key to hitKeys and all hit/miss bookkeeping.
|
||
function judgeHit(notes, playedMidi, t, hitKeys, tol) {
|
||
if (!Array.isArray(notes) || !notes.length) return null;
|
||
for (const n of notes) {
|
||
if (n.t > t + tol + 0.5) break;
|
||
if (n.t < t - tol - 0.5) continue;
|
||
if (n.midi !== playedMidi) continue;
|
||
if (Math.abs(n.t - t) > tol) continue;
|
||
const key = noteKey(n.t, n.midi);
|
||
if (hitKeys.has(key)) continue;
|
||
return key;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// Missed-note sweep (piano _updateMissedNotes, rebuilt on a monotonic
|
||
// cursor): walk notes whose window has fully elapsed and mark unhit
|
||
// ones as missed. `cursor` ({idx}) makes the sweep O(elapsed) and
|
||
// stall-proof — unlike a fixed look-back tail, a backgrounded tab or a
|
||
// multi-second render hitch can't let elapsed notes slip past
|
||
// uncounted (which would inflate accuracy for the rest of the run).
|
||
// `floor` excludes notes that elapsed before a MIDI device was
|
||
// connected so a mid-song connect doesn't retroactively count
|
||
// everything as missed (the cursor still advances past them). Calls
|
||
// onMiss(note) for each newly-missed note; returns the count.
|
||
function sweepMissed(notes, t, hitKeys, missedKeys, tol, floor, onMiss, cursor) {
|
||
if (!Array.isArray(notes)) return 0;
|
||
const cutoff = t - tol - 0.05;
|
||
let i = cursor ? cursor.idx : 0;
|
||
let count = 0;
|
||
for (; i < notes.length; i++) {
|
||
const n = notes[i];
|
||
if (n.t > cutoff) break;
|
||
// Notes at or before the mid-song connect instant must not be
|
||
// swept retroactively — a device connecting exactly as an onset
|
||
// passes should not count that onset as a miss.
|
||
if (floor != null && n.t <= floor) continue;
|
||
const key = noteKey(n.t, n.midi);
|
||
if (hitKeys.has(key) || missedKeys.has(key)) continue;
|
||
missedKeys.add(key);
|
||
count += 1;
|
||
if (onMiss) onMiss(n);
|
||
}
|
||
if (cursor) cursor.idx = i;
|
||
return count;
|
||
}
|
||
|
||
/* ======================================================================
|
||
* Notation fetch — private per-instance WS (Staff View pattern)
|
||
* ====================================================================== */
|
||
|
||
function fetchNotation(filename, arrangementIndex) {
|
||
return new Promise((resolve, reject) => {
|
||
let info = null;
|
||
const measures = [];
|
||
const url = (location.protocol === 'https:' ? 'wss://' : 'ws://')
|
||
+ location.host + '/ws/highway/' + encodeURIComponent(filename)
|
||
+ '?arrangement=' + encodeURIComponent(arrangementIndex);
|
||
let settled = false;
|
||
const ws = new WebSocket(url);
|
||
const timer = setTimeout(() => {
|
||
if (settled) return;
|
||
settled = true;
|
||
try { ws.close(); } catch (_) {}
|
||
reject(new Error('notation stream timed out'));
|
||
}, NOTATION_CHUNK_TIMEOUT_MS);
|
||
const finish = () => {
|
||
if (settled) return;
|
||
settled = true;
|
||
clearTimeout(timer);
|
||
try { ws.close(); } catch (_) {}
|
||
resolve({ info, measures });
|
||
};
|
||
ws.onmessage = (ev) => {
|
||
let msg = null;
|
||
try { msg = JSON.parse(ev.data); } catch (_) { return; }
|
||
if (!msg || typeof msg !== 'object') return;
|
||
if (msg.error) {
|
||
if (!settled) {
|
||
settled = true;
|
||
clearTimeout(timer);
|
||
try { ws.close(); } catch (_) {}
|
||
reject(new Error(String(msg.error)));
|
||
}
|
||
return;
|
||
}
|
||
if (msg.type === 'song_info' && !msg.has_notation) {
|
||
// Nothing to stream for this arrangement.
|
||
finish();
|
||
return;
|
||
}
|
||
if (msg.type === 'notation_info') {
|
||
info = msg;
|
||
if (!msg.total) finish();
|
||
return;
|
||
}
|
||
if (msg.type === 'notation_measures') {
|
||
for (const m of Array.isArray(msg.data) ? msg.data : []) measures.push(m);
|
||
if (info && measures.length >= (Number(msg.total) || 0)) finish();
|
||
return;
|
||
}
|
||
// `anchors` streams right after the notation block — if we see
|
||
// it, the notation section is over regardless of count.
|
||
if (msg.type === 'anchors' && info) finish();
|
||
};
|
||
ws.onerror = () => {
|
||
if (settled) return;
|
||
settled = true;
|
||
clearTimeout(timer);
|
||
reject(new Error('notation stream socket error'));
|
||
};
|
||
ws.onclose = () => { if (info) finish(); };
|
||
});
|
||
}
|
||
|
||
/* ======================================================================
|
||
* Texture helpers (CanvasTexture — lazy, cached, never per-frame)
|
||
* ====================================================================== */
|
||
|
||
// Key-top letter textures, cached module-level by letter+style. Bounded
|
||
// (12 pitch classes × 2 styles) and shared across factory instances;
|
||
// CanvasTexture GPU copies are re-uploaded transparently after a renderer
|
||
// dispose, so the cache survives teardown safely.
|
||
const _glyphTexCache = new Map();
|
||
function _glyphTexture(letter, dark) {
|
||
const key = letter + '|' + (dark ? 'd' : 'l');
|
||
let tex = _glyphTexCache.get(key);
|
||
if (tex) return tex;
|
||
const cnv = document.createElement('canvas');
|
||
cnv.width = 96;
|
||
cnv.height = 96;
|
||
const ctx = cnv.getContext('2d');
|
||
ctx.clearRect(0, 0, 96, 96);
|
||
ctx.font = '600 48px system-ui, sans-serif';
|
||
ctx.textAlign = 'center';
|
||
ctx.textBaseline = 'middle';
|
||
ctx.fillStyle = dark ? '#23232e' : '#e8e8f2';
|
||
ctx.fillText(letter, 48, 50);
|
||
tex = new T.CanvasTexture(cnv);
|
||
tex.anisotropy = 4;
|
||
_glyphTexCache.set(key, tex);
|
||
return tex;
|
||
}
|
||
|
||
/* ======================================================================
|
||
* Web MIDI input (module-scope singleton — one MIDI access per tab)
|
||
*
|
||
* Port of the piano plugin's MIDI block with drum_highway_3d's 3D-plugin
|
||
* refinements: id+name saved pick (Chrome regenerates ids per load),
|
||
* loopback blocklist, init-in-flight dedupe, and the _midiActive gate so
|
||
* an access promise resolving after destroy() can't wire a dead handler.
|
||
* ====================================================================== */
|
||
|
||
function _readStore(k) {
|
||
try { return localStorage.getItem(k); } catch (_) { return null; }
|
||
}
|
||
function _writeStore(k, v) {
|
||
try { localStorage.setItem(k, v); } catch (_) {}
|
||
}
|
||
|
||
const _cfg = {
|
||
midiChannel: parseInt(_readStore(STORE_KEYS.midiChannel) || '-1', 10),
|
||
transpose: parseInt(_readStore(STORE_KEYS.transpose) || '0', 10),
|
||
};
|
||
if (!Number.isFinite(_cfg.midiChannel)) _cfg.midiChannel = -1;
|
||
if (!Number.isFinite(_cfg.transpose)) _cfg.transpose = 0;
|
||
|
||
// MIDI is sourced from the core `midi-input` capability domain
|
||
// (window.slopsmith.midiInput) rather than a private requestMIDIAccess() —
|
||
// one device-access boundary shared with piano/drums/onboarding.
|
||
let _midiReady = false; // discover() has run
|
||
let _midiHandle = null; // live domain session handle (addListener/removeListener)
|
||
let _midiListener = null; // addListener callback wrapping _midiOnMessage
|
||
let _midiStateSub = false; // subscribed to midi-input:sources-changed
|
||
let _midiInput = null; // selected source descriptor { id, name }
|
||
// Set by _midiConnect; the focused instance reads + clears it to skip
|
||
// retroactive miss-counting for notes that elapsed while disconnected.
|
||
let _midiJustConnected = false;
|
||
// Gates the live listener wiring across the async connect.
|
||
let _midiActive = false;
|
||
// Routes incoming MIDI to the focused renderer instance.
|
||
let _activeInstance = null;
|
||
const _instances = new Set();
|
||
let _midiInitInFlight = null;
|
||
let _midiConnectSeq = 0; // generation guard for async _midiConnect races
|
||
|
||
// The core midi-input domain, if present (it ships with core).
|
||
function _mi() {
|
||
const m = window.slopsmith && window.slopsmith.midiInput;
|
||
return (m && m.version === 1) ? m : null;
|
||
}
|
||
// Domain sources shaped like the old MIDIInput list: { id, name }.
|
||
// sourceId == the old MIDIInput.id, so saved picks stay compatible.
|
||
function _midiSources() {
|
||
const mi = _mi();
|
||
if (!mi) return [];
|
||
return mi.listSources().map(s => ({ id: s.sourceId, name: s.label, key: s.logicalSourceKey }));
|
||
}
|
||
// Detach the live listener + release the domain session.
|
||
function _midiDetach() {
|
||
// Invalidate any in-flight _midiConnect open: a detach driven by device
|
||
// removal (sources-changed) or an opt-out must supersede a pending open
|
||
// so it can't resume and install a handle for a now-gone source.
|
||
_midiConnectSeq += 1;
|
||
if (_midiHandle && _midiListener) { try { _midiHandle.removeListener(_midiListener); } catch (_) { /* best-effort */ } }
|
||
const mi = _mi();
|
||
if (mi && _midiInput) { try { mi.close({ requester: PLUGIN_ID, logicalSourceKey: _midiInput.key || ('web-midi::' + _midiInput.id) }); } catch (_) { /* best-effort */ } }
|
||
_midiHandle = null;
|
||
_midiListener = null;
|
||
_midiInput = null;
|
||
}
|
||
|
||
function _midiInit() {
|
||
if (_midiReady) {
|
||
// Only (re)connect when there's no live session. A repeated init
|
||
// (settings panel open, extra splitscreen instance) must NOT re-enter
|
||
// _midiConnect on an active handle — that tears down the live session
|
||
// and releases held keys for no reason. After a full release the
|
||
// handle is null, so reconnect happens then.
|
||
if (!_midiHandle) _midiAutoConnect();
|
||
return Promise.resolve();
|
||
}
|
||
if (_midiInitInFlight) return _midiInitInFlight;
|
||
const mi = _mi();
|
||
if (!mi) return Promise.resolve();
|
||
_midiInitInFlight = (async () => {
|
||
try {
|
||
const r = await mi.discover(); // permission boundary (requestMIDIAccess, in core)
|
||
// Only latch ready on a successful discovery — a denied/unavailable
|
||
// outcome must NOT latch, or reopening never retries the prompt.
|
||
if (!r || r.outcome !== 'handled') return;
|
||
_midiReady = true;
|
||
// Replug/unplug refresh (replaces MIDIAccess.onstatechange): the
|
||
// domain re-discovers and emits sources-changed; refresh the list
|
||
// and re-run auto-connect so a saved device reattaches.
|
||
if (!_midiStateSub && window.slopsmith && typeof window.slopsmith.on === 'function') {
|
||
_midiStateSub = true;
|
||
window.slopsmith.on('midi-input:sources-changed', () => {
|
||
_midiNotifyDeviceListChanged();
|
||
if (!_midiInput) _midiAutoConnect(false); // recovery: saved device only
|
||
});
|
||
}
|
||
_midiAutoConnect();
|
||
_midiNotifyDeviceListChanged();
|
||
} catch (e) {
|
||
console.warn('[Keys-Hwy3D] MIDI access denied:', e);
|
||
} finally {
|
||
_midiInitInFlight = null;
|
||
}
|
||
})();
|
||
return _midiInitInFlight;
|
||
}
|
||
|
||
function _readSavedPick() {
|
||
try {
|
||
const raw = _readStore(STORE_KEYS.midiPick);
|
||
if (raw) {
|
||
const obj = JSON.parse(raw);
|
||
if (obj && typeof obj === 'object') {
|
||
return { id: String(obj.id || ''), name: String(obj.name || ''), key: String(obj.key || '') };
|
||
}
|
||
}
|
||
} catch (_) {}
|
||
return null;
|
||
}
|
||
|
||
function _writeSavedPick(id, name, key) {
|
||
_writeStore(STORE_KEYS.midiPick, JSON.stringify({ id: id || '', name: name || '', key: key || '' }));
|
||
}
|
||
|
||
// Pure decision logic (exported via __test): pick which device to
|
||
// auto-connect to from the current source list, the domain-wide selection
|
||
// (`globalKey`, from Settings → Input Setup), and this plugin's own legacy
|
||
// saved pick. Returns null for "connect to nothing" (explicit None opt-out,
|
||
// or the configured device currently absent during hotplug recovery).
|
||
//
|
||
// The domain-wide selection is the SOURCE OF TRUTH (checked first): a device
|
||
// configured globally must never be overridden by a stale plugin-local pick
|
||
// or an arbitrary first-device fallback — that override was the bug. The
|
||
// local pick is retained only as a fallback BELOW the global (and for
|
||
// name-recovery when the global's logicalSourceKey went stale, e.g. a
|
||
// browser that regenerates MIDI port ids across reloads). Auto-connect no
|
||
// longer writes the local pick, so it only ever holds a value an explicit
|
||
// selection put there (or a stale one from a pre-fix build — the global
|
||
// still wins over it).
|
||
function _pickMidiTarget(inputs, saved, globalKey, allowFallback) {
|
||
if (!inputs.length) return null;
|
||
const notBlocked = (i) => !!i && !_MIDI_BLOCKLIST_RE.test(i.name || '');
|
||
// Explicit "None" opt-out (set only via the device-select API).
|
||
if (saved && saved.id === '' && saved.name === '') return null;
|
||
|
||
// 1. Domain-wide selection (Settings → Input Setup) — source of truth.
|
||
if (globalKey) {
|
||
const g = inputs.find(i => i.key === globalKey);
|
||
if (notBlocked(g)) return g;
|
||
}
|
||
|
||
// 2. Legacy plugin-local pick, as a fallback below the global. Prefer the
|
||
// globally-unique logicalSourceKey, then the legacy bare sourceId, then
|
||
// case-insensitive name (Chrome on Linux regenerates ids per page load).
|
||
let target = null;
|
||
if (saved && saved.key) target = inputs.find(i => i.key === saved.key) || null;
|
||
if (!target && saved && saved.id) target = inputs.find(i => i.id === saved.id) || null;
|
||
if (!target && saved && saved.name) {
|
||
const n = saved.name.toLowerCase();
|
||
target = inputs.find(i => (i.name || '').toLowerCase() === n) || null;
|
||
}
|
||
// Never honour a saved pick that resolves to a loopback / "Midi Through"
|
||
// port — it carries no device input, so it silently eats every note.
|
||
if (target && !notBlocked(target)) target = null;
|
||
if (target) return target;
|
||
|
||
// 3. Nothing configured resolved to a present device. In recovery
|
||
// (allowFallback=false) with a configured preference — a global pick or a
|
||
// saved pick — that's currently absent, preserve it rather than switching
|
||
// to an arbitrary device on a transient multi-device unplug. With no
|
||
// preference at all, a first-device grab is the intended first-hotplug
|
||
// auto-connect, allowed even in recovery.
|
||
const hasPreference = !!(globalKey || (saved && (saved.key || saved.id || saved.name)));
|
||
if (!allowFallback && hasPreference) return null;
|
||
// Connect to nothing rather than a loopback: if every present device is
|
||
// blocklisted, a first-device grab would attach to a "Midi Through"/IAC
|
||
// port that carries no input and silently eats every note.
|
||
return inputs.find(notBlocked) || null;
|
||
}
|
||
|
||
function _midiAutoConnect(allowFallback) {
|
||
// Recovery (sources-changed after unplug) passes false: never switch to a
|
||
// fallback input on a transient multi-device unplug (the configured
|
||
// device returns on replug and reconnects then). Auto-connect is
|
||
// non-persisting (persist omitted → false): it opens the resolved device
|
||
// for this session WITHOUT writing the plugin-local pick or the shared
|
||
// domain selection, so opening this highway can't clobber the user's
|
||
// globally-configured device.
|
||
if (allowFallback === undefined) allowFallback = true;
|
||
const inputs = _midiSources();
|
||
const saved = _readSavedPick();
|
||
const mi = _mi();
|
||
const globalKey = mi && typeof mi.getSelected === 'function' ? mi.getSelected() : null;
|
||
const target = _pickMidiTarget(inputs, saved, globalKey, allowFallback);
|
||
if (!target) return;
|
||
_midiConnect(target.id, target.name, target.key);
|
||
}
|
||
|
||
// `persist` gates the two preference writes. Only an EXPLICIT device
|
||
// selection (the device-select API) persists: it writes the plugin-local
|
||
// pick AND the shared domain selection (`mi.select`, so the user's choice
|
||
// becomes the global default). Auto-connect and programmatic opens pass
|
||
// falsy — they open the resolved device for this session only, never
|
||
// touching either store, so they can't clobber a globally-configured device.
|
||
async function _midiConnect(id, name, key, persist) {
|
||
// Capture our generation AFTER _midiDetach()'s own bump, so a later
|
||
// detach (device removal / new connect / opt-out) reliably supersedes us.
|
||
_midiDetach();
|
||
const myGen = ++_midiConnectSeq;
|
||
// Connecting (or opting out) invalidates per-instance held state —
|
||
// clear EVERY live instance, not just the focused one, so no panel
|
||
// shows stuck keys when it later takes focus (piano-plugin lesson).
|
||
for (const inst of _instances) {
|
||
if (inst && typeof inst._releaseAllHeld === 'function') inst._releaseAllHeld();
|
||
}
|
||
if (persist) _writeSavedPick(id || '', name || '', key || '');
|
||
const mi = _mi();
|
||
if ((id || key) && mi) {
|
||
// Prefer the globally-unique logicalSourceKey so two providers that
|
||
// expose the same provider-local sourceId stay distinguishable; fall
|
||
// back to the legacy sourceId match.
|
||
const src = (key && _midiSources().find(s => s.key === key))
|
||
|| (id && _midiSources().find(s => s.id === id))
|
||
|| null;
|
||
if (src) {
|
||
const lkey = src.key || ('web-midi::' + src.id);
|
||
_midiInput = { id: src.id, name: src.name, key: lkey };
|
||
_midiJustConnected = true;
|
||
// Only an explicit selection writes the shared global default;
|
||
// open takes the logicalSourceKey directly, so select() is not
|
||
// needed to open — it exists purely to set the global. Persist it
|
||
// BEFORE the no-instance early return so a settings-panel pick with
|
||
// no live renderer still updates the shared default (best-effort:
|
||
// a select hiccup must not abort the connect).
|
||
if (persist) { try { await mi.select(lkey); } catch (_) { /* best-effort */ } }
|
||
// No live renderer to consume OR release a session — don't hold one
|
||
// open (settings-only ensure-init, or the last instance was torn
|
||
// down during async discovery). A later renderer mount re-runs
|
||
// auto-connect and opens for real, releasing on destroy.
|
||
if (_instances.size === 0) { _midiNotifyDeviceListChanged(); return; }
|
||
try {
|
||
const res = await mi.open({ requester: PLUGIN_ID, logicalSourceKey: lkey });
|
||
// A newer _midiConnect (device switch / None / replug) ran while
|
||
// we awaited open — discard this stale session so we don't wire a
|
||
// listener for a device the user already moved off of.
|
||
if (myGen !== _midiConnectSeq) {
|
||
if (!_midiInput || _midiInput.key !== lkey) { try { mi.close({ requester: PLUGIN_ID, logicalSourceKey: lkey }); } catch (_) { /* best-effort */ } }
|
||
return;
|
||
}
|
||
if (res && res.handle) {
|
||
_midiHandle = res.handle;
|
||
// The domain handle delivers raw MIDI data; adapt to the
|
||
// old MIDIMessageEvent shape so _midiOnMessage is unchanged.
|
||
_midiListener = (data) => _midiOnMessage({ data });
|
||
if (_midiActive) _midiHandle.addListener(_midiListener);
|
||
} else {
|
||
// Open yielded no live handle (device vanished post-discovery,
|
||
// or denied/unavailable). Clear the selection so the render
|
||
// loop's connected-device gate doesn't sweep phantom misses.
|
||
_midiInput = null;
|
||
}
|
||
} catch (e) {
|
||
console.warn('[Keys-Hwy3D] MIDI open failed:', e);
|
||
// Only clear if we're still the current connect — a stale older
|
||
// open's rejection must not wipe a newer connect's installed
|
||
// _midiInput/_midiHandle (which would also leak the live handle,
|
||
// since closes are gated on _midiInput).
|
||
if (myGen === _midiConnectSeq) _midiInput = null;
|
||
}
|
||
}
|
||
}
|
||
_midiNotifyDeviceListChanged();
|
||
}
|
||
|
||
function _midiResume() {
|
||
// Idempotent: a second live renderer instance (splitscreen/overlapping
|
||
// lifetimes) calls this while already active. The domain handle's
|
||
// addListener is Set-backed, but don't rely on the provider de-duping —
|
||
// re-adding here could double-deliver one MIDI note to the focused
|
||
// instance and score a hit plus duplicate misses.
|
||
if (_midiActive) return;
|
||
_midiActive = true;
|
||
if (_midiHandle && _midiListener) { try { _midiHandle.addListener(_midiListener); } catch (_) { /* best-effort */ } }
|
||
}
|
||
// Called when the LAST live instance is torn down: fully release the shared
|
||
// midi-input domain session (via _midiDetach: close + null + generation bump),
|
||
// not just the listener, so the device/provider session isn't held open after
|
||
// the visualization is gone. Re-mount's _midiInit auto-connects from the saved
|
||
// pick, so _midiReady is intentionally left latched.
|
||
function _midiReleaseSession() {
|
||
_midiActive = false;
|
||
_midiDetach();
|
||
}
|
||
|
||
function _midiOnMessage(e) {
|
||
if (!_activeInstance) return;
|
||
const data = e.data;
|
||
if (!data || data.length < 2) return;
|
||
const status = data[0];
|
||
const ch = status & 0x0f;
|
||
if (_cfg.midiChannel >= 0 && ch !== _cfg.midiChannel) return;
|
||
const cmd = status & 0xf0;
|
||
const note = data[1];
|
||
const vel = data.length > 2 ? data[2] : 0;
|
||
// Raw note number goes to the instance; transpose is applied there
|
||
// and remembered per note-on so a transpose change between note-on
|
||
// and note-off can't strand a held key (piano-plugin invariant).
|
||
if (cmd === 0x90 && vel > 0) {
|
||
_activeInstance._handleNoteOn(note, vel);
|
||
} else if (cmd === 0x80 || (cmd === 0x90 && vel === 0)) {
|
||
_activeInstance._handleNoteOff(note);
|
||
} else if (cmd === 0xb0 && note === 64) {
|
||
_activeInstance._handleSustain(vel >= 64); // CC64 sustain pedal
|
||
}
|
||
}
|
||
|
||
function _midiNotifyDeviceListChanged() {
|
||
// Clear a vanished selection so miss-sweeping stops. No note-off
|
||
// can ever arrive for keys that were down when the device went
|
||
// away, so clear held state on every instance too — otherwise
|
||
// those keys stay visually depressed for the rest of the session.
|
||
if (_midiInput && !_midiSources().some(s => s.id === _midiInput.id)) {
|
||
_midiDetach();
|
||
for (const inst of _instances) {
|
||
if (inst && typeof inst._releaseAllHeld === 'function') inst._releaseAllHeld();
|
||
}
|
||
}
|
||
_aiRefreshSources();
|
||
try { window.dispatchEvent(new CustomEvent('keys3d:midi_devices')); } catch (_) {}
|
||
}
|
||
|
||
function _midiListInputs() {
|
||
return _midiSources().map(s => ({ id: s.id, name: s.name || s.id }));
|
||
}
|
||
|
||
/* ── Built-in synth — hear your playing (WebAudio, offline-safe, no CDN) ── */
|
||
let _synthCtx = null, _synthMaster = null;
|
||
const _synthVoices = new Map(); // midi → { o1, o2, g }
|
||
function _synthResume() {
|
||
if (!_synthCtx) {
|
||
try {
|
||
const AC = window.AudioContext || window.webkitAudioContext;
|
||
if (!AC) return;
|
||
_synthCtx = new AC();
|
||
_synthMaster = _synthCtx.createGain();
|
||
_synthMaster.gain.value = 0.45;
|
||
_synthMaster.connect(_synthCtx.destination);
|
||
} catch (_) { _synthCtx = null; return; }
|
||
}
|
||
if (_synthCtx.state === 'suspended') _synthCtx.resume().catch(() => {});
|
||
}
|
||
// Autoplay policy: an AudioContext can only start from a real user gesture
|
||
// (a MIDI event doesn't count), so prime/resume it on any page input.
|
||
// Guard addEventListener — the test harness provides a bare vm window
|
||
// without DOM event support, and the synth is never invoked there.
|
||
if (typeof window !== 'undefined' && typeof window.addEventListener === 'function') {
|
||
['pointerdown', 'keydown'].forEach(ev =>
|
||
window.addEventListener(ev, _synthResume, { passive: true }));
|
||
}
|
||
function _synthNoteOn(midi, vel) {
|
||
_synthResume();
|
||
if (!_synthCtx || _synthCtx.state !== 'running') return;
|
||
if (_synthVoices.has(midi)) _synthNoteOff(midi);
|
||
const now = _synthCtx.currentTime;
|
||
const f = 440 * Math.pow(2, (midi - 69) / 12);
|
||
const o1 = _synthCtx.createOscillator(); o1.type = 'triangle'; o1.frequency.value = f;
|
||
const o2 = _synthCtx.createOscillator(); o2.type = 'sine'; o2.frequency.value = f; o2.detune.value = -6;
|
||
const lp = _synthCtx.createBiquadFilter(); lp.type = 'lowpass'; lp.frequency.value = Math.min(9000, f * 7);
|
||
const g = _synthCtx.createGain();
|
||
const peak = 0.12 + 0.16 * (Math.max(1, Math.min(127, vel || 96)) / 127);
|
||
g.gain.setValueAtTime(0.0001, now);
|
||
g.gain.exponentialRampToValueAtTime(peak, now + 0.006); // attack
|
||
g.gain.exponentialRampToValueAtTime(peak * 0.55, now + 0.35); // decay → sustain
|
||
o1.connect(lp); o2.connect(lp); lp.connect(g); g.connect(_synthMaster);
|
||
o1.start(now); o2.start(now);
|
||
_synthVoices.set(midi, { o1, o2, g });
|
||
}
|
||
function _synthNoteOff(midi) {
|
||
const v = _synthVoices.get(midi);
|
||
if (!v || !_synthCtx) return;
|
||
_synthVoices.delete(midi);
|
||
const now = _synthCtx.currentTime;
|
||
try {
|
||
v.g.gain.cancelScheduledValues(now);
|
||
v.g.gain.setValueAtTime(Math.max(0.0001, v.g.gain.value), now);
|
||
v.g.gain.exponentialRampToValueAtTime(0.0001, now + 0.22); // release
|
||
v.o1.stop(now + 0.25); v.o2.stop(now + 0.25);
|
||
} catch (_) {}
|
||
}
|
||
function _synthAllOff() {
|
||
for (const midi of Array.from(_synthVoices.keys())) _synthNoteOff(midi);
|
||
}
|
||
|
||
/* ── Device/settings control API (settings UI + console) ───────────── */
|
||
window.keysH3dEnsureMidiInit = function () { return _midiInit(); };
|
||
window.keysH3dListMidiInputs = function () { return _midiListInputs(); };
|
||
window.keysH3dGetMidiInputId = function () { return _midiInput ? _midiInput.id : ''; };
|
||
window.keysH3dSetMidiInput = function (id) {
|
||
// `id` may be a logicalSourceKey (new host calls) or a legacy sourceId.
|
||
// Explicit user selection → persist (local pick + shared global default).
|
||
const src = id
|
||
? (_midiSources().find(s => s.key === id) || _midiSources().find(s => s.id === id))
|
||
: null;
|
||
_midiConnect(src ? src.id : (id || ''), src ? src.name : '', src ? src.key : '', true);
|
||
return true;
|
||
};
|
||
window.keysH3dGetMidiChannel = function () { return _cfg.midiChannel; };
|
||
window.keysH3dSetMidiChannel = function (ch) {
|
||
const v = parseInt(ch, 10);
|
||
_cfg.midiChannel = Number.isFinite(v) ? Math.max(-1, Math.min(15, v)) : -1;
|
||
_writeStore(STORE_KEYS.midiChannel, String(_cfg.midiChannel));
|
||
};
|
||
window.keysH3dGetTranspose = function () { return _cfg.transpose; };
|
||
window.keysH3dSetTranspose = function (semis) {
|
||
const v = parseInt(semis, 10);
|
||
_cfg.transpose = Number.isFinite(v) ? Math.max(-24, Math.min(24, v)) : 0;
|
||
_writeStore(STORE_KEYS.transpose, String(_cfg.transpose));
|
||
};
|
||
|
||
// Classify a hit's timing against its matched chart note. delta =
|
||
// note.t - now: positive → struck before the note crossed the line
|
||
// (EARLY), negative → after (LATE); the inner 40% of the hit window
|
||
// reads as on-time. PORTED FROM drum_highway_3d (same proportions as
|
||
// highway_3d's timing verdicts) — keep in sync.
|
||
function _classifyTiming(delta, tol) {
|
||
if (!Number.isFinite(delta) || !Number.isFinite(tol)) return 'OK';
|
||
if (Math.abs(delta) <= tol * 0.4) return 'OK';
|
||
return delta > 0 ? 'EARLY' : 'LATE';
|
||
}
|
||
|
||
// Host splitscreen state (PORTED FROM highway_3d _ssActive, minus the
|
||
// focus-API checks the guitar needs for input routing — here it only
|
||
// gates GPU cost, so "is a split active at all" is the right question;
|
||
// a mixed split (this viz + another renderer) must count too).
|
||
function _ssActive() {
|
||
const ss = window.feedBackSplitscreen;
|
||
return !!(ss && typeof ss.isActive === 'function' && ss.isActive());
|
||
}
|
||
|
||
/* ======================================================================
|
||
* Visual-FX settings — guitar-highway parity controls
|
||
* ====================================================================== */
|
||
|
||
// Defaults for the graphics/FX controls this plugin exposes. Keys mirror
|
||
// the guitar highway's `h3d_bg_*` vocabulary under this plugin's own
|
||
// `keys3d_bg_*` localStorage prefix; later parity PRs (sparks, themes,
|
||
// background styles, score FX) extend this object with their own keys.
|
||
// Everything defaults ON — the settings screen is the opt-out.
|
||
const FX_DEFAULTS = {
|
||
bloom: true,
|
||
sparks: true, // pooled hit-spark bursts at the struck key
|
||
timingFx: true, // early/late/on-time coloring of the sparks
|
||
streakFx: true, // consecutive-hit escalation (bigger bursts)
|
||
hitFx: 0.7, // 0–1 master intensity for the hit-line kick
|
||
vibrancy: 0.85, // note-gem opacity + lane-guide strength
|
||
cinematic: true, // rebalanced lighting (dimmer ambient, stronger key)
|
||
glow: 0.5, // 0–1 emissive multiplier (0.5 = the stock look)
|
||
scoreFx: true, // 2D overlay: +N pops, combo rings, streak-break wash
|
||
bgIntensity: 0.5, // background-ambience density/strength
|
||
bgReactive: true, // background reacts to the audio analyser
|
||
// Highway-layout options (apply on the next chart build via init()'s
|
||
// fx re-read). The sharp LAYOUT is a separate string setting
|
||
// (keys3d_bg_sharpMode); these two are the booleans.
|
||
octaveGaps: true, // ON: wider divider gap at each B→C octave boundary
|
||
laneOpacity: 0.0, // 0–1: lane-color strength. 0 (default) = dark floor +
|
||
// block guide lines (E→F, B→C); 1 = full colored lanes; crossfades.
|
||
octaveContrast: 0.5, // 0–1: how strongly the B→C octave line stands out. It
|
||
// auto-darkens with lane opacity and brightens as it fades.
|
||
// Camera base-rig fine-tune. These shift the BASE vantage point the
|
||
// auto-pan/zoom follow-motion is built on (they multiply/offset the
|
||
// active CAM_PRESET before the per-frame pan + dolly), so the camera
|
||
// still tracks the notes — just from a nudged height/distance/tilt.
|
||
camHeight: 1.0, // ×preset camera height (higher = more overhead)
|
||
camDist: 1.0, // ×preset camera distance (larger = further back)
|
||
camTilt: 0.0, // aim offset up(+)/down(−); 0 = neutral — the tuned overhead aim lives in CAM_PRESETS.overhead, so this fine-tune only nudges from a preset (and Classic + tilt 0 == the historical rig)
|
||
};
|
||
// Numeric FX keys clamp to a declared [min, max]; keys absent from this
|
||
// table keep the historical 0–1 slider range. The camera fine-tune knobs
|
||
// are multipliers/offsets centred on 1 (or 0), so they need headroom and a
|
||
// floor a 0–1 range couldn't express.
|
||
const FX_RANGES = {
|
||
camHeight: [0.4, 2.2],
|
||
camDist: [0.4, 2.2],
|
||
camTilt: [-1.0, 1.0],
|
||
};
|
||
function _fxClamp(key, n) {
|
||
const r = FX_RANGES[key] || [0, 1];
|
||
return Math.min(r[1], Math.max(r[0], n));
|
||
}
|
||
const FX_LS_PREFIX = 'keys3d_bg_';
|
||
// Theme id lives OUTSIDE FX_DEFAULTS (string, not bool/number) — its own
|
||
// localStorage key + validation against BG_THEMES.
|
||
const FX_LS_THEME = 'keys3d_bg_theme';
|
||
// Background-ambience style — string-valued like the theme, so it gets
|
||
// its own validated key + setter rather than an FX_DEFAULTS slot.
|
||
// butterchurn/image/video from the guitar are deliberately out of scope.
|
||
const BG_STYLE_IDS = ['off', 'particles', 'lights', 'geometric'];
|
||
const FX_LS_STYLE = 'keys3d_bg_style';
|
||
function readBgStyleSetting() {
|
||
try {
|
||
const id = localStorage.getItem(FX_LS_STYLE);
|
||
if (id && BG_STYLE_IDS.indexOf(id) !== -1) return id;
|
||
} catch (_) {}
|
||
return 'particles';
|
||
}
|
||
window.keys3dSetBgStyle = function (id) {
|
||
if (BG_STYLE_IDS.indexOf(id) === -1) return;
|
||
try { localStorage.setItem(FX_LS_STYLE, id); } catch (_) {}
|
||
try {
|
||
window.dispatchEvent(new CustomEvent('keys3d:settings', { detail: { bgStyle: id } }));
|
||
} catch (_) { /* dispatch unavailable — persisted value applies next init */ }
|
||
};
|
||
|
||
/* ======================================================================
|
||
* Audio analyser bridge — module singletons, shared across instances.
|
||
* PORTED FROM highway_3d _bgGetAnalyser/_bgReadBands via
|
||
* drum_highway_3d (keep in sync; the guitar's diagnostics plumbing is
|
||
* dropped).
|
||
* ====================================================================== */
|
||
const BG_FREQ_BINS = 128;
|
||
const BG_ZERO_BANDS = { bass: 0, mid: 0, treble: 0 };
|
||
let _bgAudio = null; // {ctx, analyser, freq, source} | {failed, permanent}
|
||
let _bgAudioCore = null; // remembered core tap (one-shot per element)
|
||
let _bgAudioFailedAt = 0;
|
||
const _BG_AUDIO_RETRY_MS = 1000;
|
||
function _bgGetAnalyser() {
|
||
// Prefer the stems plugin's side-chain analyser: on sloppaks the
|
||
// #audio element is a silent virtual transport. Per-song node —
|
||
// cache keyed on identity so a song switch re-adopts automatically.
|
||
const stemsApi = window.feedBack && window.feedBack.stems;
|
||
const stemsAnalyser = (stemsApi && typeof stemsApi.getAnalyser === 'function')
|
||
? stemsApi.getAnalyser() : null;
|
||
if (stemsAnalyser) {
|
||
if (!_bgAudio || _bgAudio.source !== 'stems' || _bgAudio.analyser !== stemsAnalyser) {
|
||
_bgAudio = {
|
||
ctx: stemsAnalyser.context,
|
||
analyser: stemsAnalyser,
|
||
freq: new Uint8Array(Math.max(BG_FREQ_BINS, stemsAnalyser.frequencyBinCount)),
|
||
source: 'stems',
|
||
};
|
||
}
|
||
return _bgAudio;
|
||
}
|
||
if (_bgAudio && _bgAudio.source === 'stems') _bgAudio = _bgAudioCore;
|
||
if (_bgAudio && !_bgAudio.failed) return _bgAudio;
|
||
if (_bgAudio && _bgAudio.failed) {
|
||
if (_bgAudio.permanent) return null;
|
||
if (performance.now() - _bgAudioFailedAt < _BG_AUDIO_RETRY_MS) return null;
|
||
}
|
||
const audio = document.getElementById('audio');
|
||
if (!audio) return null;
|
||
// Never tap #audio before the page has user activation: a fresh
|
||
// AudioContext would start suspended and route LIVE playback into
|
||
// silence until the next play gesture (createMediaElementSource is
|
||
// one-shot — not undoable). Called per frame, so this just retries
|
||
// once activation exists. (Improvement over the guitar's copy, which
|
||
// only resumes after the fact — candidate to port back.)
|
||
const ua = navigator.userActivation;
|
||
if (ua && ua.hasBeenActive === false) return null;
|
||
// Shared tap: createMediaElementSource is one-shot per element, so
|
||
// the FIRST visualizer to tap #audio publishes it at
|
||
// window.__feedBackAudioTap and every later one (this plugin, the
|
||
// drum highway — the guitar is a port-back candidate) adopts it
|
||
// instead of throwing InvalidStateError in a mixed splitscreen.
|
||
const shared = window.__feedBackAudioTap;
|
||
if (shared && shared.analyser && shared.mediaEl === audio) {
|
||
_bgAudio = {
|
||
ctx: shared.ctx,
|
||
analyser: shared.analyser,
|
||
freq: new Uint8Array(Math.max(BG_FREQ_BINS, shared.analyser.frequencyBinCount)),
|
||
source: 'core',
|
||
};
|
||
_bgAudioCore = _bgAudio;
|
||
return _bgAudio;
|
||
}
|
||
let ctx = null;
|
||
try {
|
||
const Ctx = window.AudioContext || window.webkitAudioContext;
|
||
if (!Ctx) throw new Error('Web Audio API not available');
|
||
ctx = new Ctx();
|
||
const source = ctx.createMediaElementSource(audio);
|
||
const analyser = ctx.createAnalyser();
|
||
analyser.fftSize = 256;
|
||
source.connect(analyser);
|
||
analyser.connect(ctx.destination);
|
||
_bgAudio = { ctx, analyser, freq: new Uint8Array(Math.max(BG_FREQ_BINS, analyser.frequencyBinCount)), source: 'core' };
|
||
_bgAudioCore = _bgAudio;
|
||
try { window.__feedBackAudioTap = { ctx, analyser, mediaEl: audio }; } catch (_) {}
|
||
const resume = () => {
|
||
if (ctx.state === 'suspended' && typeof ctx.resume === 'function') {
|
||
ctx.resume().catch(() => { /* no gesture yet, retry on next play */ });
|
||
}
|
||
};
|
||
resume();
|
||
audio.addEventListener('play', resume);
|
||
return _bgAudio;
|
||
} catch (e) {
|
||
if (ctx && typeof ctx.close === 'function') {
|
||
try { ctx.close(); } catch (_) {}
|
||
}
|
||
console.warn('[Keys-Hwy3D] failed to set up audio analyser:', e);
|
||
_bgAudio = { failed: true, permanent: !!(e && e.name === 'InvalidStateError') };
|
||
_bgAudioFailedAt = performance.now();
|
||
return null;
|
||
}
|
||
}
|
||
const _BG_BANDS_CACHE_MS = 5;
|
||
let _bgBandsLastT = -Infinity;
|
||
const _bgBandsCache = { bass: 0, mid: 0, treble: 0 };
|
||
function _bgReadBands() {
|
||
const a = _bgGetAnalyser();
|
||
if (!a) return BG_ZERO_BANDS;
|
||
const t = performance.now();
|
||
if (t - _bgBandsLastT < _BG_BANDS_CACHE_MS) return _bgBandsCache;
|
||
_bgBandsLastT = t;
|
||
a.analyser.getByteFrequencyData(a.freq);
|
||
let bass = 0, mid = 0, treble = 0;
|
||
for (let i = 0; i < 8; i++) bass += a.freq[i];
|
||
for (let i = 8; i < 40; i++) mid += a.freq[i];
|
||
for (let i = 40; i < 128; i++) treble += a.freq[i];
|
||
_bgBandsCache.bass = bass / (8 * 255);
|
||
_bgBandsCache.mid = mid / (32 * 255);
|
||
_bgBandsCache.treble = treble / (88 * 255);
|
||
return _bgBandsCache;
|
||
}
|
||
|
||
// Scene color themes — PORTED FROM highway_3d BG_THEMES (keep the ids,
|
||
// names and values in sync so a user's look carries across instruments).
|
||
// One pick drives clear/fog + the highway floor + the lane-edge rails;
|
||
// pitch-class note/key colors are identity and never themed.
|
||
// clear/fog — background gradient anchor + distance fog
|
||
// board — the highway floor plane
|
||
// laneDim — the thin lane-edge rails (lane itself is pitch-colored)
|
||
const BG_THEMES = {
|
||
// 'default' is THIS plugin's original palette (fog 0x1a1a2e, floor
|
||
// 0x141422, rail 0x2a2a3e) — byte-identical for anyone who never
|
||
// touches the setting. Every OTHER id matches the guitar's table.
|
||
default: { clear: 0x1a1a2e, fog: 0x1a1a2e, board: 0x141422, laneDim: 0x2a2a3e },
|
||
midnight: { clear: 0x0a0e1a, fog: 0x0a0e1a, board: 0x080d1c, lane: 0x244fae, laneDim: 0x122a5e },
|
||
charcoal: { clear: 0x16181c, fog: 0x16181c, board: 0x141417, lane: 0x525a66, laneDim: 0x282d34 },
|
||
deeppurple: { clear: 0x140a1e, fog: 0x140a1e, board: 0x0b0610, lane: 0x3a1f6e, laneDim: 0x1f1040 },
|
||
forest: { clear: 0x0a1614, fog: 0x0a1614, board: 0x06100c, lane: 0x15602a, laneDim: 0x0a3318 },
|
||
warmslate: { clear: 0x1c130b, fog: 0x1c130b, board: 0x0e0805, lane: 0x5e3a12, laneDim: 0x341f0a },
|
||
deepfocus: { clear: 0x0c0c0d, fog: 0x0c0c0d, board: 0x060606, lane: 0x2f7fa0, laneDim: 0x163c4e },
|
||
deepsea: { clear: 0x06222b, fog: 0x06222b, board: 0x03141a, lane: 0x0e5a63, laneDim: 0x063338 },
|
||
cathode: { clear: 0x140b03, fog: 0x140b03, board: 0x0c0702, lane: 0x6e4a0e, laneDim: 0x3a2806 },
|
||
cathodegreen: { clear: 0x07301a, fog: 0x07301a, board: 0x031a0c, lane: 0x0e6e2a, laneDim: 0x073a18 },
|
||
hearth: { clear: 0x280806, fog: 0x280806, board: 0x1a0606, lane: 0x7a2410, laneDim: 0x3f1409 },
|
||
};
|
||
function _bgThemeColors(id) { return BG_THEMES[id] || BG_THEMES.default; }
|
||
function readThemeSetting() {
|
||
try {
|
||
const id = localStorage.getItem(FX_LS_THEME);
|
||
if (id && BG_THEMES[id]) return id;
|
||
} catch (_) {}
|
||
return 'default';
|
||
}
|
||
window.keys3dSetTheme = function (id) {
|
||
if (!BG_THEMES[id]) return;
|
||
try { localStorage.setItem(FX_LS_THEME, id); } catch (_) {}
|
||
try {
|
||
window.dispatchEvent(new CustomEvent('keys3d:settings', { detail: { theme: id } }));
|
||
} catch (_) { /* dispatch unavailable — persisted value applies next init */ }
|
||
};
|
||
|
||
// Note-color palette id — string-valued like the theme, so it gets its
|
||
// own validated key + setter rather than an FX_DEFAULTS slot.
|
||
const FX_LS_PALETTE = 'keys3d_bg_palette';
|
||
function readPaletteSetting() {
|
||
try {
|
||
const id = localStorage.getItem(FX_LS_PALETTE);
|
||
if (id && PALETTE_IDS.indexOf(id) !== -1) return id;
|
||
} catch (_) {}
|
||
// Default: the octave scheme (each octave its own color, darker
|
||
// sharps) — the plug-and-play piano look. Emerald/classic/etc. remain
|
||
// selectable.
|
||
return 'octaves';
|
||
}
|
||
window.keys3dSetPalette = function (id) {
|
||
if (PALETTE_IDS.indexOf(id) === -1) return;
|
||
try { localStorage.setItem(FX_LS_PALETTE, id); } catch (_) {}
|
||
try {
|
||
window.dispatchEvent(new CustomEvent('keys3d:settings', { detail: { palette: id } }));
|
||
} catch (_) { /* dispatch unavailable — persisted value applies next init */ }
|
||
};
|
||
|
||
// Sharp-display layout id — string-valued (3-way), its own validated key +
|
||
// setter. 'floating' = the original raised-plane sharps with white-only
|
||
// lanes; 'flat' = every note on one plane with piano-shaped tiled lanes
|
||
// (sharps leaned to even the naturals); 'realistic'
|
||
// = one plane with note bars sized like the physical keys (full naturals,
|
||
// full sharps overlapping on top). Geometry-time — applied on the next chart
|
||
// build via init()'s re-read.
|
||
const FX_LS_SHARPMODE = 'keys3d_bg_sharpMode';
|
||
const SHARP_MODES = ['floating', 'flat', 'realistic'];
|
||
function readSharpModeSetting() {
|
||
try {
|
||
const id = localStorage.getItem(FX_LS_SHARPMODE);
|
||
if (id && SHARP_MODES.indexOf(id) !== -1) return id;
|
||
} catch (_) {}
|
||
return 'realistic'; // default layout: physical-key-sized bars on one plane
|
||
}
|
||
window.keys3dSetSharpMode = function (id) {
|
||
if (SHARP_MODES.indexOf(id) === -1) return;
|
||
try { localStorage.setItem(FX_LS_SHARPMODE, id); } catch (_) {}
|
||
try {
|
||
window.dispatchEvent(new CustomEvent('keys3d:settings', { detail: { sharpMode: id } }));
|
||
} catch (_) { /* dispatch unavailable — persisted value applies next init */ }
|
||
};
|
||
|
||
// Camera-rig presets. 'classic' is the original low, near-telephoto rig
|
||
// (numerically identical to the historical constants, so 'classic' with the
|
||
// neutral camTilt default reproduces the exact stock framing). y/z/lookY/lookZ
|
||
// are in pre-K world units — the instance multiplies by K at the use sites,
|
||
// same as the old constants did. Zoom scales position AND look-at every
|
||
// frame, so all presets inherit the adaptive dolly behaviour unchanged.
|
||
// Each preset carries its OWN tuned aim in lookY: 'overhead' bakes in the
|
||
// plug-and-play downward tilt (the −0.6 × CAM_TILT_UNITS = −33 that used to
|
||
// ship as the camTilt default) so the default look is unchanged while
|
||
// camTilt now defaults to 0 (a neutral nudge from whatever preset is picked).
|
||
const CAM_PRESETS = {
|
||
classic: { fov: 40, y: 46, z: 112, lookY: 8, lookZ: -165 },
|
||
elevated: { fov: 44, y: 78, z: 118, lookY: 4, lookZ: -150 },
|
||
overhead: { fov: 48, y: 118, z: 74, lookY: -33, lookZ: -115 },
|
||
};
|
||
// Camera preset id — string-valued like the theme, so it gets its own
|
||
// validated key + setter rather than an FX_DEFAULTS slot.
|
||
const FX_LS_CAMERA = 'keys3d_bg_camera';
|
||
function readCameraSetting() {
|
||
try {
|
||
const id = localStorage.getItem(FX_LS_CAMERA);
|
||
if (id && CAM_PRESETS[id]) return id;
|
||
} catch (_) {}
|
||
// Default: the overhead reading rig — its lookY already carries the
|
||
// tuned downward aim, so with the neutral camTilt default it gives the
|
||
// plug-and-play piano view out of the box. Others selectable ('classic'
|
||
// + neutral tilt = the exact historical rig).
|
||
return 'overhead';
|
||
}
|
||
window.keys3dSetCamera = function (id) {
|
||
if (!CAM_PRESETS[id]) return;
|
||
try { localStorage.setItem(FX_LS_CAMERA, id); } catch (_) {}
|
||
try {
|
||
window.dispatchEvent(new CustomEvent('keys3d:settings', { detail: { camera: id } }));
|
||
} catch (_) { /* dispatch unavailable — persisted value applies next init */ }
|
||
};
|
||
|
||
function readFxSettings() {
|
||
const fx = Object.assign({}, FX_DEFAULTS);
|
||
try {
|
||
for (const k of Object.keys(FX_DEFAULTS)) {
|
||
const raw = localStorage.getItem(FX_LS_PREFIX + k);
|
||
if (raw === null) continue;
|
||
if (typeof FX_DEFAULTS[k] === 'boolean') {
|
||
// Explicit values only — anything else (corrupt/foreign
|
||
// write) keeps the default rather than silently
|
||
// disabling an effect.
|
||
if (raw === '1' || raw === 'true') fx[k] = true;
|
||
else if (raw === '0' || raw === 'false') fx[k] = false;
|
||
} else {
|
||
const n = parseFloat(raw);
|
||
// Numeric FX keys clamp to their declared range
|
||
// (default 0-1) so a corrupt/foreign write can't
|
||
// overdrive opacities or the geometry multipliers.
|
||
if (Number.isFinite(n)) fx[k] = _fxClamp(k, n);
|
||
}
|
||
}
|
||
} catch (_) { /* localStorage unavailable — use defaults */ }
|
||
return fx;
|
||
}
|
||
|
||
// Single setter for every FX key — settings.html calls
|
||
// window.keys3dSetFx('bloom', checked). Coerces to the default's type so
|
||
// a slider string can't poison a boolean toggle.
|
||
window.keys3dSetFx = function (key, value) {
|
||
if (!(key in FX_DEFAULTS)) return;
|
||
let v;
|
||
if (typeof FX_DEFAULTS[key] === 'boolean') {
|
||
// Same accepted representations as readFxSettings so the
|
||
// setter/reader round-trip is consistent ('0'/'false' → false).
|
||
v = value === true || value === 1 || value === '1' || value === 'true';
|
||
} else {
|
||
v = Number(value);
|
||
if (!Number.isFinite(v)) return;
|
||
v = _fxClamp(key, v); // declared range, default 0-1
|
||
}
|
||
try {
|
||
localStorage.setItem(FX_LS_PREFIX + key, typeof v === 'boolean' ? (v ? '1' : '0') : String(v));
|
||
} catch (_) {}
|
||
try {
|
||
window.dispatchEvent(new CustomEvent('keys3d:settings', { detail: { fx: { [key]: v } } }));
|
||
} catch (_) { /* dispatch unavailable — persisted value applies next init */ }
|
||
};
|
||
|
||
/* ======================================================================
|
||
* Capability wiring — note-detection + audio-input domains
|
||
*
|
||
* Everything here is guarded: on servers without the capability hosts
|
||
* (the spec-009 note-detection owner / the audio-session input owner)
|
||
* every call degrades to a silent no-op.
|
||
* ====================================================================== */
|
||
|
||
function _capsApi() {
|
||
const c = window.slopsmith && window.slopsmith.capabilities;
|
||
return (c && c.version === 1 && typeof c.command === 'function') ? c : null;
|
||
}
|
||
|
||
async function _capCommand(domain, name, payload, reason) {
|
||
const caps = _capsApi();
|
||
if (!caps) return null;
|
||
try {
|
||
const r = await caps.command(domain, name, {
|
||
requester: PLUGIN_ID,
|
||
source: PLUGIN_ID,
|
||
origin: 'system',
|
||
reason: reason || ('Keys Highway 3D ' + domain + '.' + name),
|
||
payload: payload || {},
|
||
});
|
||
return (r && r.outcome === 'handled') ? (r.payload || {}) : null;
|
||
} catch (_) { return null; }
|
||
}
|
||
|
||
// ── note-detection: this plugin is a Web-MIDI exact-verdict provider ──
|
||
let _ndProviderRegistered = false;
|
||
async function _ndEnsureProvider() {
|
||
if (_ndProviderRegistered) return;
|
||
const p = await _capCommand('note-detection', 'register-provider', {
|
||
providerId: ND_PROVIDER_ID,
|
||
label: 'Keys Highway MIDI',
|
||
kind: 'midi',
|
||
primitives: ['verify.target'],
|
||
}, 'Register the Web MIDI keys detector');
|
||
if (p) _ndProviderRegistered = true;
|
||
}
|
||
|
||
// Hit/miss observability events (consumers own judgment; the domain
|
||
// only carries the result — spec 009 doctrine).
|
||
function _ndReport(hit, midi, bindingId) {
|
||
const nd = window.slopsmith && window.slopsmith.noteDetection;
|
||
if (!nd || nd.version !== 1) return;
|
||
try {
|
||
(hit ? nd.reportHit : nd.reportMiss)({
|
||
bindingId: bindingId || null,
|
||
providerId: ND_PROVIDER_ID,
|
||
midi,
|
||
hit,
|
||
});
|
||
} catch (_) { /* observability must never break scoring */ }
|
||
}
|
||
|
||
// ── audio-input: enumerate Web MIDI inputs as input sources ─────────
|
||
//
|
||
// Labels are PSEUDONYMIZED ('midi-input-1', …) — device names never
|
||
// leave the plugin (the audio-input contract surfaces labels verbatim
|
||
// in redaction-safe diagnostics).
|
||
let _aiRegisteredCount = 0;
|
||
|
||
function _aiSourceSpec(index) {
|
||
const n = index + 1;
|
||
return {
|
||
version: 1,
|
||
providerId: ND_PROVIDER_ID,
|
||
ownerPluginId: PLUGIN_ID,
|
||
sourceId: ND_PROVIDER_ID + ':input-' + n,
|
||
logicalSourceKey: ND_PROVIDER_ID + ':input-' + n,
|
||
label: 'midi-input-' + n,
|
||
labelSafe: true,
|
||
kind: 'instrument',
|
||
availability: 'available',
|
||
operations: ['source.enumerate', 'source.describe', 'source.open', 'source.close'],
|
||
operationHandlers: {
|
||
'source.enumerate': _aiEnumerate,
|
||
'source.describe': _aiDescribe,
|
||
'source.open': _aiOpen,
|
||
'source.close': _aiClose,
|
||
},
|
||
};
|
||
}
|
||
|
||
function _aiEnumerate() {
|
||
const sources = _midiListInputs().map((_inp, i) => {
|
||
const spec = _aiSourceSpec(i);
|
||
delete spec.operationHandlers; // host re-merges handlers per provider
|
||
return spec;
|
||
});
|
||
return { outcome: 'handled', payload: { sources } };
|
||
}
|
||
|
||
function _aiDescribe(req) {
|
||
const idx = _aiIndexFor(req && (req.sourceId || req.logicalSourceKey));
|
||
const inputs = _midiListInputs();
|
||
if (idx == null || idx >= inputs.length) {
|
||
return { outcome: 'degraded', reason: 'Unknown MIDI input source' };
|
||
}
|
||
const spec = _aiSourceSpec(idx);
|
||
delete spec.operationHandlers;
|
||
return { outcome: 'handled', payload: { source: spec } };
|
||
}
|
||
|
||
function _aiIndexFor(key) {
|
||
const m = /:input-(\d+)$/.exec(String(key || ''));
|
||
if (!m) return null;
|
||
const idx = parseInt(m[1], 10) - 1;
|
||
return (Number.isFinite(idx) && idx >= 0) ? idx : null;
|
||
}
|
||
|
||
function _aiOpen(req) {
|
||
// Opening a MIDI source connects the corresponding Web MIDI input.
|
||
// Programmatic open (audio-input source.open) — non-persisting: it must
|
||
// not rewrite the user's saved pick or the shared global default.
|
||
const idx = _aiIndexFor(req && (req.sourceId || req.logicalSourceKey));
|
||
const inputs = _midiSources(); // carries .key (logicalSourceKey), unlike _midiListInputs()
|
||
if (idx == null || idx >= inputs.length) {
|
||
return { outcome: 'failed', reason: 'MIDI input source is no longer present' };
|
||
}
|
||
_midiConnect(inputs[idx].id, inputs[idx].name, inputs[idx].key);
|
||
return { outcome: 'handled', payload: {} };
|
||
}
|
||
|
||
function _aiClose() {
|
||
// Closing the session must actually stop note events flowing —
|
||
// detach the input WITHOUT persisting an opt-out (the saved device
|
||
// pick is user state, not session state, so a later open or a
|
||
// device statechange can re-attach it).
|
||
if (_midiInput || _midiHandle) {
|
||
_midiDetach();
|
||
// No note-off can ever arrive for keys that were down at close,
|
||
// so release held/sustained state on every instance — otherwise
|
||
// those keys stay visually depressed until a later reconnect or
|
||
// teardown. (_midiNotifyDeviceListChanged only releases when the
|
||
// device VANISHED from the source list, which a deliberate
|
||
// close is not, so we release explicitly here.)
|
||
for (const inst of _instances) {
|
||
if (inst && typeof inst._releaseAllHeld === 'function') inst._releaseAllHeld();
|
||
}
|
||
_midiNotifyDeviceListChanged();
|
||
}
|
||
return { outcome: 'handled', payload: {} };
|
||
}
|
||
|
||
function _aiRefreshSources() {
|
||
// MIDI is no longer surfaced into the audio-input domain — keys MIDI now
|
||
// lives in the dedicated midi-input domain. Exporting pseudonymized
|
||
// 'midi-input-N' sources here polluted audio-input device pickers (e.g.
|
||
// the onboarding guitar input dropdown) with non-audio entries. Drop any
|
||
// left over from an older build, and register none going forward.
|
||
if (!_capsApi()) return;
|
||
// Iterate a fixed bound over the KNOWN sourceId pattern, not a module-local
|
||
// counter: after an in-page upgrade _aiRegisteredCount is reset to 0, so a
|
||
// count-based loop would skip the prior build's leftovers entirely. The
|
||
// sourceId/logicalSourceKey are the real (unpseudonymized) keys this plugin
|
||
// registered, so unregister resolves them directly; an absent source is a
|
||
// harmless no-op. 32 comfortably exceeds any realistic MIDI input count.
|
||
const MAX_LEGACY_MIDI_SOURCES = 32;
|
||
for (let n = 1; n <= MAX_LEGACY_MIDI_SOURCES; n++) {
|
||
const key = ND_PROVIDER_ID + ':input-' + n;
|
||
_capCommand('audio-input', 'unregister-source',
|
||
{ providerId: ND_PROVIDER_ID, sourceId: key, logicalSourceKey: key },
|
||
'MIDI no longer exported to audio-input');
|
||
}
|
||
_aiRegisteredCount = 0;
|
||
}
|
||
|
||
/* ======================================================================
|
||
* Camera Director bridge resolver (pure — exported via createFactory.__test)
|
||
* ====================================================================== */
|
||
|
||
/**
|
||
* The active splitscreen API, defensive on the global-name rename in flight
|
||
* (feedBackSplitscreen is canonical; slopsmithSplitscreen is the legacy alias).
|
||
* @returns {object|null} the splitscreen API, or null when not present
|
||
*/
|
||
function _ssApi() { return window.feedBackSplitscreen || window.slopsmithSplitscreen || null; }
|
||
|
||
/**
|
||
* Resolve the Camera Director camera for a canvas: this panel's camera under
|
||
* splitscreen, else the global, else null (Camera Director absent → 100% stock
|
||
* framing). Throw-safe on panelIndexFor so a misbehaving splitscreen build
|
||
* can't break framing.
|
||
* @param {HTMLCanvasElement} canvas this renderer's highway canvas
|
||
* @param {object|null} ss the splitscreen API (see _ssApi)
|
||
* @param {object|null} panelsMap window.__h3dCamCtlPanels (per-panel cameras by index)
|
||
* @param {object|null} globalCam window.__h3dCamCtl (single global camera)
|
||
* @returns {object|null} the resolved free-camera bridge, or null
|
||
*/
|
||
function _resolveFreeCam(canvas, ss, panelsMap, globalCam) {
|
||
if (panelsMap && ss && typeof ss.panelIndexFor === 'function') {
|
||
try {
|
||
const i = ss.panelIndexFor(canvas);
|
||
// Only a non-negative integer indexes the panel map — a non-int /
|
||
// negative / string index (or a prototype key) must not resolve an
|
||
// unintended/inherited property; fall through to the global then.
|
||
if (Number.isInteger(i) && i >= 0 && panelsMap[i]) return panelsMap[i];
|
||
} catch (e) { /* ignore */ }
|
||
}
|
||
return globalCam || null;
|
||
}
|
||
|
||
/* ======================================================================
|
||
* Renderer factory
|
||
* ====================================================================== */
|
||
|
||
function createFactory() {
|
||
let highwayCanvas = null;
|
||
let ren = null, scene = null, cam = null;
|
||
let _camX = 0, _camTargetX = 0; // camera x pan-follow state (updateScene)
|
||
let _camZoom = 1, _camTargetZoom = 1; // adaptive dolly-zoom to the note span
|
||
let notesGroup = null, keyboardGroup = null, markersGroup = null, hitLine = null;
|
||
let noteMeshes = []; // [{mesh, note, len}]
|
||
let markerSprites = []; // [{sprite, t}]
|
||
let keyMeshes = new Map(); // midi → mesh
|
||
let _isReady = false;
|
||
let _notation = null; // {notes, range, markers}
|
||
let _loadSeq = 0;
|
||
let _songHandler = null;
|
||
// Per-chart geometry/material caches. One bevelled geometry per
|
||
// (width, length-bucket) and one material per (pitch class, hand)
|
||
// keeps per-note cost down to a mesh; rebuilt per chart, disposed on
|
||
// rebuild + teardown.
|
||
const _noteGeoCache = new Map();
|
||
const _noteMatCache = new Map();
|
||
const _barTexCache = new Map(); // measure idx → CanvasTexture
|
||
let _glowTex = null; // hit-line gradient (lazy, reused)
|
||
const _hitGlowMats = []; // additive glow materials pulsed in draw()
|
||
|
||
// ── Visual FX state (guitar-highway parity) ─────────────────────
|
||
let fx = readFxSettings(); // live snapshot, mutated by 'keys3d:settings'
|
||
let _fxHandler = null;
|
||
let _fxThemeHandler = null;
|
||
// Host adaptive-quality scale (bundle.renderScale, 0.25–1) —
|
||
// multiplied into the device pixel ratio like highway_3d does.
|
||
let _renderScale = 1;
|
||
// Auto-resize fallback state (PORTED FROM highway_3d — keep in sync).
|
||
// The splitscreen host resizes the panel canvas but overrides
|
||
// hw.resize and never calls our resize(w,h), so draw() self-detects
|
||
// size drift. _lastHwW/H track the backing store last seen; _appliedW/H
|
||
// track the logical size last handed to applySize(); the countdown
|
||
// throttles the per-frame clientWidth/Height read.
|
||
let _lastHwW = 0, _lastHwH = 0;
|
||
let _appliedW = 0, _appliedH = 0;
|
||
let _boxCheckCountdown = 0;
|
||
// Bloom composer state (PORTED FROM highway_3d/screen.js — keep in sync).
|
||
let _composer = null;
|
||
let _bloomPass = null;
|
||
let _bloomLoad = null;
|
||
let _bloomW = 0, _bloomH = 0;
|
||
let _bloomGen = 0; // bumped by _bloomDispose so stale loads no-op
|
||
// DOM HUD (combo / accuracy / best streak — drum_highway_3d pattern).
|
||
let _hudEl = null;
|
||
let _hudParentOrigPosition = null;
|
||
|
||
// Hit-FX state. Sparks are PORTED FROM highway_3d (keep in sync);
|
||
// pool 96 — keys hits also fire a flame sprite, so sparks are the
|
||
// accent, not the whole show.
|
||
const _SPARK_N = 96;
|
||
let _sparkPts = null, _sparkPos = null, _sparkCol = null, _sparkVel = null, _sparkLife = null;
|
||
let _fxLastWall = 0; // wall clock for FX integration
|
||
let _hitGlowKick = 0; // hit-line brightness kick, decays exp(-t*6)
|
||
const _laneGuideMats = []; // lane guide materials (vibrancy slider)
|
||
|
||
// Theme/material handles (built by buildScene/buildKeyboardAndHighway;
|
||
// _applyTheme / _applyCinematic / the glow slider retune them live).
|
||
let _theme = readThemeSetting();
|
||
let _palette = readPaletteSetting();
|
||
let _sharpMode = readSharpModeSetting(); // 'floating' | 'flat' | 'realistic'
|
||
let ambLight = null, dirLight = null;
|
||
let _floorMat = null;
|
||
const _railMats = []; // lane-edge rail materials (theme laneDim)
|
||
let _envRT = null; // PMREM render target backing scene.environment
|
||
let _bgTex = null; // vertical-gradient background texture
|
||
|
||
// Background ambience (PORTED FROM highway_3d BG_STYLES subset via
|
||
// drum_highway_3d — keep in sync).
|
||
let _bgStyle = readBgStyleSetting();
|
||
let bgGroup = null;
|
||
let _bgState = null;
|
||
|
||
// Score-FX overlay (2D canvas sibling; drum_highway_3d pattern).
|
||
let _fxCanvas = null, _fxCtx = null;
|
||
let _fxDpr = 1; // backing-store scale (CSS→device px)
|
||
let _fxParentOrigPosition = null; // parent position to restore on teardown
|
||
let _scorePops = Array.from({ length: 12 }, () => ({ active: false, x: 0, z: 0, bornMs: 0, text: '' }));
|
||
const _SCORE_BURST_N = 36;
|
||
let _scoreBursts = Array.from({ length: 2 }, () => ({
|
||
active: false, bornMs: 0,
|
||
px: new Float32Array(_SCORE_BURST_N), py: new Float32Array(_SCORE_BURST_N),
|
||
vx: new Float32Array(_SCORE_BURST_N), vy: new Float32Array(_SCORE_BURST_N),
|
||
}));
|
||
let _scoreRingMs = -1e9;
|
||
let _scoreBreakMs = -1e9;
|
||
let _probe = null;
|
||
|
||
// ── MIDI scoring + live feedback state ──────────────────────────
|
||
let _layoutInfo = null; // {layout, whiteCount} of current chart
|
||
let _hits = 0, _misses = 0, _streak = 0, _bestStreak = 0;
|
||
const _hitNoteKeys = new Set(); // noteKey(t, midi) of hit chart notes
|
||
const _missedNoteKeys = new Set(); // …and swept-missed ones
|
||
const _sweepCursor = { idx: 0 }; // monotonic miss-sweep position
|
||
let _latestTime = 0; // song time from the last draw bundle
|
||
let _missFloor = null; // no retroactive misses before this t
|
||
const _rawToPlayed = new Map(); // raw midi → transposed midi (held)
|
||
const _heldNotes = new Set(); // transposed midis currently down
|
||
const _sustainedNotes = new Set(); // released while CC64 held
|
||
let _sustainOn = false;
|
||
const _keyAnim = new Map(); // midi → true while a key is animating
|
||
const _keyFlash = new Map(); // midi → wall ms of wrong-note flash
|
||
let _lastWallMs = 0; // wall clock of the previous draw
|
||
let _runMeta = null; // {filename, arrangement} of this run
|
||
let _recordedThisRun = false; // /api/stats posted exactly once
|
||
let _ndBindingId = null; // open note-detection binding
|
||
// Flame pool (built with the scene, reused for every hit).
|
||
let _flamesGroup = null;
|
||
const _flamePool = []; // [{sprite, mat, start, baseY}]
|
||
let _flameIdx = 0;
|
||
const _flameTexCache = new Map(); // pitch class → CanvasTexture
|
||
let _endHandler = null, _stopHandler = null;
|
||
|
||
function _emitDomain(event, payload) {
|
||
const caps = window.slopsmith && window.slopsmith.capabilities;
|
||
if (caps && caps.version === 1 && typeof caps.emitEvent === 'function') {
|
||
try { caps.emitEvent('visualization', event, payload || {}); } catch (_) {}
|
||
}
|
||
}
|
||
|
||
function isBlackKey(midi) {
|
||
return [1, 3, 6, 8, 10].includes(((midi % 12) + 12) % 12);
|
||
}
|
||
|
||
function _paletteColor(pc) {
|
||
return (NOTE_PALETTES[_palette] || PITCH_CLASS_COLORS)[pc];
|
||
}
|
||
|
||
// Base color (24-bit int, no hand dimming) for a midi note under the
|
||
// active palette — the octave scheme is procedural, every other
|
||
// palette is a 12-entry pitch-class table.
|
||
function _noteHex(midi) {
|
||
if (_palette === 'octaves') return octaveNoteColor(midi);
|
||
return _paletteColor(((midi % 12) + 12) % 12);
|
||
}
|
||
|
||
function noteColor(midi, hand) {
|
||
const base = new T.Color(_noteHex(midi));
|
||
const b = HAND_BRIGHTNESS[hand] != null ? HAND_BRIGHTNESS[hand] : 1.0;
|
||
base.multiplyScalar(b);
|
||
return base;
|
||
}
|
||
|
||
// X position of a key's centre. White keys advance one slot each;
|
||
// black keys sit between, narrower and raised.
|
||
function keyLayout(range) {
|
||
const layout = new Map();
|
||
let whiteIndex = 0;
|
||
for (let midi = range.low; midi <= range.high; midi++) {
|
||
if (isBlackKey(midi)) continue;
|
||
layout.set(midi, { slot: whiteIndex, black: false });
|
||
whiteIndex += 1;
|
||
}
|
||
for (let midi = range.low; midi <= range.high; midi++) {
|
||
if (!isBlackKey(midi)) continue;
|
||
const left = layout.get(midi - 1);
|
||
layout.set(midi, { slot: left ? left.slot + 0.5 : 0, black: true });
|
||
}
|
||
return { layout, whiteCount: whiteIndex };
|
||
}
|
||
|
||
const WHITE_W = 12 * K, WHITE_L = 46 * K, WHITE_H = 5 * K;
|
||
const BLACK_W = 6.4 * K, BLACK_L = 28 * K, BLACK_H = 6.5 * K;
|
||
const HIGHWAY_LEN = 1150 * K; // longer runway → ~8.8s of lookahead visible
|
||
// 'flat' piano-shaped-lane geometry (see laneSpanFlat). Zero-overlap tiling:
|
||
// white lanes are trimmed by FLAT_SHARP_HALF where they meet a sharp and
|
||
// the sharp fills the gap, so nothing overlaps. To keep the naturals
|
||
// even, each sharp is nudged FLAT_SHARP_SHIFT toward the EDGE natural
|
||
// beside it (C#→C, D#→E, F#→F, A#→B; G# stays centred, no edge to lean
|
||
// on) — that steals a sliver from the edge natural and hands it to the
|
||
// squeezed inner natural. At shift = sharpHalf/3 the C-D-E-F-B naturals
|
||
// come out exactly equal; G/A land a hair smaller (G# can't lean). The
|
||
// sharps ride the same flat plane (no lift — they never overlap a
|
||
// natural). OCT_GAP is the extra divider opened at each octave boundary
|
||
// when the octaveGaps option is on.
|
||
const FLAT_SHARP_HALF = 2.2 * K; // sharp half-width (4.4K wide)
|
||
const FLAT_SHARP_SHIFT = FLAT_SHARP_HALF / 3; // sharp lean that evens the naturals
|
||
const OCT_GAP = 0.9 * K;
|
||
const LANE_DIMS_FLAT = {
|
||
whiteW: WHITE_W, sharpHalf: FLAT_SHARP_HALF, shift: FLAT_SHARP_SHIFT, octGap: OCT_GAP,
|
||
};
|
||
// 'realistic' layout (laneSpanReal): every note bar is the size of the
|
||
// physical key it lands on — naturals the full white-key width (always
|
||
// rendered full, only occluded where a sharp note actually overlaps in
|
||
// time) and sharps the full black-key width at their standard positions,
|
||
// drawn on top with a hair of REAL_SHARP_LIFT (anti z-fight).
|
||
const REAL_NAT_HALF = WHITE_W * 0.47; // natural bar ≈ physical white key (~11.3K)
|
||
const REAL_SHARP_HALF = BLACK_W / 2; // sharp bar = physical black key (6.4K)
|
||
const REAL_SHARP_LIFT = 0.3 * K;
|
||
const LANE_DIMS_REAL = { natHalf: REAL_NAT_HALF, sharpHalf: REAL_SHARP_HALF, octGap: OCT_GAP };
|
||
// Lane span for the active non-floating sharp mode. `range`
|
||
// (activeLow/activeHigh) is optional and only consulted by the flat
|
||
// layout, to gate the boundary-key edge trim (see laneSpanFlat).
|
||
const _flatMode = () => _sharpMode === 'flat' || _sharpMode === 'realistic';
|
||
function laneSpanFor(midi, black, cx, gaps, range) {
|
||
return _sharpMode === 'realistic'
|
||
? laneSpanReal(midi, black, cx, LANE_DIMS_REAL, gaps)
|
||
: laneSpanFlat(midi, black, cx, LANE_DIMS_FLAT, gaps, range);
|
||
}
|
||
|
||
// Camera — the default 'classic' preset is a low, near-telephoto rig
|
||
// (RS+-style): a narrow FOV from low and back gives a deep receding
|
||
// runway and frames ~2 octaves instead of cramming the whole note
|
||
// range full-width. The x position pans to follow the active notes
|
||
// (see updateScene), so wide pieces stay zoomed in on the played hand
|
||
// rather than shrinking every key. The rig numbers now come from the
|
||
// user-selectable CAM_PRESETS table; switching applies live because
|
||
// position/lookAt are re-derived every frame.
|
||
let _camPreset = CAM_PRESETS[readCameraSetting()] || CAM_PRESETS.classic;
|
||
// Pan-follow: a slow ease toward a wide, gently-weighted centroid so the
|
||
// camera glides with the melody instead of darting as notes enter/leave.
|
||
const CAM_PAN_LERP = 0.022; // per-frame ease (~1s glide @60fps)
|
||
// Adaptive dolly-zoom: frame the FULL pitch span of the incoming notes and
|
||
// centre on its MIDPOINT, so both the lowest (LH) and highest (RH) target
|
||
// keys stay on the keyboard in view. Pull back for wide spreads (both
|
||
// hands), push in for tight passages. Camera dollies along its view ray
|
||
// (offset × zoom). BASE_KEYS is calibrated at the keyboard plane (the
|
||
// binding constraint — it sits in a narrower cone than the far notes), so
|
||
// framing the key span here keeps the target keys visible, not just the notes.
|
||
const CAM_ZOOM_AHEAD = 3.5; // seconds of notes considered for framing
|
||
const CAM_ZOOM_PAD = 9 * WHITE_W;// total margin beyond the span (~4.5 keys/side)
|
||
const CAM_ZOOM_BASE_KEYS = 11; // white keys framed at zoom = 1
|
||
const CAM_ZOOM_MIN = 0.9, CAM_ZOOM_MAX = 4.8;
|
||
const CAM_ZOOM_LERP = 0.025; // smooth zoom ease
|
||
// Full-swing of the camTilt aim offset (pre-K units) at slider ±1.
|
||
const CAM_TILT_UNITS = 55;
|
||
|
||
// Base rig with the live fine-tune knobs applied — height/distance
|
||
// multiply the preset, tilt offsets the aim height. Returns the
|
||
// effective {y, z, lookY, lookZ} in pre-K units; the caller scales by
|
||
// K and the auto-zoom. Keeps the pan/dolly follow-motion intact —
|
||
// these only move the vantage point it orbits around. Writes into a
|
||
// reusable object (returned live) so the per-frame camera update stays
|
||
// allocation-free — the callers read it synchronously and never retain
|
||
// it, so a single shared instance is safe.
|
||
const _rigOut = { y: 0, z: 0, lookY: 0, lookZ: 0 };
|
||
function _rig() {
|
||
_rigOut.y = _camPreset.y * fx.camHeight;
|
||
_rigOut.z = _camPreset.z * fx.camDist;
|
||
_rigOut.lookY = _camPreset.lookY + fx.camTilt * CAM_TILT_UNITS;
|
||
_rigOut.lookZ = _camPreset.lookZ;
|
||
return _rigOut;
|
||
}
|
||
|
||
/**
|
||
* Camera Director bridge for THIS panel — delegates to the pure, unit-
|
||
* tested _resolveFreeCam / _ssApi (resolver block above the factory).
|
||
* Reads the live globals: per-panel map __h3dCamCtlPanels → this panel's
|
||
* camera, else the global __h3dCamCtl, else null (stock framing).
|
||
* @param {HTMLCanvasElement} canvas this panel's highway canvas
|
||
* @returns {object|null} the resolved free-camera bridge, or null
|
||
*/
|
||
function _freeCamFor(canvas) {
|
||
return _resolveFreeCam(canvas, _ssApi(), window.__h3dCamCtlPanels, window.__h3dCamCtl);
|
||
}
|
||
// Per-key approach glow: a key lights in its pitch-class color ONLY while a
|
||
// note is heading for it, ramping up the closer that note gets to the hit-line.
|
||
const KEY_GLOW_AHEAD = 2.0; // seconds before the hit-line a key starts to light
|
||
const KEY_GLOW_STRENGTH = 1.15; // peak emissive intensity (note at the hit-line)
|
||
|
||
function keyX(layoutEntry, whiteCount) {
|
||
return (layoutEntry.slot - (whiteCount - 1) / 2) * WHITE_W;
|
||
}
|
||
|
||
/* -- background ambience (PORTED FROM highway_3d BG_STYLES subset
|
||
* via drum_highway_3d — keep formulas in sync) -- */
|
||
const BG_STYLES = {
|
||
off: {
|
||
build() { return null; },
|
||
update() {},
|
||
teardown() {},
|
||
},
|
||
particles: {
|
||
build(group, settings) {
|
||
const N = Math.max(20, Math.floor(80 + 200 * settings.intensity));
|
||
const positions = new Float32Array(N * 3);
|
||
for (let i = 0; i < N; i++) {
|
||
positions[i * 3] = (Math.random() - 0.5) * 800 * K;
|
||
positions[i * 3 + 1] = (Math.random() - 0.4) * 80 * K;
|
||
positions[i * 3 + 2] = -FOG_START - Math.random() * (FOG_END - FOG_START) * 0.85;
|
||
}
|
||
const geo = new T.BufferGeometry();
|
||
geo.setAttribute('position', new T.BufferAttribute(positions, 3));
|
||
const mat = new T.PointsMaterial({
|
||
color: 0xa0c0ff, size: 5 * K, transparent: true,
|
||
blending: T.AdditiveBlending, depthWrite: false, sizeAttenuation: true,
|
||
});
|
||
const points = new T.Points(geo, mat);
|
||
points.renderOrder = -1;
|
||
points.frustumCulled = false;
|
||
group.add(points);
|
||
return { points, geo, mat, N };
|
||
},
|
||
update(s, bands, dt) {
|
||
const positions = s.geo.attributes.position.array;
|
||
const dx = dt * (3 + bands.mid * 12) * K;
|
||
for (let i = 0; i < s.N; i++) {
|
||
positions[i * 3] += dx;
|
||
if (positions[i * 3] > 400 * K) positions[i * 3] -= 800 * K;
|
||
}
|
||
s.geo.attributes.position.needsUpdate = true;
|
||
s.mat.opacity = 0.55 + bands.treble * 0.45;
|
||
},
|
||
teardown(s) {
|
||
if (!s) return;
|
||
if (s.points.parent) s.points.parent.remove(s.points);
|
||
s.geo.dispose();
|
||
s.mat.dispose();
|
||
},
|
||
},
|
||
lights: {
|
||
build(group, settings) {
|
||
const N = Math.floor(6 + 8 * settings.intensity);
|
||
const lights = [];
|
||
const palette = settings.palette || [0xa0c0ff];
|
||
for (let i = 0; i < N; i++) {
|
||
const color = palette[i % palette.length];
|
||
const geo = new T.PlaneGeometry(30 * K, 30 * K);
|
||
const mat = new T.MeshBasicMaterial({
|
||
color, transparent: true,
|
||
blending: T.AdditiveBlending, depthWrite: false,
|
||
});
|
||
const mesh = new T.Mesh(geo, mat);
|
||
mesh.renderOrder = -1;
|
||
mesh.position.set(
|
||
(Math.random() - 0.5) * 600 * K,
|
||
(Math.random() - 0.3) * 80 * K,
|
||
-FOG_START - Math.random() * (FOG_END - FOG_START) * 0.85
|
||
);
|
||
group.add(mesh);
|
||
lights.push({ mesh, geo, mat, baseScale: 1 + Math.random() * 0.5, phase: Math.random() * Math.PI * 2 });
|
||
}
|
||
return { lights };
|
||
},
|
||
update(s, bands, dt, t) {
|
||
for (const L of s.lights) {
|
||
const pulse = 1 + bands.bass * 1.5 + Math.sin(t * 1.5 + L.phase) * 0.2;
|
||
L.mesh.scale.set(L.baseScale * pulse, L.baseScale * pulse, 1);
|
||
L.mat.opacity = 0.55 + bands.treble * 0.4;
|
||
}
|
||
},
|
||
teardown(s) {
|
||
if (!s) return;
|
||
for (const L of s.lights) {
|
||
if (L.mesh.parent) L.mesh.parent.remove(L.mesh);
|
||
L.geo.dispose();
|
||
L.mat.dispose();
|
||
}
|
||
},
|
||
},
|
||
geometric: {
|
||
build(group, settings) {
|
||
const meshes = [];
|
||
const op = 0.45 + 0.25 * settings.intensity;
|
||
const ico = new T.Mesh(
|
||
new T.IcosahedronGeometry(30 * K, 1),
|
||
new T.MeshBasicMaterial({ color: 0x6080c0, wireframe: true, transparent: true, opacity: op, depthWrite: false }),
|
||
);
|
||
ico.position.set(-100 * K, 30 * K, -FOG_END * 0.65);
|
||
ico.renderOrder = -1;
|
||
group.add(ico);
|
||
meshes.push(ico);
|
||
const torus = new T.Mesh(
|
||
new T.TorusGeometry(22 * K, 4 * K, 6, 12),
|
||
new T.MeshBasicMaterial({ color: 0xc06080, wireframe: true, transparent: true, opacity: op * 0.9, depthWrite: false }),
|
||
);
|
||
torus.position.set(120 * K, 20 * K, -FOG_END * 0.75);
|
||
torus.renderOrder = -1;
|
||
group.add(torus);
|
||
meshes.push(torus);
|
||
return { meshes };
|
||
},
|
||
update(s, bands, dt) {
|
||
const speed = 0.2 + bands.mid * 0.4;
|
||
const pulse = 1 + bands.bass * 0.25;
|
||
for (const m of s.meshes) {
|
||
m.rotation.x += dt * speed * 0.3;
|
||
m.rotation.y += dt * speed * 0.4;
|
||
m.scale.setScalar(pulse);
|
||
}
|
||
},
|
||
teardown(s) {
|
||
if (!s) return;
|
||
for (const m of s.meshes) {
|
||
if (m.parent) m.parent.remove(m);
|
||
m.geometry.dispose();
|
||
m.material.dispose();
|
||
}
|
||
},
|
||
},
|
||
};
|
||
|
||
function _bgMountStyle() {
|
||
if (!scene || !T) return;
|
||
if (!bgGroup) {
|
||
bgGroup = new T.Group();
|
||
scene.add(bgGroup);
|
||
}
|
||
if (_bgState && _bgState._style && BG_STYLES[_bgState._style]) {
|
||
try { BG_STYLES[_bgState._style].teardown(_bgState.s); } catch (_) {}
|
||
}
|
||
const style = BG_STYLES[_bgStyle] || BG_STYLES.off;
|
||
let s = null;
|
||
try {
|
||
s = style.build(bgGroup, { intensity: Math.min(1, Math.max(0, fx.bgIntensity)), palette: PITCH_CLASS_COLORS });
|
||
} catch (e) {
|
||
console.warn('[Keys-Hwy3D] bg style build failed', e);
|
||
s = null;
|
||
}
|
||
_bgState = { _style: _bgStyle, s };
|
||
}
|
||
function _bgTeardownStyle() {
|
||
if (_bgState && _bgState._style && BG_STYLES[_bgState._style]) {
|
||
try { BG_STYLES[_bgState._style].teardown(_bgState.s); } catch (_) {}
|
||
}
|
||
_bgState = null;
|
||
bgGroup = null;
|
||
}
|
||
|
||
/* -- score-FX overlay (drum_highway_3d pattern — keep in sync) -- */
|
||
function _ensureFxCanvas() {
|
||
if (_fxCanvas || !highwayCanvas) return;
|
||
const parent = highwayCanvas.parentElement;
|
||
if (!parent) return;
|
||
const cur = parent.style.position || getComputedStyle(parent).position;
|
||
if (cur === 'static' || !cur) {
|
||
// Record the original inline value (usually '') so teardown
|
||
// can undo this layout mutation — same contract as the HUD.
|
||
_fxParentOrigPosition = parent.style.position;
|
||
parent.style.position = 'relative';
|
||
}
|
||
_fxCanvas = document.createElement('canvas');
|
||
_fxCanvas.className = 'keys-h3d-fx';
|
||
_fxCanvas.style.cssText = 'position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none;z-index:5';
|
||
parent.appendChild(_fxCanvas);
|
||
_fxCtx = _fxCanvas.getContext('2d');
|
||
_sizeFxCanvas();
|
||
}
|
||
function _sizeFxCanvas() {
|
||
if (!_fxCanvas || !highwayCanvas) return;
|
||
const w = highwayCanvas.clientWidth | 0, h = highwayCanvas.clientHeight | 0;
|
||
if (!w || !h) return;
|
||
const r = Math.min(window.devicePixelRatio || 1, 2);
|
||
if (_fxCanvas.width !== (w * r | 0) || _fxCanvas.height !== (h * r | 0)) {
|
||
_fxCanvas.width = w * r | 0;
|
||
_fxCanvas.height = h * r | 0;
|
||
}
|
||
// The draw path works in CSS pixels and scales the context by
|
||
// this — fixed font/line sizes stay physically consistent on
|
||
// HiDPI instead of shrinking with the backing store.
|
||
_fxDpr = r;
|
||
}
|
||
function _removeFxCanvas() {
|
||
if (_fxCanvas && _fxCanvas.parentNode) {
|
||
const parent = _fxCanvas.parentNode;
|
||
parent.removeChild(_fxCanvas);
|
||
// Restore position only if _ensureFxCanvas changed it (and
|
||
// nothing else — e.g. the HUD — still needs it; the HUD sets
|
||
// and restores its own copy of the same guard first).
|
||
if (_fxParentOrigPosition !== null) {
|
||
parent.style.position = _fxParentOrigPosition;
|
||
_fxParentOrigPosition = null;
|
||
}
|
||
}
|
||
_fxCanvas = null;
|
||
_fxCtx = null;
|
||
}
|
||
function _scoreSpawnPop(midi) {
|
||
if (!_layoutInfo) return;
|
||
const entry = _layoutInfo.layout.get(midi);
|
||
if (!entry) return;
|
||
const nowMs = performance.now();
|
||
for (const pop of _scorePops) {
|
||
if (pop.active) continue;
|
||
pop.active = true;
|
||
pop.x = keyX(entry, _layoutInfo.whiteCount);
|
||
pop.z = -WHITE_L / 2;
|
||
pop.bornMs = nowMs;
|
||
pop.text = '+1';
|
||
return;
|
||
}
|
||
}
|
||
function _scoreSpawnBurst(nowMs) {
|
||
for (const b of _scoreBursts) {
|
||
if (b.active) continue;
|
||
b.active = true;
|
||
b.bornMs = nowMs;
|
||
for (let j = 0; j < _SCORE_BURST_N; j++) {
|
||
const a = (j / _SCORE_BURST_N) * Math.PI * 2;
|
||
const sp = 2 + (j % 5) * 0.8;
|
||
b.px[j] = 0; b.py[j] = 0;
|
||
b.vx[j] = Math.cos(a) * sp;
|
||
b.vy[j] = Math.sin(a) * sp - 1.2;
|
||
}
|
||
return;
|
||
}
|
||
}
|
||
function _scoreOnHit(midi) {
|
||
if (!fx.scoreFx) return;
|
||
const nowMs = performance.now();
|
||
_scoreSpawnPop(midi);
|
||
if (_streak > 0 && _streak % 10 === 0) _scoreRingMs = nowMs;
|
||
if (_streak === 25 || _streak === 50 || _streak === 100) _scoreSpawnBurst(nowMs);
|
||
}
|
||
function _scoreOnBreak(prevStreak) {
|
||
if (!fx.scoreFx) return;
|
||
if (prevStreak >= 3) _scoreBreakMs = performance.now();
|
||
}
|
||
function _drawScoreFx() {
|
||
if (!_fxCtx || !_fxCanvas || !cam) return;
|
||
// CSS-pixel coordinate space; the context transform applies the
|
||
// DPR so strokes/fonts render at their intended physical size.
|
||
const r = _fxDpr || 1;
|
||
const W = _fxCanvas.width / r, H = _fxCanvas.height / r;
|
||
_fxCtx.setTransform(r, 0, 0, r, 0, 0);
|
||
const nowMs = performance.now();
|
||
let anyPop = false;
|
||
for (const pop of _scorePops) if (pop.active) { anyPop = true; break; }
|
||
let anyBurst = false;
|
||
for (const b of _scoreBursts) if (b.active) { anyBurst = true; break; }
|
||
const ringAge = nowMs - _scoreRingMs;
|
||
const breakAge = nowMs - _scoreBreakMs;
|
||
if (!anyPop && !anyBurst && ringAge >= 600 && breakAge >= 350) {
|
||
if (_fxCanvas._dirty) { _fxCtx.clearRect(0, 0, W, H); _fxCanvas._dirty = false; }
|
||
return;
|
||
}
|
||
_fxCanvas._dirty = true;
|
||
if (!_probe) _probe = new T.Vector3();
|
||
const ctx = _fxCtx;
|
||
ctx.clearRect(0, 0, W, H);
|
||
ctx.save();
|
||
if (breakAge < 350) {
|
||
ctx.fillStyle = '#ef4444';
|
||
ctx.globalAlpha = 0.10 * (1 - breakAge / 350);
|
||
ctx.fillRect(0, 0, W, H);
|
||
ctx.globalAlpha = 1;
|
||
}
|
||
let cx = W / 2, cy = H * 0.72, centerOk = false;
|
||
_probe.set(0, WHITE_H + 0.5 * K, -WHITE_L / 2);
|
||
_probe.project(cam);
|
||
if (_probe.z >= -1 && _probe.z <= 1) {
|
||
cx = (_probe.x * 0.5 + 0.5) * W;
|
||
cy = (-_probe.y * 0.5 + 0.5) * H;
|
||
centerOk = true;
|
||
}
|
||
if (centerOk && ringAge < 600) {
|
||
const t = ringAge / 600;
|
||
const ease = 1 - Math.pow(1 - t, 2);
|
||
ctx.beginPath();
|
||
ctx.arc(cx, cy, 20 + ease * Math.min(W, H) * 0.28, 0, Math.PI * 2);
|
||
ctx.strokeStyle = _streak >= 30 ? '#fde047' : '#86efac';
|
||
ctx.globalAlpha = 0.6 * (1 - t);
|
||
ctx.lineWidth = 3;
|
||
ctx.stroke();
|
||
ctx.globalAlpha = 1;
|
||
}
|
||
if (anyBurst && centerOk) {
|
||
for (const b of _scoreBursts) {
|
||
if (!b.active) continue;
|
||
const age = nowMs - b.bornMs;
|
||
if (age >= 900) { b.active = false; continue; }
|
||
const t = age / 900;
|
||
ctx.globalAlpha = 1 - t;
|
||
for (let j = 0; j < _SCORE_BURST_N; j++) {
|
||
b.px[j] += b.vx[j];
|
||
b.py[j] += b.vy[j];
|
||
b.vy[j] += 0.08;
|
||
ctx.fillStyle = (j & 1) ? '#fde047' : '#86efac';
|
||
ctx.fillRect(cx + b.px[j] - 2, cy + b.py[j] - 2, 4, 4);
|
||
}
|
||
ctx.globalAlpha = 1;
|
||
}
|
||
}
|
||
if (anyPop) {
|
||
ctx.textAlign = 'center';
|
||
ctx.textBaseline = 'middle';
|
||
for (const pop of _scorePops) {
|
||
if (!pop.active) continue;
|
||
const age = nowMs - pop.bornMs;
|
||
if (age >= 800) { pop.active = false; continue; }
|
||
_probe.set(pop.x, WHITE_H + 2 * K, pop.z);
|
||
_probe.project(cam);
|
||
if (_probe.z < -1 || _probe.z > 1) continue;
|
||
const t = age / 800;
|
||
const sx = (_probe.x * 0.5 + 0.5) * W;
|
||
const sy = (-_probe.y * 0.5 + 0.5) * H - t * 30;
|
||
ctx.globalAlpha = t < 0.4 ? 1 : 1 - (t - 0.4) / 0.6;
|
||
ctx.font = 'bold 15px system-ui, sans-serif';
|
||
ctx.lineWidth = 4;
|
||
ctx.strokeStyle = 'rgba(0,0,0,0.8)';
|
||
ctx.strokeText(pop.text, sx, sy);
|
||
ctx.fillStyle = '#86efac';
|
||
ctx.fillText(pop.text, sx, sy);
|
||
}
|
||
ctx.globalAlpha = 1;
|
||
}
|
||
ctx.restore();
|
||
}
|
||
|
||
// Emissive multiplier from the Glow slider — 0.5 is neutral (the
|
||
// stock look), so `base * _glowMul()` leaves defaults byte-identical.
|
||
function _glowMul() {
|
||
return Math.min(1, Math.max(0, fx.glow)) * 2;
|
||
}
|
||
|
||
// Cinematic lighting: dimmer ambient + stronger key light. Off = the
|
||
// plugin's pre-parity values.
|
||
function _applyCinematic() {
|
||
if (!ambLight || !dirLight) return;
|
||
ambLight.intensity = fx.cinematic ? 0.55 : 0.75;
|
||
dirLight.intensity = fx.cinematic ? 1.3 : 1.1;
|
||
}
|
||
|
||
// Procedural "studio" environment for image-based lighting — the
|
||
// anti-plastic core: the clearcoat note gems and the piano-black
|
||
// keys need something to reflect. PORTED FROM drum_highway_3d
|
||
// _makeStudioEnv — keep in sync. Returns the PMREM RT or null.
|
||
function _makeStudioEnv(ThreeLib, renderer) {
|
||
if (!renderer) return null;
|
||
try {
|
||
const envScene = new ThreeLib.Scene();
|
||
const own = [];
|
||
const shellGeo = new ThreeLib.BoxGeometry(100, 100, 100);
|
||
const shellMat = new ThreeLib.MeshBasicMaterial({ color: 0x11131c, side: ThreeLib.BackSide });
|
||
envScene.add(new ThreeLib.Mesh(shellGeo, shellMat));
|
||
own.push(shellGeo, shellMat);
|
||
const strip = (w, h, hex, intensity, x, y, z, rx, ry) => {
|
||
const g = new ThreeLib.PlaneGeometry(w, h);
|
||
// DoubleSide: orientation-proof — the bake only runs once,
|
||
// so the extra faces are free insurance.
|
||
const m = new ThreeLib.MeshBasicMaterial({ color: hex, side: ThreeLib.DoubleSide });
|
||
m.color.multiplyScalar(intensity);
|
||
const mesh = new ThreeLib.Mesh(g, m);
|
||
mesh.position.set(x, y, z);
|
||
mesh.rotation.set(rx, ry, 0);
|
||
envScene.add(mesh);
|
||
own.push(g, m);
|
||
};
|
||
// Deliberate delta from drum_highway_3d: the strips run at
|
||
// a third of the drum scene's intensity — this scene is
|
||
// dominated by a WHITE keyboard and a sheened floor, and the
|
||
// drum-strength strips overexpose it into a milky bloom
|
||
// flood (screenshot-verified). The blacks' glints survive.
|
||
strip(60, 8, 0xdfe8ff, 6, 0, 45, 0, Math.PI / 2, 0); // overhead key
|
||
strip(30, 50, 0xffd9a8, 2.5, -48, 5, 0, 0, Math.PI / 2); // warm left wall
|
||
strip(30, 50, 0x9fd8ff, 2.5, 48, 5, 0, 0, -Math.PI / 2); // cool right wall
|
||
const pmrem = new ThreeLib.PMREMGenerator(renderer);
|
||
const rt = pmrem.fromScene(envScene, 0.04);
|
||
pmrem.dispose();
|
||
for (const r of own) r.dispose();
|
||
return rt;
|
||
} catch (e) {
|
||
console.warn('[Keys-Hwy3D] studio env failed (continuing without IBL)', e);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function _disposeEnv() {
|
||
if (_envRT) { try { _envRT.dispose(); } catch (_) {} _envRT = null; }
|
||
if (_bgTex) { try { _bgTex.dispose(); } catch (_) {} _bgTex = null; }
|
||
}
|
||
|
||
// Vertical-gradient background: lighter above the horizon, the theme
|
||
// clear at the midline, darker toward the keyboard — depth the flat
|
||
// clear color never had. sRGB-tagged so the composer's output
|
||
// transform reads it correctly.
|
||
function _makeBgTexture(clearHex) {
|
||
const cnv = document.createElement('canvas');
|
||
cnv.width = 2; cnv.height = 256;
|
||
const ctx = cnv.getContext('2d');
|
||
const c = new T.Color(clearHex);
|
||
const top = c.clone().lerp(new T.Color(0xffffff), 0.10);
|
||
const bottom = c.clone().multiplyScalar(0.55);
|
||
const grad = ctx.createLinearGradient(0, 0, 0, 256);
|
||
grad.addColorStop(0, '#' + top.getHexString());
|
||
grad.addColorStop(0.55, '#' + c.getHexString());
|
||
grad.addColorStop(1, '#' + bottom.getHexString());
|
||
ctx.fillStyle = grad;
|
||
ctx.fillRect(0, 0, 2, 256);
|
||
const tex = new T.CanvasTexture(cnv);
|
||
tex.colorSpace = T.SRGBColorSpace;
|
||
return tex;
|
||
}
|
||
|
||
// Apply the active theme to the live scene. Pitch-class note/key
|
||
// colors are identity — themes own the scene, not the notes.
|
||
function _applyTheme() {
|
||
const c = _bgThemeColors(_theme);
|
||
if (scene) {
|
||
if (_bgTex) { try { _bgTex.dispose(); } catch (_) {} }
|
||
_bgTex = _makeBgTexture(c.clear);
|
||
scene.background = _bgTex;
|
||
if (scene.fog) scene.fog.color.setHex(c.fog);
|
||
}
|
||
if (ren) ren.setClearColor(c.clear, 1);
|
||
if (_floorMat) _floorMat.color.setHex(c.board);
|
||
for (const m of _railMats) m.color.setHex(c.laneDim != null ? c.laneDim : 0x2a2a3e);
|
||
}
|
||
|
||
function buildScene() {
|
||
scene = new T.Scene();
|
||
// Theme-driven gradient background (an explicit scene.background
|
||
// is also what keeps the composer's ACES output from washing out
|
||
// a bare clear color) + matching fog.
|
||
const themeCols = _bgThemeColors(_theme);
|
||
_bgTex = _makeBgTexture(themeCols.clear);
|
||
scene.background = _bgTex;
|
||
scene.fog = new T.Fog(themeCols.fog, FOG_START, FOG_END);
|
||
if (ren) ren.setClearColor(themeCols.clear, 1);
|
||
|
||
// Studio environment map — image-based lighting for the
|
||
// clearcoat gems / piano-black keys. Renderer-bound; disposed in
|
||
// teardown via _disposeEnv.
|
||
_envRT = _makeStudioEnv(T, ren);
|
||
if (_envRT) scene.environment = _envRT.texture;
|
||
|
||
cam = new T.PerspectiveCamera(_camPreset.fov, 1, 0.1, 2000 * K);
|
||
_camX = 0; _camTargetX = 0; _camZoom = 1; _camTargetZoom = 1;
|
||
{ const r = _rig(); cam.position.set(0, r.y * K, r.z * K); cam.lookAt(0, r.lookY * K, r.lookZ * K); }
|
||
|
||
ambLight = new T.AmbientLight(0xffffff, 0.75);
|
||
dirLight = new T.DirectionalLight(0xffffff, 1.1);
|
||
dirLight.position.set(60 * K, 200 * K, 80 * K);
|
||
scene.add(ambLight, dirLight);
|
||
_applyCinematic();
|
||
|
||
keyboardGroup = new T.Group();
|
||
notesGroup = new T.Group();
|
||
markersGroup = new T.Group();
|
||
_flamesGroup = new T.Group();
|
||
scene.add(keyboardGroup, notesGroup, markersGroup, _flamesGroup);
|
||
_buildFlamePool();
|
||
|
||
// Hit sparks (PORTED FROM highway_3d): pooled additive Points
|
||
// cloud, burst at the struck key, integrated in draw(). Same
|
||
// coordinate space as the flames (keyX / hit-line z).
|
||
_sparkPos = new Float32Array(_SPARK_N * 3); _sparkCol = new Float32Array(_SPARK_N * 3);
|
||
_sparkVel = new Float32Array(_SPARK_N * 3); _sparkLife = new Float32Array(_SPARK_N);
|
||
{
|
||
const sg = new T.BufferGeometry();
|
||
sg.setAttribute('position', new T.BufferAttribute(_sparkPos, 3).setUsage(T.DynamicDrawUsage));
|
||
sg.setAttribute('color', new T.BufferAttribute(_sparkCol, 3).setUsage(T.DynamicDrawUsage));
|
||
const sm = new T.PointsMaterial({ size: 1.0 * K, vertexColors: true, transparent: true, opacity: 0.8, depthWrite: false, blending: T.AdditiveBlending, sizeAttenuation: true });
|
||
_sparkPts = new T.Points(sg, sm); _sparkPts.frustumCulled = false; _sparkPts.renderOrder = 8;
|
||
scene.add(_sparkPts);
|
||
}
|
||
|
||
// Background ambience behind everything (renderOrder -1).
|
||
_bgMountStyle();
|
||
}
|
||
|
||
// Drop every child of a group (recursively — key glyphs are children
|
||
// of their key mesh so depress animation carries them), disposing
|
||
// geometry + material. Cached resources (note geometries/materials,
|
||
// bar/glyph textures) may be disposed again via their caches —
|
||
// three.js dispose() is safe to call more than once. Texture maps
|
||
// held by caches are NOT disposed here.
|
||
function _disposeDeep(obj) {
|
||
while (obj.children && obj.children.length) {
|
||
_disposeDeep(obj.children.pop());
|
||
}
|
||
if (obj.geometry) obj.geometry.dispose();
|
||
if (obj.material && obj.material.dispose) obj.material.dispose();
|
||
}
|
||
function _clearGroup(group) {
|
||
if (!group) return;
|
||
while (group.children.length) {
|
||
_disposeDeep(group.children.pop());
|
||
}
|
||
}
|
||
|
||
function _clearNoteCaches() {
|
||
for (const g of _noteGeoCache.values()) g.dispose();
|
||
_noteGeoCache.clear();
|
||
for (const m of _noteMatCache.values()) m.dispose();
|
||
_noteMatCache.clear();
|
||
}
|
||
|
||
function _clearBarTextures() {
|
||
for (const t of _barTexCache.values()) t.dispose();
|
||
_barTexCache.clear();
|
||
}
|
||
|
||
// Bevelled "gem" geometry for a note block: a rectangular cross-
|
||
// section (w × NOTE_H) extruded along Z for the note's length with a
|
||
// small bevel, so edges catch the light. Cached per (width,
|
||
// length-bucket) — lengths quantised to 0.01 world units so charts
|
||
// share geometries instead of allocating one per note.
|
||
// Rounded-rectangle shape (centred) — reused for gem cross-sections and
|
||
// rounded key footprints.
|
||
function _roundedRectShape(w, h, r) {
|
||
r = Math.max(0, Math.min(r, w / 2 - 0.001, h / 2 - 0.001));
|
||
const hw = w / 2, hh = h / 2, s = new T.Shape();
|
||
s.moveTo(-hw + r, -hh);
|
||
s.lineTo(hw - r, -hh);
|
||
s.quadraticCurveTo(hw, -hh, hw, -hh + r);
|
||
s.lineTo(hw, hh - r);
|
||
s.quadraticCurveTo(hw, hh, hw - r, hh);
|
||
s.lineTo(-hw + r, hh);
|
||
s.quadraticCurveTo(-hw, hh, -hw, hh - r);
|
||
s.lineTo(-hw, -hh + r);
|
||
s.quadraticCurveTo(-hw, -hh, -hw + r, -hh);
|
||
return s;
|
||
}
|
||
|
||
// Rounded box (w×h×d, centred) — rounded-rect footprint extruded along
|
||
// height with a bevel, so vertical corners are rounded and the top/bottom
|
||
// edges softened. Core-three only (no RoundedBoxGeometry addon). Used for
|
||
// the piano keys.
|
||
function _roundedBoxGeo(w, h, d, r, bev) {
|
||
bev = Math.max(0.001, Math.min(bev, h / 2 - 0.001));
|
||
const g = new T.ExtrudeGeometry(_roundedRectShape(w, d, r), {
|
||
depth: Math.max(h - 2 * bev, 0.001),
|
||
bevelEnabled: true, bevelThickness: bev, bevelSize: bev,
|
||
bevelSegments: 3, curveSegments: 5,
|
||
});
|
||
g.computeBoundingBox();
|
||
const bz = g.boundingBox;
|
||
g.translate(0, 0, -(bz.min.z + bz.max.z) / 2); // centre the height axis
|
||
g.rotateX(-Math.PI / 2); // stand up: height → +Y
|
||
return g;
|
||
}
|
||
|
||
function _noteGeometry(w, len) {
|
||
const key = w.toFixed(4) + '|' + len.toFixed(2);
|
||
let geo = _noteGeoCache.get(key);
|
||
if (geo) return geo;
|
||
const bevel = Math.min(NOTE_BEVEL, w * 0.25, len * 0.25, NOTE_H * 0.35);
|
||
const hw = w / 2 - bevel;
|
||
const hh = NOTE_H / 2 - bevel;
|
||
// Lightly rounded cross-section — soften the long edges without the
|
||
// pill/plastic look.
|
||
const shape = _roundedRectShape(2 * hw, 2 * hh, Math.min(hw, hh) * 0.3);
|
||
const depth = Math.max(len - 2 * bevel, bevel);
|
||
geo = new T.ExtrudeGeometry(shape, {
|
||
depth,
|
||
bevelEnabled: true,
|
||
bevelThickness: bevel,
|
||
bevelSize: bevel,
|
||
bevelSegments: 3,
|
||
curveSegments: 5,
|
||
});
|
||
// Extrusion spans z ∈ [-bevel, depth + bevel]; centre it.
|
||
geo.translate(0, 0, -depth / 2);
|
||
// Bake a vertical brightness ramp into vertex colors (bottom shade →
|
||
// top highlight) so the gem reads 3D; the material multiplies its
|
||
// pitch-class color by this (vertexColors).
|
||
geo.computeBoundingBox();
|
||
const y0 = geo.boundingBox.min.y, yr = (geo.boundingBox.max.y - y0) || 1;
|
||
const pos = geo.attributes.position;
|
||
const cols = new Float32Array(pos.count * 3);
|
||
for (let i = 0; i < pos.count; i++) {
|
||
const t = (pos.getY(i) - y0) / yr; // 0 bottom .. 1 top
|
||
const v = GEM_SHADE_BOT + (GEM_SHADE_TOP - GEM_SHADE_BOT) * (t * t * (3 - 2 * t));
|
||
cols[i * 3] = cols[i * 3 + 1] = cols[i * 3 + 2] = v;
|
||
}
|
||
geo.setAttribute('color', new T.BufferAttribute(cols, 3));
|
||
_noteGeoCache.set(key, geo);
|
||
return geo;
|
||
}
|
||
|
||
// Glossy note material, cached per resolved color. Keying by the
|
||
// final color int (hand brightness already baked in by noteColor)
|
||
// works for every palette — including 'octaves', where two notes of
|
||
// the same pitch class in different octaves are DIFFERENT colors and
|
||
// must not share a material (a pitch-class key would collide them).
|
||
function _noteMaterial(midi, hand) {
|
||
const col = noteColor(midi, hand);
|
||
const key = col.getHex();
|
||
let mat = _noteMatCache.get(key);
|
||
if (mat) return mat;
|
||
// MeshPhysicalMaterial with a clearcoat: lacquered glass-gem
|
||
// look — a sharp coat highlight over a colored body, lit by the
|
||
// studio env map. This is the "not plastic" ask: the old matte
|
||
// MeshStandard (roughness 0.78, no envMap) had a dead surface.
|
||
// (~20-30% more fragment cost than MeshStandard on note pixels;
|
||
// acceptable — notes cover a modest screen fraction and all
|
||
// share one shader program.)
|
||
mat = new T.MeshPhysicalMaterial({
|
||
color: col,
|
||
vertexColors: true, // multiply color by the baked gem ramp
|
||
emissive: col,
|
||
emissiveIntensity: NOTE_EMISSIVE_BASE * _glowMul(),
|
||
roughness: 0.32,
|
||
metalness: 0.0,
|
||
clearcoat: 1.0,
|
||
clearcoatRoughness: 0.18,
|
||
envMapIntensity: 0.9,
|
||
transparent: true,
|
||
// Vibrancy-driven: 0.72 (airy, keys clearly visible through
|
||
// the gems) → 0.94 (solid, saturated). The old fixed 0.8
|
||
// read washed-out against the dim floor.
|
||
opacity: _noteOpacity(),
|
||
});
|
||
_noteMatCache.set(key, mat);
|
||
return mat;
|
||
}
|
||
|
||
function _noteOpacity() {
|
||
return 0.72 + 0.22 * Math.min(1, Math.max(0, fx.vibrancy));
|
||
}
|
||
function _laneGuideOpacity() {
|
||
// Vibrancy sets the ceiling (much brighter than the old subtle
|
||
// 0.10–0.22 range); the laneOpacity slider then scales 0 → ceiling.
|
||
const vib = 0.32 + 0.52 * Math.min(1, Math.max(0, fx.vibrancy)); // ~0.32..0.84
|
||
return vib * Math.min(1, Math.max(0, fx.laneOpacity));
|
||
}
|
||
|
||
// Live vibrancy slider: retint everything already built — the
|
||
// per-note clones, the material cache (future clones), and the lane
|
||
// guides — without a chart rebuild.
|
||
function _applyVibrancy() {
|
||
const op = _noteOpacity();
|
||
for (const m of _noteMatCache.values()) m.opacity = op;
|
||
for (const nm of noteMeshes) {
|
||
if (nm.mesh && nm.mesh.material) nm.mesh.material.opacity = op;
|
||
}
|
||
const lop = _laneGuideOpacity();
|
||
for (const m of _laneGuideMats) m.opacity = lop;
|
||
}
|
||
|
||
// Live palette switch: recolor everything already built — cached
|
||
// note materials (future clones), per-note clones, key emissives
|
||
// (incl. the wrong-flash restore state), lane guides — and drop the
|
||
// pitch-class flame textures so the next spawn bakes the new hues.
|
||
// Same no-rebuild approach as _applyVibrancy.
|
||
function _applyPalette() {
|
||
// The base-material cache is keyed by resolved color, so old
|
||
// entries are simply stale under a new palette — drop them and let
|
||
// the next build re-cache. The live per-note clones below are
|
||
// retinted directly from each note's midi (palette-correct).
|
||
for (const m of _noteMatCache.values()) m.dispose();
|
||
_noteMatCache.clear();
|
||
for (const nm of noteMeshes) {
|
||
if (!nm.mesh || !nm.mesh.material) continue;
|
||
const col = noteColor(nm.note.midi, nm.note.hand);
|
||
nm.mesh.material.color.copy(col);
|
||
nm.mesh.material.emissive.copy(col);
|
||
}
|
||
for (const [midi, km] of keyMeshes) {
|
||
const col = noteColor(midi, 'rh');
|
||
km.material.emissive.copy(col);
|
||
km.userData.origEmissive = col.getHex();
|
||
}
|
||
for (const m of _laneGuideMats) {
|
||
if (m.userData.midi != null) m.color.copy(noteColor(m.userData.midi, 'rh'));
|
||
}
|
||
_clearFlameTextures();
|
||
// Re-arm the pool so no slot keeps rendering a disposed texture
|
||
// (a flame mid-flight briefly re-tints — next spawn sets its
|
||
// true pitch texture).
|
||
for (const s of _flamePool) s.mat.map = _flameTexture(0);
|
||
}
|
||
|
||
function _barNumberTexture(idx) {
|
||
let tex = _barTexCache.get(idx);
|
||
if (tex) return tex;
|
||
const cnv = document.createElement('canvas');
|
||
cnv.width = 128;
|
||
cnv.height = 64;
|
||
const ctx = cnv.getContext('2d');
|
||
ctx.clearRect(0, 0, 128, 64);
|
||
ctx.font = '600 38px system-ui, sans-serif';
|
||
ctx.textAlign = 'center';
|
||
ctx.textBaseline = 'middle';
|
||
ctx.fillStyle = 'rgba(232,232,242,0.88)';
|
||
ctx.fillText(String(idx), 64, 34);
|
||
tex = new T.CanvasTexture(cnv);
|
||
_barTexCache.set(idx, tex);
|
||
return tex;
|
||
}
|
||
|
||
// Vertical gradient for the hit-line glow: white-hot core fading to
|
||
// transparent cyan at the edges. Lazy, reused for both glow planes.
|
||
function _glowTexture() {
|
||
if (_glowTex) return _glowTex;
|
||
const cnv = document.createElement('canvas');
|
||
cnv.width = 16;
|
||
cnv.height = 64;
|
||
const ctx = cnv.getContext('2d');
|
||
const grad = ctx.createLinearGradient(0, 0, 0, 64);
|
||
grad.addColorStop(0.0, 'rgba(64,255,208,0)');
|
||
grad.addColorStop(0.5, 'rgba(224,255,248,1)');
|
||
grad.addColorStop(1.0, 'rgba(64,255,208,0)');
|
||
ctx.fillStyle = grad;
|
||
ctx.fillRect(0, 0, 16, 64);
|
||
_glowTex = new T.CanvasTexture(cnv);
|
||
return _glowTex;
|
||
}
|
||
|
||
// Vertical flame texture for hit flares / held-key halos: white-hot
|
||
// base fading up into the note's color, with a horizontal falloff.
|
||
// Cached per resolved color (bounded — 12 for pitch-class palettes,
|
||
// up to ~one-per-octave for 'octaves'), so a flare always matches the
|
||
// struck note's color whatever the palette.
|
||
function _flameTexture(midi) {
|
||
const c = _noteHex(midi);
|
||
let tex = _flameTexCache.get(c);
|
||
if (tex) return tex;
|
||
const r = (c >> 16) & 0xff, g = (c >> 8) & 0xff, b = c & 0xff;
|
||
const cnv = document.createElement('canvas');
|
||
cnv.width = 64;
|
||
cnv.height = 128;
|
||
const ctx = cnv.getContext('2d');
|
||
const grad = ctx.createLinearGradient(0, 128, 0, 0); // bottom → top
|
||
grad.addColorStop(0.0, 'rgba(255,255,255,1)'); // white-hot base
|
||
grad.addColorStop(0.3, `rgba(${r},${g},${b},0.95)`);
|
||
grad.addColorStop(1.0, `rgba(${r},${g},${b},0)`);
|
||
ctx.fillStyle = grad;
|
||
ctx.fillRect(0, 0, 64, 128);
|
||
const falloff = ctx.createLinearGradient(0, 0, 64, 0);
|
||
falloff.addColorStop(0.0, 'rgba(0,0,0,0)');
|
||
falloff.addColorStop(0.5, 'rgba(0,0,0,1)');
|
||
falloff.addColorStop(1.0, 'rgba(0,0,0,0)');
|
||
ctx.globalCompositeOperation = 'destination-in';
|
||
ctx.fillStyle = falloff;
|
||
ctx.fillRect(0, 0, 64, 128);
|
||
tex = new T.CanvasTexture(cnv);
|
||
_flameTexCache.set(c, tex);
|
||
return tex;
|
||
}
|
||
|
||
function _clearFlameTextures() {
|
||
for (const t of _flameTexCache.values()) t.dispose();
|
||
_flameTexCache.clear();
|
||
}
|
||
|
||
// Pooled flame sprites — allocated once with the scene; spawning a
|
||
// flame only mutates a slot.
|
||
function _buildFlamePool() {
|
||
_flamePool.length = 0;
|
||
_flameIdx = 0;
|
||
for (let i = 0; i < FLAME_POOL_SIZE; i++) {
|
||
const mat = new T.SpriteMaterial({
|
||
map: _flameTexture(0),
|
||
transparent: true,
|
||
depthWrite: false,
|
||
blending: T.AdditiveBlending,
|
||
});
|
||
const sprite = new T.Sprite(mat);
|
||
sprite.visible = false;
|
||
_flamesGroup.add(sprite);
|
||
_flamePool.push({ sprite, mat, start: -1, baseY: 0 });
|
||
}
|
||
}
|
||
|
||
// Hit sparks (PORTED FROM highway_3d _sparkBurst/_sparkUpdate — keep
|
||
// in sync): pooled additive Points; a timing-colored burst fires at
|
||
// the struck key alongside the flame sprite.
|
||
function _sparkBurst(x, y, z, hex, count) {
|
||
if (!_sparkPts || count <= 0) return;
|
||
const r = ((hex >> 16) & 255) / 255, g = ((hex >> 8) & 255) / 255, b = (hex & 255) / 255;
|
||
let made = 0;
|
||
for (let i = 0; i < _SPARK_N && made < count; i++) {
|
||
if (_sparkLife[i] > 0) continue;
|
||
const j = i * 3, ang = Math.random() * Math.PI * 2, sp = (5 + Math.random() * 12) * K;
|
||
_sparkPos[j] = x; _sparkPos[j + 1] = y; _sparkPos[j + 2] = z;
|
||
_sparkVel[j] = Math.cos(ang) * sp; _sparkVel[j + 1] = (12 + Math.random() * 24) * K; _sparkVel[j + 2] = Math.sin(ang) * sp * 0.55;
|
||
_sparkCol[j] = r; _sparkCol[j + 1] = g; _sparkCol[j + 2] = b;
|
||
_sparkLife[i] = 0.30 + Math.random() * 0.16; made++;
|
||
}
|
||
}
|
||
function _sparkUpdate(dt) {
|
||
if (!_sparkPts) return;
|
||
const grav = 55 * K; let any = false;
|
||
for (let i = 0; i < _SPARK_N; i++) {
|
||
if (_sparkLife[i] <= 0) continue;
|
||
const j = i * 3;
|
||
_sparkLife[i] -= dt;
|
||
if (_sparkLife[i] <= 0) { _sparkCol[j] = _sparkCol[j + 1] = _sparkCol[j + 2] = 0; continue; }
|
||
any = true;
|
||
_sparkVel[j + 1] -= grav * dt;
|
||
_sparkPos[j] += _sparkVel[j] * dt; _sparkPos[j + 1] += _sparkVel[j + 1] * dt; _sparkPos[j + 2] += _sparkVel[j + 2] * dt;
|
||
const fade = 1 - Math.min(1, dt * 3.2);
|
||
_sparkCol[j] *= fade; _sparkCol[j + 1] *= fade; _sparkCol[j + 2] *= fade;
|
||
}
|
||
_sparkPts.geometry.attributes.position.needsUpdate = true;
|
||
_sparkPts.geometry.attributes.color.needsUpdate = true;
|
||
_sparkPts.visible = any;
|
||
}
|
||
|
||
// Timing → color (PORTED FROM highway_3d _timingHex — keep in sync).
|
||
function _timingHex(ts) {
|
||
if (!fx.timingFx || !ts || ts === 'OK') return 0x22ff88;
|
||
if (ts === 'EARLY') return 0x35d6ff;
|
||
if (ts === 'LATE') return 0xffb84d;
|
||
return 0x22ff88;
|
||
}
|
||
|
||
function _spawnSparks(midi, ts) {
|
||
if (!fx.sparks || !_sparkPts || !_layoutInfo) return;
|
||
const entry = _layoutInfo.layout.get(midi);
|
||
if (!entry) return;
|
||
let count = 8;
|
||
if (fx.streakFx) count += Math.round(8 * Math.min(1, _streak / 16));
|
||
const y = (entry.black ? BLACK_H + WHITE_H * 0.6 : WHITE_H) + 1 * K;
|
||
_sparkBurst(keyX(entry, _layoutInfo.whiteCount), y, -WHITE_L / 2, _timingHex(ts), count);
|
||
}
|
||
|
||
function _spawnFlame(midi, wallNow) {
|
||
if (!_layoutInfo || !_flamePool.length) return;
|
||
const entry = _layoutInfo.layout.get(midi);
|
||
if (!entry) return;
|
||
const slot = _flamePool[_flameIdx];
|
||
_flameIdx = (_flameIdx + 1) % _flamePool.length;
|
||
slot.mat.map = _flameTexture(midi);
|
||
slot.start = wallNow;
|
||
slot.baseY = entry.black ? BLACK_H + WHITE_H * 0.6 : WHITE_H;
|
||
slot.sprite.position.x = keyX(entry, _layoutInfo.whiteCount);
|
||
slot.sprite.position.z = -WHITE_L / 2; // at the hit-line
|
||
slot.sprite.visible = true;
|
||
}
|
||
|
||
function buildKeyboardAndHighway() {
|
||
_clearGroup(keyboardGroup);
|
||
_hitGlowMats.length = 0;
|
||
_laneGuideMats.length = 0;
|
||
_railMats.length = 0;
|
||
keyMeshes = new Map();
|
||
_keyAnim.clear();
|
||
_keyFlash.clear();
|
||
const range = _notation.range;
|
||
const { layout, whiteCount } = keyLayout(range);
|
||
_layoutInfo = { layout, whiteCount };
|
||
const totalW = whiteCount * WHITE_W;
|
||
const floorW = totalW + 8 * WHITE_W;
|
||
const hitZ = -WHITE_L / 2;
|
||
|
||
// Highway floor receding to the vanishing point.
|
||
_floorMat = new T.MeshStandardMaterial({
|
||
color: _bgThemeColors(_theme).board,
|
||
// Stage-floor sheen (was matte 0.9): the env strips give the
|
||
// deck a soft reflection lane without mirroring the notes.
|
||
roughness: 0.55,
|
||
metalness: 0.15,
|
||
envMapIntensity: 0.25,
|
||
});
|
||
const floor = new T.Mesh(
|
||
new T.PlaneGeometry(floorW, HIGHWAY_LEN),
|
||
_floorMat,
|
||
);
|
||
floor.rotation.x = -Math.PI / 2;
|
||
floor.position.set(0, -0.5 * K, -HIGHWAY_LEN / 2 + WHITE_L);
|
||
keyboardGroup.add(floor);
|
||
|
||
// Active-range pop: dim the scroll lanes outside the active span
|
||
// with subtle dark overlays so the playable range reads bright.
|
||
let activeMinX = Infinity, activeMaxX = -Infinity;
|
||
for (const [midi, entry] of layout) {
|
||
if (midi < range.activeLow || midi > range.activeHigh) continue;
|
||
const w = entry.black ? BLACK_W : WHITE_W;
|
||
const x = keyX(entry, whiteCount);
|
||
if (x - w / 2 < activeMinX) activeMinX = x - w / 2;
|
||
if (x + w / 2 > activeMaxX) activeMaxX = x + w / 2;
|
||
}
|
||
if (activeMinX < activeMaxX) {
|
||
const laneLen = HIGHWAY_LEN - WHITE_L;
|
||
const dimMat = new T.MeshBasicMaterial({
|
||
color: 0x000000, transparent: true, opacity: 0.42, depthWrite: false,
|
||
});
|
||
const zones = [
|
||
[-floorW / 2, activeMinX],
|
||
[activeMaxX, floorW / 2],
|
||
];
|
||
for (const [x0, x1] of zones) {
|
||
const zoneW = x1 - x0;
|
||
if (zoneW <= 0.01 * K) continue;
|
||
const dim = new T.Mesh(new T.PlaneGeometry(zoneW, laneLen), dimMat);
|
||
dim.rotation.x = -Math.PI / 2;
|
||
dim.position.set((x0 + x1) / 2, 0.1 * K, hitZ - laneLen / 2);
|
||
keyboardGroup.add(dim);
|
||
}
|
||
}
|
||
|
||
// Lane guides: a faint color strip running up the runway from each
|
||
// active key, in that key's pitch-class color. A falling note shares
|
||
// its target key's color, so the player can trace it straight down
|
||
// its lane to the right key even when it sits near the frame edge.
|
||
//
|
||
// The lanes sit at the NOTES' travel height (coplanar), not on the
|
||
// deck. The camera looks down the y–z plane, so a note elevated above
|
||
// an on-floor lane projects to a different screen column (parallax) —
|
||
// putting the lane at the note's y makes the note ride exactly in its
|
||
// lane, perfectly aligned with the lane and its key.
|
||
const guideLen = HIGHWAY_LEN - WHITE_L;
|
||
const laneY = WHITE_H + NOTE_H / 2 + 0.5 * K; // == white-note travel height
|
||
const gaps = fx.octaveGaps;
|
||
const floating = _sharpMode === 'floating';
|
||
const t = Math.min(1, Math.max(0, fx.laneOpacity)); // lane-color opacity
|
||
const octC = Math.min(1, Math.max(0, fx.octaveContrast)); // 0..1 line-contrast
|
||
const themeLaneDim = (() => { const c = _bgThemeColors(_theme); return c.laneDim != null ? c.laneDim : 0x2a2a3e; })();
|
||
// A vertical guide line running the full runway at world x (skips
|
||
// near-transparent lines so the crossfade never builds dead meshes).
|
||
const addLine = (x, color, opacity, wpx, trackTheme) => {
|
||
if (opacity < 0.02) return;
|
||
const m = new T.MeshBasicMaterial({ color, transparent: true, opacity, depthWrite: false });
|
||
if (trackTheme) _railMats.push(m); // theme retint tracks these; fixed guides stay put
|
||
const line = new T.Mesh(new T.PlaneGeometry(wpx, guideLen), m);
|
||
line.rotation.x = -Math.PI / 2;
|
||
line.position.set(x, laneY + 0.06 * K, hitZ - guideLen / 2);
|
||
keyboardGroup.add(line);
|
||
};
|
||
for (const [midi, entry] of layout) {
|
||
if (midi < range.activeLow || midi > range.activeHigh) continue;
|
||
// Floating: white-only lanes (blacks float, lane-less). Flat/
|
||
// realistic: every key gets a piano-shaped lane.
|
||
if (entry.black && floating) continue;
|
||
// Lane footprint per mode.
|
||
let left, right, stripY = laneY;
|
||
if (floating) {
|
||
const cx = keyX(entry, whiteCount);
|
||
left = cx - WHITE_W / 2; right = cx + WHITE_W / 2;
|
||
if (gaps) {
|
||
const pc = ((midi % 12) + 12) % 12;
|
||
if (pc === 11) right -= OCT_GAP / 2; // B → C boundary
|
||
if (pc === 0) left += OCT_GAP / 2;
|
||
}
|
||
} else {
|
||
const span = laneSpanFor(midi, entry.black, keyX(entry, whiteCount), gaps, range);
|
||
left = span.left; right = span.right;
|
||
if (_sharpMode === 'realistic' && entry.black) stripY = laneY + REAL_SHARP_LIFT;
|
||
}
|
||
const center = (left + right) / 2;
|
||
// Colored lane strip + a subtle per-lane separator — fade in with
|
||
// lane opacity. (As lanes fade, the block/octave lines below take
|
||
// over as the guide.)
|
||
if (t > 0.02) {
|
||
// Floating keeps the historical 0.84-wide white strip; the
|
||
// piano-shaped lanes inset a touch for a dark separator.
|
||
const stripW = floating ? (right - left) - WHITE_W * 0.16 : (right - left) * 0.9;
|
||
const gmat = new T.MeshBasicMaterial({
|
||
color: noteColor(midi, 'rh'), transparent: true,
|
||
opacity: _laneGuideOpacity(), depthWrite: false, // includes lane opacity
|
||
});
|
||
gmat.userData.midi = midi; // palette retint needs the lane's pitch
|
||
_laneGuideMats.push(gmat);
|
||
const strip = new T.Mesh(new T.PlaneGeometry(stripW, guideLen), gmat);
|
||
strip.rotation.x = -Math.PI / 2;
|
||
strip.position.set(center, stripY, hitZ - guideLen / 2);
|
||
keyboardGroup.add(strip);
|
||
// Per-lane separator, fading with the strips. Skip realistic
|
||
// sharps (they overlap the white columns).
|
||
if (!(entry.black && _sharpMode === 'realistic')) {
|
||
addLine(left, themeLaneDim, 0.5 * t, 0.6 * K, true);
|
||
}
|
||
}
|
||
}
|
||
// Structural divider lines: ONE per "block" boundary — E→F and B→C —
|
||
// so each block of keys (C-D-E, F-G-A-B) is bounded, not every lane.
|
||
// They crossfade IN as the lanes fade OUT. The B→C octave line is a
|
||
// dark layer (reads over bright lanes, scales with lane opacity) plus
|
||
// a bright layer (reads over the dark floor, scales with the inverse),
|
||
// so it auto-shifts dark→bright as you fade lanes; octaveContrast
|
||
// scales the whole thing.
|
||
for (let midi = range.activeLow; midi <= range.activeHigh; midi++) {
|
||
const pc = ((midi % 12) + 12) % 12;
|
||
const isEF = pc === 4; // E → F block boundary
|
||
const isBC = pc === 11; // B → C octave boundary
|
||
if (!isEF && !isBC) continue;
|
||
const boundaryX = keyX(layout.get(midi), whiteCount) + WHITE_W / 2;
|
||
if (isBC) {
|
||
addLine(boundaryX, 0x05060a, octC * 0.92 * t, 1.1 * K, false); // dark, over lanes
|
||
addLine(boundaryX, 0xd8dcec, (0.42 + octC * 0.5) * (1 - t), 1.1 * K, false); // bright, over floor
|
||
} else {
|
||
// E→F block divider — a guide that appears as the lanes fade.
|
||
addLine(boundaryX, 0x6a6a7a, 0.5 * (1 - t), 0.8 * K, false);
|
||
}
|
||
}
|
||
|
||
// Keys (whites first so blacks overlay). Geometries are shared
|
||
// across keys; materials are per key (range tint / depress +
|
||
// wrong-flash feedback are per-key state). The geometry origin
|
||
// is translated to the key's BACK edge so the live MIDI key-
|
||
// depress animation can pivot there (rotation.x > 0 tips the
|
||
// front of the key down, like a real piano action).
|
||
const whiteGeo = _roundedBoxGeo(WHITE_W * 0.94, WHITE_H, WHITE_L, WHITE_W * 0.05, WHITE_H * 0.1);
|
||
whiteGeo.translate(0, 0, WHITE_L / 2);
|
||
const blackGeo = _roundedBoxGeo(BLACK_W, BLACK_H, BLACK_L, BLACK_W * 0.08, BLACK_H * 0.06);
|
||
blackGeo.translate(0, 0, BLACK_L / 2);
|
||
const whiteGlyphGeo = new T.PlaneGeometry(WHITE_W * 0.72, WHITE_W * 0.72);
|
||
const blackGlyphGeo = new T.PlaneGeometry(BLACK_W * 0.96, BLACK_W * 0.96);
|
||
for (const [midi, entry] of layout) {
|
||
const black = entry.black;
|
||
const inRange = midi >= range.activeLow && midi <= range.activeHigh;
|
||
const material = new T.MeshStandardMaterial({
|
||
color: black ? 0x070708 : 0xe8e8ee,
|
||
// Pitch-class color preset on emissive but OFF at rest — the key
|
||
// is neutral until a note approaches, when updateScene ramps the
|
||
// intensity up by proximity.
|
||
emissive: noteColor(midi, 'rh'),
|
||
emissiveIntensity: 0,
|
||
// Anti-plastic: glossy piano black with visible strip
|
||
// reflections; whites keep a subtle ivory sheen (they're
|
||
// already near-white — env light saturates them fast).
|
||
roughness: black ? 0.22 : 0.42,
|
||
envMapIntensity: black ? 1.3 : 0.3,
|
||
});
|
||
const mesh = new T.Mesh(black ? blackGeo : whiteGeo, material);
|
||
// Positions place the geometry where the old centred boxes
|
||
// sat: the mesh origin is the back-edge centre.
|
||
mesh.position.set(
|
||
keyX(entry, whiteCount),
|
||
black ? BLACK_H / 2 + WHITE_H * 0.1 : WHITE_H / 2,
|
||
black ? (WHITE_L - BLACK_L) / 2 - WHITE_L / 2 - BLACK_L / 2 - 4 * K : -WHITE_L / 2,
|
||
);
|
||
mesh.userData.midi = midi;
|
||
// Wrong-note flash restore state.
|
||
mesh.userData.origEmissive = material.emissive.getHex();
|
||
mesh.userData.origEmissiveIntensity = material.emissiveIntensity;
|
||
keyboardGroup.add(mesh);
|
||
keyMeshes.set(midi, mesh);
|
||
|
||
if (inRange) {
|
||
// RS+-style letter printed on the key top — dark text
|
||
// near the front edge of whites, light text on blacks.
|
||
// The glyph is a CHILD of the key mesh so it rides the
|
||
// depress animation (positions are key-local).
|
||
const glyph = new T.Mesh(
|
||
black ? blackGlyphGeo : whiteGlyphGeo,
|
||
new T.MeshBasicMaterial({
|
||
map: _glyphTexture(noteLetter(midi), !black),
|
||
transparent: true,
|
||
depthWrite: false,
|
||
}),
|
||
);
|
||
glyph.rotation.x = -Math.PI / 2;
|
||
glyph.position.set(
|
||
0,
|
||
black ? BLACK_H / 2 + 0.15 * K : WHITE_H / 2 + 0.15 * K,
|
||
black ? BLACK_L / 2 : WHITE_L - WHITE_W * 0.55,
|
||
);
|
||
mesh.add(glyph);
|
||
}
|
||
}
|
||
|
||
// Glowing hit-line: a bright core bar plus two additive gradient
|
||
// planes (a flat wash on the deck and an upright curtain) pulsed
|
||
// gently in draw(). No postprocessing — just cheap blending.
|
||
const lineW = totalW + 4 * WHITE_W;
|
||
hitLine = new T.Mesh(
|
||
new T.BoxGeometry(lineW, 1.2 * K, 1.2 * K),
|
||
new T.MeshBasicMaterial({ color: 0xb8fff0 }),
|
||
);
|
||
hitLine.position.set(0, WHITE_H + 0.5 * K, hitZ);
|
||
keyboardGroup.add(hitLine);
|
||
|
||
const flatGlowMat = new T.MeshBasicMaterial({
|
||
map: _glowTexture(), transparent: true, opacity: 0.85,
|
||
blending: T.AdditiveBlending, depthWrite: false,
|
||
});
|
||
const flatGlow = new T.Mesh(new T.PlaneGeometry(lineW, 16 * K), flatGlowMat);
|
||
flatGlow.rotation.x = -Math.PI / 2;
|
||
flatGlow.position.set(0, 0.2 * K, hitZ - 5 * K);
|
||
keyboardGroup.add(flatGlow);
|
||
|
||
const upGlowMat = flatGlowMat.clone();
|
||
const upGlow = new T.Mesh(new T.PlaneGeometry(lineW, 7 * K), upGlowMat);
|
||
upGlow.position.set(0, WHITE_H + 1.5 * K, hitZ + 0.1 * K);
|
||
keyboardGroup.add(upGlow);
|
||
_hitGlowMats.push(flatGlowMat, upGlowMat);
|
||
}
|
||
|
||
function buildNoteMeshes() {
|
||
_clearGroup(notesGroup);
|
||
_clearNoteCaches();
|
||
noteMeshes = [];
|
||
const range = _notation.range;
|
||
const { layout, whiteCount } = keyLayout(range);
|
||
for (const note of _notation.notes) {
|
||
const entry = layout.get(note.midi);
|
||
if (!entry) continue;
|
||
const len = Math.max(4 * K, note.durSec * TS);
|
||
// Non-floating layouts: notes ride the naturals' plane and take
|
||
// their piano-shaped lane's width/centre. Floating (default):
|
||
// original elevated sharps, key-centred bars.
|
||
let w, x, y;
|
||
if (_flatMode()) {
|
||
const span = laneSpanFor(
|
||
note.midi, entry.black, keyX(entry, whiteCount), fx.octaveGaps, range);
|
||
// 'realistic' bars are full (physical-key size); 'flat' bars are
|
||
// inset a touch for a dark separator in the tight tiling.
|
||
const inset = _sharpMode === 'realistic' ? 1.0 : 0.9;
|
||
w = (span.right - span.left) * inset;
|
||
x = (span.left + span.right) / 2;
|
||
// Coplanar; in 'realistic' the sharps ride a hair proud so they
|
||
// draw over the naturals they overlap without z-fighting.
|
||
const lift = (_sharpMode === 'realistic' && entry.black) ? REAL_SHARP_LIFT : 0;
|
||
y = WHITE_H + NOTE_H / 2 + 0.5 * K + lift;
|
||
} else {
|
||
w = (entry.black ? BLACK_W : WHITE_W * 0.94) * 0.9;
|
||
x = keyX(entry, whiteCount);
|
||
y = (entry.black ? BLACK_H + WHITE_H : WHITE_H) + NOTE_H / 2 + 0.5 * K;
|
||
}
|
||
// Clone per note so each can glow independently while being consumed.
|
||
const mesh = new T.Mesh(_noteGeometry(w, len), _noteMaterial(note.midi, note.hand).clone());
|
||
mesh.position.x = x;
|
||
mesh.position.y = y;
|
||
mesh.visible = false;
|
||
notesGroup.add(mesh);
|
||
// Note-name label: a camera-facing sprite (readable at this low camera
|
||
// angle, unlike a flat top decal). Lives in notesGroup as a sibling —
|
||
// NOT a child of the note mesh — so the consumption z-clip never
|
||
// squashes the letter. Positioned per frame in updateScene.
|
||
const label = new T.Sprite(new T.SpriteMaterial({
|
||
map: _glyphTexture(noteLetter(note.midi), false),
|
||
transparent: true, depthWrite: false,
|
||
}));
|
||
label.scale.set(6.5 * K, 6.5 * K, 1);
|
||
label.visible = false;
|
||
notesGroup.add(label);
|
||
noteMeshes.push({ mesh, note, len, label });
|
||
}
|
||
}
|
||
|
||
// Floating bar numbers: one camera-facing sprite per measure marker,
|
||
// parked on the left shoulder of the highway and scrolled with the
|
||
// notes in draw().
|
||
function buildMarkerSprites() {
|
||
_clearGroup(markersGroup);
|
||
_clearBarTextures();
|
||
markerSprites = [];
|
||
const { whiteCount } = keyLayout(_notation.range);
|
||
const totalW = whiteCount * WHITE_W;
|
||
for (const marker of _notation.markers) {
|
||
const mat = new T.SpriteMaterial({
|
||
map: _barNumberTexture(marker.idx),
|
||
transparent: true,
|
||
depthWrite: false,
|
||
opacity: 0.9,
|
||
});
|
||
const sprite = new T.Sprite(mat);
|
||
sprite.scale.set(16 * K, 8 * K, 1);
|
||
sprite.position.set(-totalW / 2 - 2.2 * WHITE_W, 3 * K, 0);
|
||
sprite.visible = false;
|
||
markersGroup.add(sprite);
|
||
markerSprites.push({ sprite, t: marker.t });
|
||
}
|
||
}
|
||
|
||
/* ── MIDI event handlers (called by module _midiOnMessage) ───────
|
||
*
|
||
* Receive the RAW midi note from the device; transpose is applied
|
||
* here and the result stored under the raw note so note-off finds
|
||
* it even if the transpose changed in between (piano invariant).
|
||
*/
|
||
|
||
function _handleNoteOn(rawMidi, velocity, tOverride) {
|
||
if (rawMidi < 0 || rawMidi > 127) return;
|
||
const played = rawMidi + _cfg.transpose;
|
||
if (played < 0 || played > 127) return;
|
||
_rawToPlayed.set(rawMidi, played);
|
||
// Retrigger while the pedal is down: the key is physically held
|
||
// again, so it's no longer merely pedal-sustained. Without this,
|
||
// pedal-up would un-depress (and stop tracking) a key the player
|
||
// is still holding. _heldNotes is the visual-depress set (physical
|
||
// OR pedal-sustained); _sustainedNotes is the pedal-only subset.
|
||
_sustainedNotes.delete(played);
|
||
_heldNotes.add(played);
|
||
// Key depress starts immediately on the LIVE input path —
|
||
// feedback must not wait for hit judgment.
|
||
if (keyMeshes.has(played)) _keyAnim.set(played, true);
|
||
_synthNoteOn(played, velocity); // hear the played note
|
||
_checkHit(played, Number.isFinite(tOverride) ? tOverride : _latestTime);
|
||
}
|
||
|
||
function _handleNoteOff(rawMidi) {
|
||
if (rawMidi < 0 || rawMidi > 127) return;
|
||
const played = _rawToPlayed.get(rawMidi);
|
||
if (played == null) return; // stray note-off (cleared state)
|
||
_rawToPlayed.delete(rawMidi);
|
||
if (_sustainOn) {
|
||
_sustainedNotes.add(played);
|
||
return; // pedal held → keep sounding (synth released on pedal-up)
|
||
}
|
||
_heldNotes.delete(played);
|
||
_synthNoteOff(played);
|
||
}
|
||
|
||
function _handleSustain(down) {
|
||
if (down) {
|
||
_sustainOn = true;
|
||
} else {
|
||
_sustainOn = false;
|
||
for (const midi of _sustainedNotes) { _heldNotes.delete(midi); _synthNoteOff(midi); }
|
||
_sustainedNotes.clear();
|
||
}
|
||
}
|
||
|
||
function _releaseAllHeld() {
|
||
_heldNotes.clear();
|
||
_sustainedNotes.clear();
|
||
_rawToPlayed.clear();
|
||
_sustainOn = false;
|
||
_synthAllOff();
|
||
}
|
||
|
||
/* ── Hit detection / scoring (piano _checkHit port) ───────────── */
|
||
|
||
function _checkHit(playedMidi, t) {
|
||
if (!_notation || !_notation.notes.length) return;
|
||
const key = judgeHit(_notation.notes, playedMidi, t, _hitNoteKeys, HIT_TOLERANCE_S);
|
||
const wall = performance.now();
|
||
if (key) {
|
||
_hitNoteKeys.add(key);
|
||
_hits++;
|
||
_streak++;
|
||
if (_streak > _bestStreak) _bestStreak = _streak;
|
||
_keyFlash.delete(playedMidi); // a hit cancels a lingering red
|
||
_spawnFlame(playedMidi, wall);
|
||
// Timing verdict: noteKey() serializes the matched note's t
|
||
// as its prefix ("<t.toFixed(3)>|<midi>"), so parseFloat
|
||
// recovers it without changing judgeHit's tested contract.
|
||
const ts = _classifyTiming(parseFloat(key) - t, HIT_TOLERANCE_S);
|
||
_spawnSparks(playedMidi, ts);
|
||
// Brief hit-line brightness kick (decays exp(-t*6) in draw).
|
||
_hitGlowKick = 1;
|
||
_scoreOnHit(playedMidi);
|
||
_ndReport(true, playedMidi, _ndBindingId);
|
||
} else {
|
||
const prevStreak = _streak;
|
||
_misses++;
|
||
_streak = 0;
|
||
_scoreOnBreak(prevStreak);
|
||
if (keyMeshes.has(playedMidi)) _keyFlash.set(playedMidi, wall);
|
||
_ndReport(false, playedMidi, _ndBindingId);
|
||
}
|
||
}
|
||
|
||
function _resetScoring() {
|
||
_hits = 0; _misses = 0; _streak = 0; _bestStreak = 0;
|
||
_hitNoteKeys.clear();
|
||
_missedNoteKeys.clear();
|
||
_sweepCursor.idx = 0;
|
||
_missFloor = null;
|
||
_keyFlash.clear();
|
||
// Fresh chart, fresh timing cursor — a note-on landing between
|
||
// the new chart build and its first draw() must be judged at
|
||
// the new run's start, not against the previous song's time.
|
||
_latestTime = 0;
|
||
}
|
||
|
||
// Swept-miss callback — kept as a named function so the per-frame
|
||
// sweep passes a stable reference (no closure allocation in draw()).
|
||
function _onSweptMiss(note) {
|
||
_scoreOnBreak(_streak); // called before the caller zeroes it
|
||
_ndReport(false, note.midi, _ndBindingId);
|
||
}
|
||
|
||
/* ── End-of-run stats POST (exactly once per run) ──────────────── */
|
||
|
||
async function _finalizeRun() {
|
||
if (_recordedThisRun) return;
|
||
// When the note-detection domain is present, the consumer (notedetect
|
||
// → core stats-recorder) owns stats + progression, posting/awarding
|
||
// from our reported verdicts. Defer to it so we don't double-count
|
||
// the play or double-award feedback points.
|
||
if (window.slopsmith && window.slopsmith.noteDetection && window.slopsmith.noteDetection.version === 1) {
|
||
_recordedThisRun = true;
|
||
return;
|
||
}
|
||
if (!_runMeta || !_runMeta.filename) return;
|
||
if (_hits + _misses <= 0) return; // nothing was scored this run
|
||
_recordedThisRun = true;
|
||
const accuracy = accuracyOf(_hits, _misses);
|
||
const body = {
|
||
filename: _runMeta.filename,
|
||
arrangement: _runMeta.arrangement,
|
||
score: scoreOf(_hits, _misses),
|
||
accuracy,
|
||
hits: _hits,
|
||
misses: _misses,
|
||
bestStreak: _bestStreak,
|
||
};
|
||
let resp = null;
|
||
try {
|
||
const r = await fetch('/api/stats', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(body),
|
||
});
|
||
resp = await r.json().catch(() => null);
|
||
} catch (_) { /* offline / endpoint absent — non-fatal */ }
|
||
// Fan the outcome out to the progression core (guarded — older
|
||
// servers have neither the field nor the host).
|
||
try { window.v3Progression?.notify(resp ? resp.progression : null); } catch (_) {}
|
||
}
|
||
|
||
/* ── Live feedback animation (no per-frame allocations) ────────── */
|
||
|
||
function _animateFeedback(wallNow) {
|
||
const dt = Math.min(Math.max(wallNow - _lastWallMs, 0), 100);
|
||
_lastWallMs = wallNow;
|
||
// Key depress: exponential spring toward the held/released
|
||
// target angle, pivoting at the back edge (geometry origin).
|
||
const k = 1 - Math.exp(-dt / KEY_PRESS_TAU_MS);
|
||
for (const midi of _keyAnim.keys()) {
|
||
const mesh = keyMeshes.get(midi);
|
||
if (!mesh) { _keyAnim.delete(midi); continue; }
|
||
const target = _heldNotes.has(midi) ? KEY_PRESS_ANGLE : 0;
|
||
const next = mesh.rotation.x + (target - mesh.rotation.x) * k;
|
||
mesh.rotation.x = next;
|
||
if (target === 0 && Math.abs(next) < 0.001) {
|
||
mesh.rotation.x = 0;
|
||
_keyAnim.delete(midi);
|
||
}
|
||
}
|
||
// Wrong-note red flash, fading back to the key's own emissive.
|
||
for (const [midi, start] of _keyFlash) {
|
||
const mesh = keyMeshes.get(midi);
|
||
if (!mesh) { _keyFlash.delete(midi); continue; }
|
||
const p = (wallNow - start) / WRONG_FLASH_MS;
|
||
if (p >= 1) {
|
||
mesh.material.emissive.setHex(mesh.userData.origEmissive);
|
||
mesh.material.emissiveIntensity = mesh.userData.origEmissiveIntensity;
|
||
_keyFlash.delete(midi);
|
||
} else {
|
||
mesh.material.emissive.setHex(0xff2020);
|
||
mesh.material.emissiveIntensity = 0.9 * (1 - p);
|
||
}
|
||
}
|
||
// Flame flares: rise, widen and fade over FLAME_MS.
|
||
for (let i = 0; i < _flamePool.length; i++) {
|
||
const slot = _flamePool[i];
|
||
if (slot.start < 0) continue;
|
||
const age = (wallNow - slot.start) / FLAME_MS;
|
||
if (age >= 1) {
|
||
slot.start = -1;
|
||
slot.sprite.visible = false;
|
||
continue;
|
||
}
|
||
const h = 14 * K * (0.6 + 0.9 * age);
|
||
slot.sprite.scale.set(8 * K * (1 + 0.3 * age), h, 1);
|
||
slot.sprite.position.y = slot.baseY + h / 2;
|
||
slot.mat.opacity = 1 - age;
|
||
}
|
||
}
|
||
|
||
function updateScene(now) {
|
||
// Rest emissive with the Glow slider applied — computed once so
|
||
// the per-note '!==' guards stay effective at any glow value.
|
||
const _restEmissive = NOTE_EMISSIVE_BASE * _glowMul();
|
||
const hitZ = -WHITE_L / 2;
|
||
|
||
// Pan the camera x to follow the active hand: a hit-line-weighted
|
||
// centroid of the notes around `now`. Keeps a ~2-octave window
|
||
// framed and scrolls with the melody, RS+-style, instead of
|
||
// statically framing the whole range.
|
||
let minX = Infinity, maxX = -Infinity;
|
||
for (const { mesh, note } of noteMeshes) {
|
||
const dt = note.t - now;
|
||
if (dt < -0.4 || dt > CAM_ZOOM_AHEAD) continue;
|
||
const x = mesh.position.x;
|
||
if (x < minX) minX = x;
|
||
if (x > maxX) maxX = x;
|
||
}
|
||
if (maxX >= minX) {
|
||
// Centre on the span midpoint so the lowest and highest target
|
||
// keys are equidistant and both stay in view; zoom to fit the span.
|
||
_camTargetX = (minX + maxX) / 2;
|
||
const span = (maxX - minX) + CAM_ZOOM_PAD;
|
||
const tz = span / (CAM_ZOOM_BASE_KEYS * WHITE_W);
|
||
_camTargetZoom = Math.max(CAM_ZOOM_MIN, Math.min(CAM_ZOOM_MAX, tz));
|
||
}
|
||
_camX += (_camTargetX - _camX) * CAM_PAN_LERP;
|
||
_camZoom += (_camTargetZoom - _camZoom) * CAM_ZOOM_LERP;
|
||
{
|
||
const r = _rig();
|
||
let _cx = _camX, _cy = r.y * K * _camZoom, _cz = r.z * K * _camZoom;
|
||
let _lx = _camX, _ly = r.lookY * K * _camZoom, _lz = r.lookZ * K * _camZoom;
|
||
// Camera Director free-cam offsets (per-panel-aware), layered on top
|
||
// of the auto-framing so pan/zoom-follow still works. Dolly/height/
|
||
// orbit act on the camera-from-target vector; pan/pitch shift the
|
||
// look target. NaN-safe; null/disabled bridge → stock.
|
||
const _fc = _freeCamFor(highwayCanvas);
|
||
if (_fc && _fc.enabled) {
|
||
const _dm = Number.isFinite(_fc.distMul) ? _fc.distMul : 1;
|
||
const _hm = Number.isFinite(_fc.heightMul) ? _fc.heightMul : 1;
|
||
const _yaw = Number.isFinite(_fc.yaw) ? _fc.yaw : 0;
|
||
let _vx = _cx - _lx, _vy = _cy - _ly, _vz = _cz - _lz;
|
||
_vx *= _dm; _vy *= _dm; _vz *= _dm; // dolly (zoom)
|
||
_vy *= _hm; // height
|
||
const _cyw = Math.cos(_yaw), _syw = Math.sin(_yaw);
|
||
const _rx = _vx * _cyw - _vz * _syw, _rz = _vx * _syw + _vz * _cyw; // orbit around Y
|
||
_cx = _lx + _rx; _cy = _ly + _vy; _cz = _lz + _rz;
|
||
const _px = Number.isFinite(_fc.panX) ? _fc.panX : 0;
|
||
const _py = Number.isFinite(_fc.panY) ? _fc.panY : 0;
|
||
const _pt = Number.isFinite(_fc.pitch) ? _fc.pitch : 0;
|
||
_lx += _px * K; _ly += (_pt + _py) * K;
|
||
}
|
||
cam.position.set(_cx, _cy, _cz);
|
||
cam.lookAt(_lx, _ly, _lz);
|
||
}
|
||
|
||
for (const km of keyMeshes.values()) km.userData.glow = 0;
|
||
for (const { mesh, note, len, label } of noteMeshes) {
|
||
const dt = note.t - now;
|
||
// Key approach-glow: the nearest upcoming note lights its key,
|
||
// stronger the closer it is to the hit-line.
|
||
if (dt >= -0.05 && dt <= KEY_GLOW_AHEAD) {
|
||
const km = keyMeshes.get(note.midi);
|
||
if (km) {
|
||
const p = 1 - dt / KEY_GLOW_AHEAD;
|
||
if (p > km.userData.glow) km.userData.glow = p;
|
||
}
|
||
}
|
||
// Note front edge arrives at the hit-line at note.t.
|
||
const z = scrollZ(note.t, now, hitZ, TS) - len / 2;
|
||
const frontZ = z + len / 2;
|
||
const backZ = z - len / 2;
|
||
const past = frontZ - hitZ;
|
||
|
||
// Note-name label: full while approaching; once the note starts being
|
||
// consumed, fade out over LABEL_FADE_DIST. Driven by `past` (not the
|
||
// mesh state) so it lingers and fades even after the note is eaten.
|
||
if (label) {
|
||
if (frontZ < -HIGHWAY_LEN) {
|
||
label.visible = false;
|
||
} else if (past <= 0) {
|
||
label.visible = true;
|
||
label.material.opacity = 1;
|
||
label.position.set(mesh.position.x, mesh.position.y + NOTE_H / 2 + 1.6 * K, z);
|
||
} else {
|
||
const op = 1 - past / LABEL_FADE_DIST;
|
||
if (op <= 0) {
|
||
label.visible = false;
|
||
} else {
|
||
label.visible = true;
|
||
label.material.opacity = op;
|
||
const rem = Math.max(0, len - past);
|
||
label.position.set(mesh.position.x, mesh.position.y + NOTE_H / 2 + 1.6 * K, hitZ - rem / 2);
|
||
}
|
||
}
|
||
}
|
||
|
||
if (frontZ < -HIGHWAY_LEN || backZ > hitZ + 20 * K) {
|
||
mesh.visible = false;
|
||
continue;
|
||
}
|
||
mesh.visible = true;
|
||
// True sustain consumption: once the leading edge reaches the
|
||
// hit-line, pin the front there and shorten the note from the front
|
||
// as it's "eaten", while the tail keeps feeding down the runway. The
|
||
// gem geometry has length `len`, so scale Z by remaining/len and
|
||
// recentre on the still-visible [backZ, hitZ] segment.
|
||
if (past <= 0) {
|
||
if (mesh.scale.z !== 1) mesh.scale.set(1, 1, 1);
|
||
mesh.position.z = z;
|
||
if (mesh.material.emissiveIntensity !== _restEmissive) {
|
||
mesh.material.emissiveIntensity = _restEmissive;
|
||
}
|
||
} else {
|
||
const remaining = len - past; // = hitZ - backZ, length still up the runway
|
||
if (remaining <= 0.02 * K) { mesh.visible = false; continue; }
|
||
mesh.scale.set(1, 1, remaining / len);
|
||
mesh.position.z = hitZ - remaining / 2;
|
||
// Glow as it's eaten — intensifies the more of the note is consumed.
|
||
const consumed = Math.min(1, past / len);
|
||
mesh.material.emissiveIntensity =
|
||
(NOTE_EMISSIVE_BASE + (CONSUME_GLOW - NOTE_EMISSIVE_BASE) * (0.45 + 0.55 * consumed)) * _glowMul();
|
||
}
|
||
}
|
||
// Apply the approach-glow (a wrong-note red flash owns emissive meanwhile).
|
||
for (const [midi, km] of keyMeshes) {
|
||
if (_keyFlash.has(midi)) continue;
|
||
const g = km.userData.glow || 0;
|
||
km.material.emissiveIntensity = g * g * KEY_GLOW_STRENGTH * _glowMul(); // ease → pops near the hit-line
|
||
}
|
||
for (const entry of markerSprites) {
|
||
const z = scrollZ(entry.t, now, hitZ, TS);
|
||
if (z < -HIGHWAY_LEN || z > hitZ + 6 * K) {
|
||
entry.sprite.visible = false;
|
||
continue;
|
||
}
|
||
entry.sprite.visible = true;
|
||
entry.sprite.position.z = z;
|
||
}
|
||
// Gentle hit-line pulse — two material opacity writes, no
|
||
// allocations. With bloom active the additive planes accumulate
|
||
// into an HDR target and the UnrealBloomPass amplifies them —
|
||
// full-strength they flood the whole keyboard white — so damp
|
||
// the pulse on that path; the composer's glow makes up the
|
||
// difference.
|
||
// Must mirror draw()'s composer gate exactly (incl. the
|
||
// splitscreen checks) — damping on the direct-render path would
|
||
// leave the hit line visibly dimmer.
|
||
const glowScale = (_bloomGateOk() && _composer) ? 0.45 : 1;
|
||
// Base pulse + the per-hit brightness kick (decayed in draw()'s
|
||
// wall-clock FX step, scaled by the Hit feedback slider).
|
||
const pulse = Math.min(1,
|
||
(0.72 + 0.18 * Math.sin(now * 5.0) + 0.5 * _hitGlowKick * fx.hitFx) * glowScale);
|
||
for (let i = 0; i < _hitGlowMats.length; i++) _hitGlowMats[i].opacity = pulse;
|
||
|
||
// Missed-note sweep — only while a MIDI device is connected
|
||
// (without one, every note would count as a miss and corrupt
|
||
// accuracy), and never retroactively across a mid-song connect.
|
||
// Only the focused instance scores: note-ons route solely to
|
||
// _activeInstance, so sweeping misses anywhere else would accrue
|
||
// misses with no possible hits and corrupt that run's stats
|
||
// (splitscreen / overlapping renderer lifetimes).
|
||
// Gate on _midiHandle (the live wired session), NOT _midiInput: the
|
||
// latter is set as soon as a device is picked, but the async mi.open()
|
||
// may still be pending (slow / permission prompt), during which no
|
||
// events can arrive — sweeping then would bank false misses. _midiHandle
|
||
// is truthy only after a handle is opened and wired.
|
||
if (_midiHandle && _notation && _activeInstance === instance) {
|
||
if (_midiJustConnected) {
|
||
_missFloor = now;
|
||
_midiJustConnected = false;
|
||
}
|
||
const swept = sweepMissed(
|
||
_notation.notes, now, _hitNoteKeys, _missedNoteKeys,
|
||
HIT_TOLERANCE_S, _missFloor, _onSweptMiss, _sweepCursor,
|
||
);
|
||
if (swept) { _misses += swept; _streak = 0; }
|
||
}
|
||
_latestTime = now;
|
||
}
|
||
|
||
async function loadNotationForCurrentSong() {
|
||
const song = window.slopsmith && window.slopsmith.currentSong;
|
||
if (!song || !song.filename) return;
|
||
const seq = ++_loadSeq;
|
||
try {
|
||
const { measures } = await fetchNotation(song.filename, song.arrangementIndex != null ? song.arrangementIndex : -1);
|
||
if (seq !== _loadSeq || !_isReady) return; // superseded / torn down
|
||
const notes = flattenNotation(measures);
|
||
_notation = {
|
||
notes,
|
||
range: keyRange(notes),
|
||
markers: measureMarkers(measures),
|
||
};
|
||
buildKeyboardAndHighway();
|
||
buildNoteMeshes();
|
||
buildMarkerSprites();
|
||
// Finalize the OUTGOING run before we clobber its scoring
|
||
// state. The host may emit song:loaded for the next song
|
||
// before the prior run's song:ended/stop (or never emit one
|
||
// for a seamless switch); without this the previous run would
|
||
// post zero times, or worse, post the new song's metadata.
|
||
// _finalizeRun is a no-op when nothing was scored or it
|
||
// already posted, so a normal end→load sequence is unaffected.
|
||
// Not awaited: _finalizeRun latches _recordedThisRun and
|
||
// snapshots the POST body synchronously BEFORE its `await
|
||
// fetch`, so the reset below can't corrupt it — and a slow or
|
||
// hung /api/stats must never block the next chart's load.
|
||
void _finalizeRun();
|
||
// Fresh run: scoring + once-per-run stats latch reset here.
|
||
_resetScoring();
|
||
_recordedThisRun = false;
|
||
_runMeta = {
|
||
filename: song.filename,
|
||
arrangement: Number.isFinite(Number(song.arrangementIndex)) ? Number(song.arrangementIndex) : 0,
|
||
};
|
||
_ndOpenBindingForChart(_notation.range, seq);
|
||
_emitDomain('renderer-ready', { providerId: 'keys_highway_3d' });
|
||
} catch (e) {
|
||
console.error('[Keys-Hwy3D] notation load failed:', e);
|
||
_emitDomain('renderer-failed', { providerId: 'keys_highway_3d', reason: 'notation load failed' });
|
||
}
|
||
}
|
||
|
||
// Per-song note-detection binding: close the previous one, register
|
||
// the provider (idempotent) and open a binding scoped to this
|
||
// chart's keys range. All guarded — degrades silently when the
|
||
// spec-009 host is absent. `seq` is the chart-load sequence: a
|
||
// rapid song switch can land a stale open-binding response after
|
||
// the next chart is active, which would misattribute hit/miss
|
||
// events and leak the live binding — so the write-back is gated on
|
||
// the same supersession check the notation fetch uses, and a
|
||
// superseded binding is closed instead of stored.
|
||
async function _ndOpenBindingForChart(range, seq) {
|
||
if (_ndBindingId) {
|
||
_capCommand('note-detection', 'close-binding', { bindingId: _ndBindingId },
|
||
'Song changed — close the previous keys binding');
|
||
_ndBindingId = null;
|
||
}
|
||
await _ndEnsureProvider();
|
||
const p = await _capCommand('note-detection', 'open-binding', {
|
||
providerId: ND_PROVIDER_ID,
|
||
context: { arrangement: 'keys', midiLow: range.activeLow, midiHigh: range.activeHigh },
|
||
}, 'Open a keys verify binding for the loaded chart');
|
||
const bindingId = p && p.bindingId;
|
||
if (!bindingId) return;
|
||
if (seq !== _loadSeq || !_isReady) {
|
||
_capCommand('note-detection', 'close-binding', { bindingId },
|
||
'Superseded by a newer chart load');
|
||
return;
|
||
}
|
||
_ndBindingId = bindingId;
|
||
}
|
||
|
||
// The one condition under which the bloom composer path may render:
|
||
// enabled, single instance of this viz, and no host splitscreen
|
||
// (mixed splits with another renderer included). updateScene's
|
||
// hit-line damping and draw()'s render tail must agree on this.
|
||
function _bloomGateOk() {
|
||
return fx.bloom && _instances.size === 1 && !_ssActive();
|
||
}
|
||
|
||
/* ── Bloom (PORTED FROM highway_3d/screen.js _bloomEnsure — keep in
|
||
* sync; deliberate delta: this copy tracks pixel-ratio changes via
|
||
* composer.setPixelRatio (here and in applySize) because renderScale
|
||
* changes the ratio at runtime — the upstream composer never learns
|
||
* about ratio changes after construction, a candidate fix to port
|
||
* back to highway_3d) ── */
|
||
// Lazy-load the vendored postprocessing addons and build an
|
||
// EffectComposer (RenderPass -> UnrealBloomPass -> OutputPass/ACES).
|
||
// Returns the composer once ready, or null (caller falls back to a
|
||
// direct render — also the permanent path if the addons are missing,
|
||
// e.g. an older self-hosted core without static/vendor/three/addons).
|
||
function _bloomEnsure() {
|
||
if (_composer) return _composer;
|
||
if (_bloomLoad || !ren || !scene || !cam) return null;
|
||
const A = '/static/vendor/three/addons/';
|
||
const myGen = _bloomGen; // superseded by any _bloomDispose()
|
||
_bloomLoad = Promise.all([
|
||
import(A + 'postprocessing/EffectComposer.js'),
|
||
import(A + 'postprocessing/RenderPass.js'),
|
||
import(A + 'postprocessing/UnrealBloomPass.js'),
|
||
import(A + 'postprocessing/OutputPass.js'),
|
||
]).then(([EC, RP, UB, OP]) => {
|
||
try {
|
||
// Torn down or superseded mid-load (a dispose clears
|
||
// _bloomLoad, letting a NEW load start against the new
|
||
// renderer — this stale completion must not also build
|
||
// and orphan a composer).
|
||
if (myGen !== _bloomGen || _composer) return;
|
||
if (!ren || !scene || !cam || !highwayCanvas) return;
|
||
const w = Math.max(2, (highwayCanvas.clientWidth || highwayCanvas.width || 1280) | 0);
|
||
const h = Math.max(2, (highwayCanvas.clientHeight || highwayCanvas.height || 720) | 0);
|
||
// Multisampled (WebGL2 MSAA) HalfFloat target so anti-aliasing
|
||
// survives the bloom path — EffectComposer's default target has
|
||
// no `samples`.
|
||
const rt = new T.WebGLRenderTarget(w, h, { type: T.HalfFloatType, samples: 4 });
|
||
const comp = new EC.EffectComposer(ren, rt);
|
||
comp.setPixelRatio(ren.getPixelRatio());
|
||
comp.addPass(new RP.RenderPass(scene, cam));
|
||
_bloomPass = new UB.UnrealBloomPass(new T.Vector2(w, h), 0.65, 0.5, 0.82); // strength, radius, threshold (high → only emissive blooms)
|
||
comp.addPass(_bloomPass);
|
||
comp.addPass(new OP.OutputPass());
|
||
comp.setSize(w, h);
|
||
_bloomW = w; _bloomH = h; _composer = comp;
|
||
} catch (e) { console.warn('[Keys-Hwy3D] bloom init failed', e); _composer = null; }
|
||
}).catch((e) => console.warn('[Keys-Hwy3D] bloom modules failed', e));
|
||
return null;
|
||
}
|
||
|
||
// Drop the composer + its render targets (teardown path). Nulling
|
||
// _bloomLoad lets _bloomEnsure rebuild lazily on a later init.
|
||
function _bloomDispose() {
|
||
if (_composer) {
|
||
// EffectComposer.dispose() only frees its own read/write
|
||
// buffers — passes own additional GPU resources (UnrealBloom
|
||
// keeps several render targets + materials, OutputPass a
|
||
// material), so dispose each pass explicitly first.
|
||
try {
|
||
for (const p of _composer.passes || []) {
|
||
if (p && typeof p.dispose === 'function') p.dispose();
|
||
}
|
||
} catch (_) {}
|
||
try { _composer.dispose(); } catch (_) {}
|
||
}
|
||
_composer = null;
|
||
_bloomPass = null;
|
||
_bloomLoad = null;
|
||
_bloomW = 0; _bloomH = 0;
|
||
_bloomGen++; // invalidate any in-flight addon load
|
||
}
|
||
|
||
/* ── HUD overlay (combo / accuracy / streak — drum_highway_3d pattern) ── */
|
||
function _injectHud() {
|
||
if (_hudEl || !highwayCanvas) return;
|
||
const parent = highwayCanvas.parentElement;
|
||
if (!parent) return;
|
||
// Position the parent relative so the absolute HUD anchors to the
|
||
// canvas. Read-only check first so we don't clobber an existing
|
||
// position the host page set.
|
||
const cur = parent.style.position || getComputedStyle(parent).position;
|
||
if (cur === 'static' || !cur) {
|
||
_hudParentOrigPosition = parent.style.position;
|
||
parent.style.position = 'relative';
|
||
}
|
||
_hudEl = document.createElement('div');
|
||
_hudEl.className = 'keys-h3d-hud';
|
||
_hudEl.style.cssText = [
|
||
// Below the host's top-left song-info block (title /
|
||
// arrangement / tuning, ~3 lines) so the two never overlap.
|
||
'position:absolute', 'top:96px', 'left:14px',
|
||
'font-family:system-ui,sans-serif', 'font-size:13px',
|
||
'color:#e2e8f0', 'pointer-events:none', 'z-index:6',
|
||
'text-shadow:0 1px 2px rgba(0,0,0,0.8)',
|
||
'min-width:140px', 'line-height:1.4',
|
||
].join(';');
|
||
parent.appendChild(_hudEl);
|
||
_refreshHud();
|
||
}
|
||
|
||
function _removeHud() {
|
||
if (_hudEl) {
|
||
const parent = _hudEl.parentNode;
|
||
if (parent) {
|
||
parent.removeChild(_hudEl);
|
||
// Restore position only if _injectHud changed it.
|
||
if (_hudParentOrigPosition !== null) {
|
||
parent.style.position = _hudParentOrigPosition;
|
||
_hudParentOrigPosition = null;
|
||
}
|
||
}
|
||
}
|
||
_hudEl = null;
|
||
}
|
||
|
||
function _refreshHud() {
|
||
if (!_hudEl) return;
|
||
// Only meaningful while a wired MIDI session can score — same
|
||
// gate as the miss sweep. Without one, show nothing rather than
|
||
// a frozen 0× combo.
|
||
if (!_midiHandle) {
|
||
if (_hudEl.innerHTML) _hudEl.innerHTML = '';
|
||
return;
|
||
}
|
||
const total = _hits + _misses;
|
||
const pct = total ? Math.round((_hits / total) * 100) : 0;
|
||
const comboColor = _streak >= 30 ? '#fde047' :
|
||
_streak >= 10 ? '#86efac' : '#cbd5e1';
|
||
_hudEl.innerHTML =
|
||
`<div style="color:${comboColor};font-weight:600;font-size:18px">${_streak}× combo</div>` +
|
||
`<div>${_hits}/${total} (${pct}%)</div>` +
|
||
(_bestStreak ? `<div style="color:#94a3b8;font-size:11px">best ${_bestStreak}</div>` : '');
|
||
}
|
||
|
||
function applySize(w, h) {
|
||
if (!ren || !cam || !w || !h) return;
|
||
// Splitscreen: cap the base DPR harder (1.25 vs 2, mirroring
|
||
// highway_3d) so two panels don't double the fill cost. Checks
|
||
// the host split state (covers a mixed split with another
|
||
// renderer) plus our own instance count (covers multi-instance
|
||
// without host state). Before this PR no setPixelRatio was ever
|
||
// called — HiDPI displays rendered at CSS resolution and looked
|
||
// soft/aliased.
|
||
const baseDPR = (_ssActive() || _instances.size > 1)
|
||
? Math.min(window.devicePixelRatio || 1, 1.25)
|
||
: Math.min(window.devicePixelRatio || 1, 2);
|
||
ren.setPixelRatio(_renderScale * baseDPR);
|
||
ren.setSize(w, h, false);
|
||
if (_composer) {
|
||
// EffectComposer snapshots the renderer's pixelRatio — it must
|
||
// be told about both ratio and box changes or bloom renders at
|
||
// the wrong resolution.
|
||
_composer.setPixelRatio(ren.getPixelRatio());
|
||
_composer.setSize(w, h);
|
||
_bloomW = w; _bloomH = h;
|
||
}
|
||
cam.aspect = w / h;
|
||
cam.updateProjectionMatrix();
|
||
// Record the logical size actually framed for, so the draw()
|
||
// drift check can tell when the live canvas box has moved away
|
||
// from it (PORTED FROM highway_3d).
|
||
_appliedW = w; _appliedH = h;
|
||
_sizeFxCanvas();
|
||
}
|
||
|
||
function teardown() {
|
||
_bloomDispose();
|
||
// HUD cleanup lives here (not only destroy): init() re-runs
|
||
// teardown() for renderer re-initialization, possibly against a
|
||
// different canvas — a stale _hudEl would both linger in the old
|
||
// parent and make the next _injectHud() an early-return no-op.
|
||
_removeHud();
|
||
if (_fxHandler) {
|
||
window.removeEventListener('keys3d:settings', _fxHandler);
|
||
_fxHandler = null;
|
||
}
|
||
if (_fxThemeHandler) {
|
||
window.removeEventListener('keys3d:settings', _fxThemeHandler);
|
||
_fxThemeHandler = null;
|
||
}
|
||
_disposeEnv();
|
||
_bgTeardownStyle();
|
||
_removeFxCanvas();
|
||
_railMats.length = 0;
|
||
ambLight = dirLight = _floorMat = null;
|
||
const sm = window.slopsmith;
|
||
if (sm && typeof sm.off === 'function') {
|
||
if (_songHandler) sm.off('song:loaded', _songHandler);
|
||
if (_endHandler) sm.off('song:ended', _endHandler);
|
||
if (_stopHandler) sm.off('song:stop', _stopHandler);
|
||
}
|
||
_songHandler = _endHandler = _stopHandler = null;
|
||
if (_ndBindingId) {
|
||
_capCommand('note-detection', 'close-binding', { bindingId: _ndBindingId },
|
||
'Renderer torn down');
|
||
_ndBindingId = null;
|
||
}
|
||
_clearGroup(notesGroup);
|
||
_clearGroup(markersGroup);
|
||
_clearGroup(keyboardGroup);
|
||
_clearGroup(_flamesGroup);
|
||
_flamePool.length = 0;
|
||
if (_sparkPts) {
|
||
try { _sparkPts.geometry.dispose(); _sparkPts.material.dispose(); } catch (_) {}
|
||
_sparkPts = null;
|
||
_sparkPos = _sparkCol = _sparkVel = _sparkLife = null;
|
||
}
|
||
_laneGuideMats.length = 0; // owned + disposed with keyboardGroup
|
||
_hitGlowKick = 0;
|
||
_clearNoteCaches();
|
||
_clearBarTextures();
|
||
_clearFlameTextures();
|
||
if (_glowTex) { _glowTex.dispose(); _glowTex = null; }
|
||
_hitGlowMats.length = 0;
|
||
if (ren) ren.dispose();
|
||
ren = scene = cam = notesGroup = keyboardGroup = markersGroup = hitLine = null;
|
||
_flamesGroup = null;
|
||
noteMeshes = [];
|
||
markerSprites = [];
|
||
keyMeshes = new Map();
|
||
_layoutInfo = null;
|
||
_keyAnim.clear();
|
||
_keyFlash.clear();
|
||
_releaseAllHeld();
|
||
_notation = null;
|
||
_isReady = false;
|
||
}
|
||
|
||
const instance = {
|
||
contextType: 'webgl2',
|
||
|
||
init(canvas, _bundle) {
|
||
if (_isReady) teardown();
|
||
highwayCanvas = canvas;
|
||
fx = readFxSettings();
|
||
// Persisted string settings refresh here too — a palette,
|
||
// camera, theme or background style saved while no instance was
|
||
// listening (e.g. changed on the Settings screen, where the live
|
||
// viz is torn down) must not come up stale on a later init().
|
||
_palette = readPaletteSetting();
|
||
_sharpMode = readSharpModeSetting();
|
||
_camPreset = CAM_PRESETS[readCameraSetting()] || CAM_PRESETS.classic;
|
||
_theme = readThemeSetting();
|
||
_bgStyle = readBgStyleSetting();
|
||
loadThree().then(() => {
|
||
if (!highwayCanvas) return; // destroyed before load resolved
|
||
try {
|
||
ren = new T.WebGLRenderer({ canvas: highwayCanvas, antialias: true, alpha: false });
|
||
ren.setClearColor(FOG_COLOR, 1);
|
||
} catch (e) {
|
||
console.error('[Keys-Hwy3D] WebGL2 init failed:', e);
|
||
_emitDomain('renderer-failed', { providerId: 'keys_highway_3d', reason: 'webgl2 init failed' });
|
||
return;
|
||
}
|
||
buildScene();
|
||
applySize(highwayCanvas.clientWidth, highwayCanvas.clientHeight);
|
||
_fxHandler = (ev) => {
|
||
const d = ev && ev.detail;
|
||
if (!d || !d.fx) return;
|
||
// FX toggles are consumed per-frame in draw() — no
|
||
// rebuild needed; the composer stays cached while off.
|
||
for (const k of Object.keys(d.fx)) {
|
||
if (k in FX_DEFAULTS) fx[k] = d.fx[k];
|
||
}
|
||
// Vibrancy is baked into built materials — retint
|
||
// the live scene without a chart rebuild.
|
||
if ('vibrancy' in d.fx) _applyVibrancy();
|
||
if ('cinematic' in d.fx) _applyCinematic();
|
||
if ('bgIntensity' in d.fx) _bgMountStyle();
|
||
if ('glow' in d.fx) {
|
||
// Material emissive bases are also per-frame
|
||
// (updateScene) — only the cached/cloned rest
|
||
// values need a nudge here.
|
||
const base = NOTE_EMISSIVE_BASE * _glowMul();
|
||
for (const m of _noteMatCache.values()) m.emissiveIntensity = base;
|
||
}
|
||
};
|
||
_fxThemeHandler = (ev) => {
|
||
const d = ev && ev.detail;
|
||
if (d && d.theme && BG_THEMES[d.theme]) {
|
||
_theme = d.theme;
|
||
_applyTheme();
|
||
}
|
||
if (d && d.bgStyle && BG_STYLE_IDS.indexOf(d.bgStyle) !== -1) {
|
||
_bgStyle = d.bgStyle;
|
||
_bgMountStyle();
|
||
}
|
||
if (d && d.palette && PALETTE_IDS.indexOf(d.palette) !== -1) {
|
||
_palette = d.palette;
|
||
_applyPalette();
|
||
}
|
||
if (d && d.sharpMode && SHARP_MODES.indexOf(d.sharpMode) !== -1) {
|
||
// Geometry-time — takes effect on the next chart build.
|
||
_sharpMode = d.sharpMode;
|
||
}
|
||
if (d && d.camera && CAM_PRESETS[d.camera]) {
|
||
_camPreset = CAM_PRESETS[d.camera];
|
||
// Position/lookAt re-derive next frame; only the
|
||
// projection needs an explicit poke.
|
||
if (cam) {
|
||
cam.fov = _camPreset.fov;
|
||
cam.updateProjectionMatrix();
|
||
}
|
||
}
|
||
};
|
||
window.addEventListener('keys3d:settings', _fxThemeHandler);
|
||
window.addEventListener('keys3d:settings', _fxHandler);
|
||
_injectHud();
|
||
_isReady = true;
|
||
loadNotationForCurrentSong();
|
||
if (window.slopsmith && typeof window.slopsmith.on === 'function') {
|
||
_songHandler = () => loadNotationForCurrentSong();
|
||
window.slopsmith.on('song:loaded', _songHandler);
|
||
// End-of-run stats: finalize on natural end AND on
|
||
// an early stop (player closed the song) — the
|
||
// once-per-run latch makes the pair idempotent.
|
||
_endHandler = () => { _finalizeRun(); };
|
||
_stopHandler = () => { _finalizeRun(); };
|
||
window.slopsmith.on('song:ended', _endHandler);
|
||
window.slopsmith.on('song:stop', _stopHandler);
|
||
}
|
||
// MIDI lifecycle: this instance takes focus; _midiInit
|
||
// is idempotent (no repeat permission prompt) and
|
||
// _midiActive gates the async access resolution so a
|
||
// destroy() before it lands can't wire a dead handler.
|
||
_instances.add(instance);
|
||
_activeInstance = instance;
|
||
// Re-apply size now that this instance is counted in
|
||
// _instances: the applySize above ran before the add, so
|
||
// its splitscreen DPR check (size > 1) undercounted — the
|
||
// second panel of a splitscreen mount would otherwise keep
|
||
// full 2x DPR until some later resize. The already-mounted
|
||
// panel is corrected by the host's own layout resize when
|
||
// the split activates (panels change box size).
|
||
if (_instances.size > 1 || _ssActive()) {
|
||
applySize(highwayCanvas.clientWidth, highwayCanvas.clientHeight);
|
||
}
|
||
_midiInit();
|
||
_midiResume();
|
||
}).catch(() => {
|
||
_emitDomain('renderer-failed', { providerId: 'keys_highway_3d', reason: 'three load failed' });
|
||
});
|
||
},
|
||
|
||
draw(bundle) {
|
||
if (!_isReady || !ren || !scene || !cam) return;
|
||
// Host adaptive quality: consume bundle.renderScale like
|
||
// highway_3d — the host lowers it under GPU load ("Quality"
|
||
// + "Min res" controls) and applySize folds it into the DPR.
|
||
const newScale = (bundle && bundle.renderScale) || 1;
|
||
if (newScale !== _renderScale) {
|
||
_renderScale = newScale;
|
||
applySize(highwayCanvas.clientWidth, highwayCanvas.clientHeight);
|
||
}
|
||
// Keep the render matched to the highway canvas's real box
|
||
// (PORTED FROM highway_3d — keep in sync). Two drifts to catch:
|
||
// 1. Backing store (canvas.width/height) changed out from under
|
||
// us — the splitscreen hw.resize override resizes the element
|
||
// but never calls renderer.resize().
|
||
// 2. The CSS box (clientWidth/Height) drifted while the backing
|
||
// store held — e.g. the flex #highway box settling after a
|
||
// fullscreen transition, with no backing-store change and no
|
||
// resize() call, so branch 1 never fires. Without this the
|
||
// drum/keys panels stay framed for the pre-fullscreen size.
|
||
if (highwayCanvas) {
|
||
const _bsChanged = highwayCanvas.width !== _lastHwW
|
||
|| highwayCanvas.height !== _lastHwH;
|
||
_boxCheckCountdown = (_boxCheckCountdown + 1) % 10;
|
||
if (_bsChanged || _boxCheckCountdown === 0) {
|
||
const _bw = highwayCanvas.clientWidth | 0;
|
||
const _bh = highwayCanvas.clientHeight | 0;
|
||
if (_bsChanged) {
|
||
_lastHwW = highwayCanvas.width;
|
||
_lastHwH = highwayCanvas.height;
|
||
if (_bw > 0 && _bh > 0) applySize(_bw, _bh);
|
||
} else if (_bw > 0 && _bh > 0 &&
|
||
(Math.abs(_bw - _appliedW) > 1 || Math.abs(_bh - _appliedH) > 1)) {
|
||
applySize(_bw, _bh);
|
||
}
|
||
}
|
||
}
|
||
const now = (bundle && typeof bundle.currentTime === 'number') ? bundle.currentTime : 0;
|
||
if (_notation) updateScene(now);
|
||
_animateFeedback(performance.now());
|
||
// Wall-clock FX step (sparks, hit-line kick decay) —
|
||
// decoupled from song time so effects settle while paused.
|
||
{
|
||
const nowMs = performance.now();
|
||
const fdt = _fxLastWall === 0 ? 1 / 60 : Math.min(0.05, (nowMs - _fxLastWall) / 1000);
|
||
_fxLastWall = nowMs;
|
||
_sparkUpdate(fdt);
|
||
if (_hitGlowKick > 0.001) _hitGlowKick *= Math.exp(-fdt * 6);
|
||
else if (_hitGlowKick !== 0) _hitGlowKick = 0;
|
||
// Background ambience: advance the active style with the
|
||
// audio bands (zeros when reactivity is off).
|
||
if (_bgState && _bgState.s && BG_STYLES[_bgState._style]) {
|
||
const bands = fx.bgReactive ? _bgReadBands() : BG_ZERO_BANDS;
|
||
try {
|
||
BG_STYLES[_bgState._style].update(_bgState.s, bands, fdt, nowMs / 1000);
|
||
} catch (_) { /* visual-only */ }
|
||
}
|
||
}
|
||
_refreshHud();
|
||
if (fx.scoreFx) { _ensureFxCanvas(); _drawScoreFx(); }
|
||
else if (_fxCtx && _fxCanvas && _fxCanvas._dirty) {
|
||
_fxCtx.clearRect(0, 0, _fxCanvas.width, _fxCanvas.height);
|
||
_fxCanvas._dirty = false;
|
||
}
|
||
// Bloom path (PORTED FROM highway_3d): composer + ACES tone
|
||
// mapping when enabled and single-instance; direct render
|
||
// otherwise (including the frames while addons stream in).
|
||
const comp = _bloomGateOk() ? _bloomEnsure() : null;
|
||
if (comp) {
|
||
const w = highwayCanvas.clientWidth | 0, h = highwayCanvas.clientHeight | 0;
|
||
if (w > 0 && h > 0 && (w !== _bloomW || h !== _bloomH)) {
|
||
comp.setSize(w, h); _bloomW = w; _bloomH = h;
|
||
}
|
||
if (ren.toneMapping !== T.ACESFilmicToneMapping) ren.toneMapping = T.ACESFilmicToneMapping;
|
||
comp.render();
|
||
} else {
|
||
if (ren.toneMapping !== T.NoToneMapping) ren.toneMapping = T.NoToneMapping;
|
||
ren.render(scene, cam);
|
||
}
|
||
},
|
||
|
||
resize(w, h) {
|
||
if (!_isReady) return;
|
||
applySize(w, h);
|
||
},
|
||
|
||
destroy() {
|
||
_instances.delete(instance);
|
||
// If the focused instance is going away but others remain
|
||
// (splitscreen teardown of one panel), promote a survivor so
|
||
// _midiOnMessage keeps routing instead of dropping every
|
||
// event on its `if (!_activeInstance) return` guard.
|
||
if (_activeInstance === instance) {
|
||
_activeInstance = null;
|
||
for (const inst of _instances) { _activeInstance = inst; break; }
|
||
}
|
||
if (_instances.size === 0) _midiReleaseSession();
|
||
teardown(); // includes _removeHud()
|
||
// Instances are reused across songs (destroy() → init()); stale
|
||
// applied/backing dims would suppress the first reframe of the
|
||
// next song (PORTED FROM highway_3d).
|
||
_lastHwW = 0; _lastHwH = 0;
|
||
_appliedW = 0; _appliedH = 0;
|
||
highwayCanvas = null;
|
||
},
|
||
|
||
// ── Module MIDI router surface (focused-instance dispatch) ──
|
||
_handleNoteOn,
|
||
_handleNoteOff,
|
||
_handleSustain,
|
||
_releaseAllHeld,
|
||
|
||
// ── Headless test hooks (window.__keysHwTest) ───────────────
|
||
_injectNoteOn(midi, when) {
|
||
// Behaves like a device note-on of `midi` at song-time
|
||
// `when` (defaults to the last drawn bundle time).
|
||
_handleNoteOn(midi, 100, when);
|
||
},
|
||
_getScore() {
|
||
return {
|
||
hits: _hits,
|
||
misses: _misses,
|
||
streak: _streak,
|
||
bestStreak: _bestStreak,
|
||
accuracy: accuracyOf(_hits, _misses),
|
||
score: scoreOf(_hits, _misses),
|
||
};
|
||
},
|
||
};
|
||
return instance;
|
||
}
|
||
|
||
/* ======================================================================
|
||
* Register
|
||
* ====================================================================== */
|
||
|
||
window.slopsmithViz_keys_highway_3d = createFactory;
|
||
// slopsmith→feedBack rename: host viz picker looks up `window.feedBackViz_<id>`.
|
||
window.feedBackViz_keys_highway_3d = window.slopsmithViz_keys_highway_3d;
|
||
// Static contextType so core can make canvas-swap decisions before
|
||
// constructing a renderer instance (and gate Auto on WebGL2).
|
||
window.slopsmithViz_keys_highway_3d.contextType = 'webgl2';
|
||
// Auto-select on arrangements that carry notation (sloppak-spec §5.3).
|
||
// Predicates receive the raw song_info wire frame — snake_case field.
|
||
// Directory name `keys_highway_3d` sorts before `piano` and `staffview`
|
||
// so Auto prefers the 3D highway when all three match.
|
||
window.slopsmithViz_keys_highway_3d.matchesArrangement = function (songInfo) {
|
||
return !!(songInfo && songInfo.has_notation);
|
||
};
|
||
// Pure data-layer + scoring hooks for headless tests.
|
||
window.slopsmithViz_keys_highway_3d.__test = {
|
||
_resolveFreeCam,
|
||
_ssApi,
|
||
beatDurSec,
|
||
flattenNotation,
|
||
keyRange,
|
||
measureMarkers,
|
||
noteLetter,
|
||
scrollZ,
|
||
noteKey,
|
||
accuracyOf,
|
||
scoreOf,
|
||
judgeHit,
|
||
sweepMissed,
|
||
readFxSettings,
|
||
readThemeSetting,
|
||
readBgStyleSetting,
|
||
readPaletteSetting,
|
||
readCameraSetting,
|
||
readSharpModeSetting,
|
||
SHARP_MODES,
|
||
_bgThemeColors,
|
||
BG_THEMES,
|
||
BG_STYLE_IDS,
|
||
NOTE_PALETTES,
|
||
PITCH_CLASS_COLORS,
|
||
PALETTE_IDS,
|
||
OCTAVE_HUES,
|
||
octaveNoteColor,
|
||
_isBlackPc,
|
||
laneSpanFlat,
|
||
laneSpanReal,
|
||
CAM_PRESETS,
|
||
FX_DEFAULTS,
|
||
FX_RANGES,
|
||
_classifyTiming,
|
||
_pickMidiTarget,
|
||
};
|
||
|
||
// Headless verification hook: lets Playwright drive synthetic note-ons
|
||
// through the full hit-detection + feedback path and read the live
|
||
// score without a physical MIDI device.
|
||
window.__keysHwTest = {
|
||
injectNoteOn(midi, when) {
|
||
if (_activeInstance && typeof _activeInstance._injectNoteOn === 'function') {
|
||
_activeInstance._injectNoteOn(midi, when);
|
||
}
|
||
},
|
||
getScore() {
|
||
return (_activeInstance && typeof _activeInstance._getScore === 'function')
|
||
? _activeInstance._getScore() : null;
|
||
},
|
||
};
|
||
})();
|