diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b1ef16f..ff6d37ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Auto-sync: DTW step constraint — riff-based songs no longer produce garbage sync points.** `librosa.sequence.dtw`'s default step pattern allows unbounded horizontal/vertical path runs, and on music with long self-similar chroma stretches (riff-driven stoner/doom, drone sections) the flat cost surface let the warping path collapse — minutes of score mapped onto a single audio frame, so the per-bar warp imported charts wildly out of sync while reporting success (observed on a real 138 BPM tab: effective displayed tempo 159 BPM, three sync points sharing one audio timestamp). `_dtw_align` now uses the standard music-sync slope-constrained step pattern (`[[1,1],[1,2],[2,1]]`, local tempo ratio bounded to 0.5x–2x), which makes the degenerate path impossible, with a fallback to unconstrained steps when the global length ratio makes the constrained pattern infeasible (e.g. a tab aligned against a full-concert video). Validated on the failing song: coarse points track the recording 1:1, refined downbeats land on onset peaks at 3.3x background energy. ### Added +- **3D Keys Highway: key layout modes, lane-color opacity & octave lines.** A new **Highway layout** settings section rebuilds how sharps/flats and lanes draw on the 3D piano highway. **Sharps & flats layout** (`keys3d_bg_sharpMode`) picks between **floating** (the original raised-sharp look), **flat** (one plane, zero-overlap piano-shaped tiled lanes with the naturals evened out), and **realistic** (one plane, bars sized like the physical keys) — default **realistic**; the geometry lives in pure, unit-tested `laneSpanFlat()`/`laneSpanReal()` helpers. **Lane color opacity** (`keys3d_bg_laneOpacity`, 0–1, default 0) fades the pitch-class lane tint from full vivid color down to a dark floor with guide lines only at the key-block boundaries (E→F and each octave); the lane strips, per-lane separators and block lines crossfade with the value. **Octave separators** (`keys3d_bg_octaveGaps`, default on) and **Octave line contrast** (`keys3d_bg_octaveContrast`, 0–1) control the B→C octave divider, which auto-shifts from a dark to a bright layer as lane opacity fades. Settings re-read on init and apply on the next chart build. `plugins/keys_highway_3d` → 0.2.0. Tests: `plugins/keys_highway_3d/tests/fx_settings.test.js` (new defaults, sharp-mode setting, lane-geometry tiling/evening for flat, uniform/overlap for realistic, and an active-range boundary case where a white key's edge stays untrimmed when its neighboring sharp falls outside the active range). - **Handedness (left-handed) is now a first-class choice in the instrument selector — and surfaced during onboarding.** Left-handed players could already mirror the highway, but only via a buried Settings toggle they had to find *after* setup — so a lefty hit the tour, the tuner and calibration all right-handed first. The v3 instrument badge popover now has a **Handedness: Right / Left** row alongside Instrument / Strings / Tuning (all player-orientation choices), writing the same `lefty` preference (`highway.setLefty` when a live highway exists, else the `lefty` localStorage key it reads on init; the Settings checkbox stays in sync). The first-run tour's "Choose your instrument" step — which runs **before** the tuner/audio-calibration steps — now calls it out so lefties flip it up front. Frontend-only, additive: `static/v3/badges.js`, `static/v3/onboarding-tour.js`. Tests: `tests/js/badges_handedness.test.js`. - **"Colorblind (deuteranope)" highway string-color preset.** Adds a one-click preset to the shared "Highway String Colors" picker, sitting next to the existing Okabe–Ito "Colorblind-friendly" preset — contributed by a deuteranopic player who found the Okabe–Ito set still hard to separate. It retunes the six main strings (red / yellow-green / blue / orange / teal / deep-purple) and keeps that set's 7/8-string colors, and applies to **both** the 2D and 3D highways via the shared picker. Frontend-only, additive: `static/app.js` (`HWC_PRESETS`). - **`lib/gp_autosync.py`: piecewise time-warp helpers + a working `refine_sync()`.** `auto_sync()` has always computed per-bar sync points (DTW), but consumers could only apply the scalar bar-1 `audio_offset`, so any tempo difference between the recording and the tab's authored tempo accumulated audibly over the song. New librosa-free helpers expose the full mapping: `bar_start_times(gp_path)` (per-bar score times on the same axis as the sync points — GPIF bar-resolution map for `.gp`/`.gpx`, per-tick integration for GP3/4/5), `build_warp_anchors(points, bar_starts)` (strictly-monotonic `(score, audio)` anchor pairs), `warp_time(t, anchors)` (piecewise-linear map with edge-slope extrapolation for count-ins/tails), `warp_song_times(song, warp)` (retimes a `lib.song.Song` in place: notes + sustains, chords, beats, sections, anchors, handshapes, per-phrase difficulty levels, tone changes, tempo overrides), and `gp_has_expandable_repeats(gp_path)` (detects GP3/4/5 repeat/volta/direction markup whose playback expansion the as-written sync points cannot map — callers fall back to offset-only sync). Also implements `refine_sync()`, which the editor plugin's refine-sync endpoint has imported since the snapshot but which never existed in core (the Refine button 500'd): it densifies the coarse DTW points to every Nth bar and re-times each with a local onset phase sweep (sweep radius clamped under half a beat so periodic material can't lock a full beat off; short scoring grid + median residual snap). Synthetic click-track validation: ~13ms mean / ~40ms max error from ±180ms coarse input across 117–123 BPM recordings of a 120 BPM tab. Tests: `tests/test_gp_autosync_warp.py`. diff --git a/plugins/keys_highway_3d/README.md b/plugins/keys_highway_3d/README.md index 7616264c..6c214d63 100644 --- a/plugins/keys_highway_3d/README.md +++ b/plugins/keys_highway_3d/README.md @@ -3,13 +3,34 @@ RS+-style falling-note 3D piano highway for [Slopsmith](https://github.com/got-feedback/feedback), fed by the **Sloppak Notation Format** (sloppak-spec §5.3) — part of the piano/keys first-class epic (slopsmith#828, plugin workstream slopsmith#824). - Consumes the `notation_info` / `notation_measures` highway-WS stream over a private per-instance socket and flattens measure → staff → voice → beat → note into `{midi, t, durSec, hand}` (durations derived from written `dur`/`dot`/`tu` at the running tempo; ties extend; overlap-clamped). -- 3D perspective highway to a vanishing point with a real white/black-key keyboard; per-key **pitch-class colours** (Synthesia convention — C red, D yellow, E blue, …) with hand (rh/lh) as a secondary brightness cue. Selectable **note-colour palettes** (settings → Note colours, `keys3d_bg_palette`, default the per-octave scheme): a per-octave rainbow (each octave its own hue, darker sharps), the original per-pitch "Rainbow" table, vivid/pastel per-pitch variants, and single-hue two-tone palettes (uniform naturals, darker sharps) for players who want "black key coming" to read at a glance; notes, key glow, lane guides and hit flames all follow the pick live. +- 3D perspective highway to a vanishing point with a real white/black-key keyboard; per-key **pitch-class colors** (Synthesia convention — C red, D yellow, E blue, …) with hand (rh/lh) as a secondary brightness cue. Selectable **note-color palettes** (settings → Note colors, `keys3d_bg_palette`, default the per-octave scheme): a per-octave rainbow (each octave its own hue, darker sharps), the original per-pitch "Rainbow" table, vivid/pastel per-pitch variants, and single-hue two-tone palettes (uniform naturals, darker sharps) for players who want "black key coming" to read at a glance; notes, key glow, lane guides and hit flames all follow the pick live. - Full RS+ visual treatment: key **letter glyphs** printed on the active-range key tops (cached CanvasTextures), **bevelled gem-style note blocks** (ExtrudeGeometry, geometry/material caches keyed by size and pitch-class×hand), **floating bar numbers** scrolling with the notes, **active-range lane dimming** so the playable span pops, and a **glowing pulsing hit-line** (layered additive gradient planes — no postprocessing). - Performance discipline: no per-frame allocations or DOM queries in `draw()`. Chart-scoped resources — note geometries/materials, bar-number and glow textures — are cached and disposed on chart teardown; the key-letter glyph `CanvasTexture`s live in a shared module-level cache that survives teardown and is reused across instances. - Auto-selected for arrangements with notation via `matchesArrangement(songInfo.has_notation)`; capability-native `visualization` provider declaration. - **Camera settings**: camera-rig presets (`keys3d_bg_camera` — classic low rig / elevated / overhead; default overhead, applied live, adaptive pan-zoom preserved) with base-rig fine-tune sliders for height, distance and tilt (`keys3d_bg_camHeight` / `camDist` / `camTilt`) that nudge the vantage point the follow-motion orbits. Numeric FX keys clamp to per-key declared ranges (`FX_RANGES`, default 0–1). +- **Highway-layout options** (settings → Highway layout). **Sharps & flats** + (`keys3d_bg_sharpMode`, string; default `realistic`) picks the sharp layout: + `floating` (original raised-plane sharps, white-only lanes); `flat` (one plane, + zero-overlap piano-shaped tiled lanes — white lanes trimmed where a sharp adjoins + them, and each sharp leaned toward the edge natural beside it so the naturals come + out close to even: C/D/E/F/B equal, G/A a hair smaller since G# can't lean; pure + `laneSpanFlat()`); `realistic` (one plane, bars sized like the physical keys — full + naturals always rendered full, full black keys drawn on top and only occluding a + natural where a sharp note actually coincides in time; pure `laneSpanReal()`). + **Lane color opacity** (`keys3d_bg_laneOpacity`, 0–1, default 0) fades the + pitch-class lane tint; at 0 (default) the strips are a dark floor with guide lines + only at the key-block boundaries (E→F and each octave B→C), so each block is bounded + rather than every lane — the notes keep their colors; toward 1 it fills in full, + vivid colored lanes. The strips, per-lane separators and block lines crossfade with + this value. **Octave separators** (`keys3d_bg_octaveGaps`, default on) widens the + gap a touch at each B→C octave boundary. **Octave line contrast** + (`keys3d_bg_octaveContrast`, 0–1, default 0.5) scales how hard the B→C octave line + reads; it is drawn as a dark layer (scaled by lane opacity) plus a bright layer + (scaled by its inverse), so it auto-shifts dark→bright as the lanes fade — no mode + switch needed. All are geometry-time — applied on the next chart build via + `init()`'s re-read. - **Web MIDI input scoring**: module-level MIDI singleton (one access per tab, focused-instance routing) with device auto-connect by saved id+name, loopback blocklist, channel filter, transpose and CC64 sustain (`keys3d_` localStorage prefix; `window.keysH3d*` settings API). Hit detection matches played MIDI against the flattened chart notes within ±0.10 s with per-note dedupe and a missed-note sweep (only while a device is connected — never retroactive across a mid-song connect). -- **Live hit feedback on the MIDI path** (not the chart): key depress (~4° back-edge pivot, ~120 ms spring; the key letter rides along), wrong-note red key flash, and a vertical flame flare on hits (pooled additive sprites, white-hot base fading into the pitch-class colour, ~400 ms). +- **Live hit feedback on the MIDI path** (not the chart): key depress (~4° back-edge pivot, ~120 ms spring; the key letter rides along), wrong-note red key flash, and a vertical flame flare on hits (pooled additive sprites, white-hot base fading into the pitch-class color, ~400 ms). - **End-of-run stats**: POSTs `/api/stats` `{filename, arrangement, score, accuracy}` exactly once per run with the same formula as the guitar notedetect path (`accuracy = hits / max(1, hits+misses)`, `score = round(hits·100·accuracy)`), then notifies the progression core when present. - **Capability wiring** (all guarded for servers without the hosts): registers as a note-detection `midi` provider (`keys-midi`, `verify.target`), opens a per-song binding scoped to the chart's keys range, reports hit/miss observability events, and exposes Web MIDI inputs to the audio-input domain with pseudonymized labels (`midi-input-1`, …) via `source.enumerate/describe/open/close`. - Headless test hook: `window.__keysHwTest = { injectNoteOn(midi, when), getScore() }`. diff --git a/plugins/keys_highway_3d/plugin.json b/plugins/keys_highway_3d/plugin.json index 47b3ff0e..80bb0087 100644 --- a/plugins/keys_highway_3d/plugin.json +++ b/plugins/keys_highway_3d/plugin.json @@ -1,7 +1,7 @@ { "id": "keys_highway_3d", "name": "Keys Highway 3D", - "version": "0.1.2", + "version": "0.2.0", "description": "RS+-style 3D falling-note piano highway fed by the Sloppak Notation Format, with Web MIDI input scoring.", "type": "visualization", "bundled": true, diff --git a/plugins/keys_highway_3d/screen.js b/plugins/keys_highway_3d/screen.js index e38ab891..1871b32c 100644 --- a/plugins/keys_highway_3d/screen.js +++ b/plugins/keys_highway_3d/screen.js @@ -9,7 +9,7 @@ // // Visual contract is the frame analysis on slopsmith#824 (RS+ reference): // 3D perspective highway to a vanishing point, notes landing on a real 3D -// keyboard, per-key Synthesia-style PITCH-CLASS colours (hand is only a +// 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 @@ -59,7 +59,7 @@ // World scroll speed (units / second) — matches the sibling highways. const TS = 130 * K; - // Per-pitch-class colours (Synthesia convention observed in the RS+ + // 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. @@ -79,11 +79,11 @@ ]; // Hand cue is SECONDARY (slopsmith#824 design call): right hand renders - // at full brightness, left hand slightly darkened — colour stays the + // at full brightness, left hand slightly darkened — color stays the // pitch class. const HAND_BRIGHTNESS = { rh: 1.0, lh: 0.72 }; - // Selectable note-colour palettes. Index = midi % 12, same contract as + // 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: @@ -151,7 +151,7 @@ ], }; - // Octave-based colour scheme ('octaves'): every octave gets a distinct + // 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 @@ -181,7 +181,80 @@ function _isBlackPc(midi) { return [1, 3, 6, 8, 10].indexOf(((midi % 12) + 12) % 12) !== -1; } - // Colour (24-bit int) for a midi note under the octave scheme: hue by + // 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 @@ -210,8 +283,8 @@ // Gem vertical gradient (bottom shade → top highlight), baked per-vertex into // the note geometry so a block reads as a lit 3D gem instead of a flat fill — // same approach as the bundled guitar highway_3d (`gNoteGrad`). The ramp is - // greyscale so one geometry serves every pitch-class colour; the material - // multiplies its colour by it via vertexColors. + // 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']; @@ -1026,6 +1099,14 @@ 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 @@ -1223,7 +1304,7 @@ } catch (_) { /* dispatch unavailable — persisted value applies next init */ } }; - // Note-colour palette id — string-valued like the theme, so it gets its + // 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() { @@ -1231,7 +1312,7 @@ 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 colour, darker + // Default: the octave scheme (each octave its own color, darker // sharps) — the plug-and-play piano look. Emerald/classic/etc. remain // selectable. return 'octaves'; @@ -1244,6 +1325,30 @@ } 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 @@ -1561,6 +1666,7 @@ // _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) @@ -1628,7 +1734,7 @@ return (NOTE_PALETTES[_palette] || PITCH_CLASS_COLORS)[pc]; } - // Base colour (24-bit int, no hand dimming) for a midi note under the + // 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) { @@ -1664,6 +1770,41 @@ 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 @@ -1708,7 +1849,7 @@ _rigOut.lookZ = _camPreset.lookZ; return _rigOut; } - // Per-key approach glow: a key lights in its pitch-class colour ONLY while a + // 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) @@ -2297,9 +2438,9 @@ }); // Extrusion spans z ∈ [-bevel, depth + bevel]; centre it. geo.translate(0, 0, -depth / 2); - // Bake a vertical brightness ramp into vertex colours (bottom shade → + // Bake a vertical brightness ramp into vertex colors (bottom shade → // top highlight) so the gem reads 3D; the material multiplies its - // pitch-class colour by this (vertexColors). + // 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; @@ -2314,10 +2455,10 @@ return geo; } - // Glossy note material, cached per resolved colour. Keying by the - // final colour int (hand brightness already baked in by noteColor) + // 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 colours and + // 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); @@ -2333,7 +2474,7 @@ // share one shader program.) mat = new T.MeshPhysicalMaterial({ color: col, - vertexColors: true, // multiply colour by the baked gem ramp + vertexColors: true, // multiply color by the baked gem ramp emissive: col, emissiveIntensity: NOTE_EMISSIVE_BASE * _glowMul(), roughness: 0.32, @@ -2355,7 +2496,10 @@ return 0.72 + 0.22 * Math.min(1, Math.max(0, fx.vibrancy)); } function _laneGuideOpacity() { - return 0.10 + 0.12 * Math.min(1, Math.max(0, fx.vibrancy)); + // 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 @@ -2371,13 +2515,13 @@ for (const m of _laneGuideMats) m.opacity = lop; } - // Live palette switch: recolour everything already built — cached + // 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 colour, so old + // 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). @@ -2441,10 +2585,10 @@ } // Vertical flame texture for hit flares / held-key halos: white-hot - // base fading up into the note's colour, with a horizontal falloff. - // Cached per resolved colour (bounded — 12 for pitch-class palettes, + // 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 colour whatever the palette. + // struck note's color whatever the palette. function _flameTexture(midi) { const c = _noteHex(midi); let tex = _flameTexCache.get(c); @@ -2624,9 +2768,9 @@ } } - // Lane guides: a faint colour strip running up the runway from each - // active key, in that key's pitch-class colour. A falling note shares - // its target key's colour, so the player can trace it straight down + // 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 @@ -2636,29 +2780,87 @@ // 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; - if (entry.black) continue; // one strip per semitone-slot lands on whites - const gmat = new T.MeshBasicMaterial({ - color: noteColor(midi, 'rh'), transparent: true, - opacity: _laneGuideOpacity(), depthWrite: false, - }); - gmat.userData.midi = midi; // palette retint needs the lane's pitch - _laneGuideMats.push(gmat); - const strip = new T.Mesh(new T.PlaneGeometry(WHITE_W * 0.84, guideLen), gmat); - strip.rotation.x = -Math.PI / 2; - strip.position.set(keyX(entry, whiteCount), laneY, hitZ - guideLen / 2); - keyboardGroup.add(strip); - // Thin brighter rails at the lane edges for crisp separation. - const railMat = new T.MeshBasicMaterial({ - color: (() => { const c = _bgThemeColors(_theme); return c.laneDim != null ? c.laneDim : 0x2a2a3e; })(), - transparent: true, opacity: 0.5, depthWrite: false, - }); - _railMats.push(railMat); - const rail = new T.Mesh(new T.PlaneGeometry(0.6 * K, guideLen), railMat); - rail.rotation.x = -Math.PI / 2; - rail.position.set(keyX(entry, whiteCount) - WHITE_W / 2, laneY + 0.05 * K, hitZ - guideLen / 2); - keyboardGroup.add(rail); + // 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 @@ -2678,7 +2880,7 @@ const inRange = midi >= range.activeLow && midi <= range.activeHigh; const material = new T.MeshStandardMaterial({ color: black ? 0x070708 : 0xe8e8ee, - // Pitch-class colour preset on emissive but OFF at rest — the key + // 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'), @@ -2764,11 +2966,31 @@ const entry = layout.get(note.midi); if (!entry) continue; const len = Math.max(4 * K, note.durSec * TS); - const w = (entry.black ? BLACK_W : WHITE_W * 0.94) * 0.9; + // 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 = keyX(entry, whiteCount); - mesh.position.y = (entry.black ? BLACK_H + WHITE_H : WHITE_H) + NOTE_H / 2 + 0.5 * K; + 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 @@ -3483,6 +3705,7 @@ // 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(); @@ -3533,6 +3756,10 @@ _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 @@ -3749,6 +3976,8 @@ readBgStyleSetting, readPaletteSetting, readCameraSetting, + readSharpModeSetting, + SHARP_MODES, _bgThemeColors, BG_THEMES, BG_STYLE_IDS, @@ -3757,6 +3986,9 @@ PALETTE_IDS, OCTAVE_HUES, octaveNoteColor, + _isBlackPc, + laneSpanFlat, + laneSpanReal, CAM_PRESETS, FX_DEFAULTS, FX_RANGES, diff --git a/plugins/keys_highway_3d/settings.html b/plugins/keys_highway_3d/settings.html index d413beaf..135f2ab1 100644 --- a/plugins/keys_highway_3d/settings.html +++ b/plugins/keys_highway_3d/settings.html @@ -12,11 +12,11 @@
- Choose the colour scheme for the falling notes, key glow, lane + Choose the color scheme for the falling notes, key glow, lane guides and hit flames. Each option is described in its own label.
@@ -46,8 +46,8 @@Background gradient, floor and lane rails — the same theme names - as the guitar highway. Note colours come from the - "Note colours" palette above. + as the guitar highway. Note colors come from the + "Note colors" palette above.
@@ -101,6 +101,64 @@+ How sharps and flats are drawn. Floating: they ride a raised + plane above the naturals. Non-floating: everything on one + plane, each key its own even piano-shaped lane. Realistic key + sizes: one plane, bars sized like the real keys (full naturals, + full black keys on top). Applies next time you open a song. +
+ + + ++ How strongly each lane is tinted its note color. 0.00 (default) is a + dark floor with plain guide lines only between the key blocks (at E–F + and each octave); the notes keep their colors and pop off the floor. + Raise toward 1.00 for full, vivid colored lanes. Applies next time you + open a song. +
+ + ++ Widen the gap a little at each octave boundary (every B to the C + above it) so octaves are easier to read. Applies next time you open + a song. +
+ + + ++ How strongly the octave line (every B to C) stands out. It adapts to + the lane color opacity automatically — darkening the line against + bright lanes and brightening it as you fade them toward the dark + floor. Applies next time you open a song. +
+