Files
feedBack/plugins/highway_3d/screen.js
T
Sin 4148b0e72e Purge external-format terminology from code, tests and docs
Reword comments/docstrings/strings and rename identifiers that referenced
the external game and its file formats:

- format-id "psarc" -> "archive"; local vars psarc_path -> song_path,
  psarc_base -> tone_base
- lyrics provenance value "sng" -> "notechart" (legacy "sng" still accepted)
- highway_3d fret-ghost scope value "rocksmith" -> "chords" (invalid/legacy
  values fall back to the default, preserving behaviour)
- neutralise references in prose, test names/data, .gitattributes and docs

No functional change beyond the renamed identifiers; all Python compiles.
2026-06-16 19:36:53 +01:00

13386 lines
739 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 3D Highway visualization plugin — Three.js note highway.
// Visual layer from joel's prototype (vibrant palette, glowing strings,
// fret heat, dynamic lane, chord frame-boxes, per-note connector labels,
// board projection, outline+core note meshes) adapted into the
// slopsmithViz setRenderer contract (slopsmith#36) so it works in the
// main player and per-panel in splitscreen without any architectural
// changes.
(function () {
'use strict';
/* ======================================================================
* Constants
* ====================================================================== */
// Three.js is vendored under static/vendor/three/ in core (pinned r170 —
// see static/vendor/three/VERSION). The bundled plugin loads from the
// same origin to avoid the first-launch CDN round-trip and to pin the
// version against breakages from upstream Three.js drift.
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';
// Selectable per-string color palettes (issue #10). Each palette has
// 8 entries to match MAX_RENDER_STRINGS so 6/7/8-string arrangements
// all index safely. Default is the canonical chart-format classic
// mapping (low E=red, A=yellow, D=blue, G=orange, B=green,
// high E=purple); Neon pushes saturation harder; Pastel desaturates
// for long-session comfort; Colorblind (high contrast) is derived from
// the chart format's built-in colorblind-mode palette, but this preset
// intentionally keeps some entries tuned for slopsmith rather than
// reproducing every original hex value verbatim. The chart-format base
// values came from community reverse-engineering of the original chart
// files; do not treat the tuned values below as the exact original
// palette.
// In slopsmith's index convention s=0 is the low E (thickest) and
// s=5 is the high E (thinnest), matching the chart format's native string
// indexing. Per-index ordering is preserved across all palettes so
// switching between them never reassigns a string to a different
// colour family. Indices 6/7 are supplementary slots used for
// 7/8-string arrangements.
// NOTE: settings.html mirrors these arrays in its hydration script
// for the palette-preview swatches — keep them in sync.
const PALETTES = {
default: [
0xe61f26, 0xecd234, 0x1096e6, 0xf18313,
0x3fc413, 0xb518d9, 0xff6bd5, 0x6bffe6,
],
neon: [
0xff0030, 0xffe800, 0x0080ff, 0xff8030,
0x40ff50, 0xb050ff, 0xff40d0, 0x40ffd0,
],
pastel: [
0xe89aa0, 0xefdf90, 0x9adfee, 0xefb898,
0xa6e0a8, 0xc4a6e0, 0xe0a6c8, 0xa6e0d8,
],
colorblind_hc: [
0xa42424, 0xa3f300, 0x19abfc, 0xda7e41,
0x30d0a0, 0x7648a7, 0xff6bd5, 0x6bffe6,
],
};
const PALETTE_IDS = Object.keys(PALETTES);
// User-defined per-string colors (core "Highway String Colors" theming).
// Persisted as a JSON hex array under the bg setting key 'customColors';
// when the active palette id is 'custom' the renderer resolves this into
// numeric hex, falling back to the default palette per missing index.
// Mutated in place by _resolveCustomPalette so the reference stays stable.
let _customPalette = PALETTES.default.slice();
function _h3dHexToInt(hex) {
if (typeof hex !== 'string') return null;
const t = hex.trim().replace(/^#/, '');
const full = t.length === 3 ? t[0] + t[0] + t[1] + t[1] + t[2] + t[2] : t;
if (!/^[0-9a-fA-F]{6}$/.test(full)) return null;
return parseInt(full, 16);
}
// Numeric (0xRRGGBB) darken/lighten — used to derive the gem-gradient
// top-highlight / bottom-shade stops from a custom per-string base color
// so the note bodies follow the custom palette (mirrors the 2D highway's
// dim/bright derivation). factor 0..1 keeps that fraction of each channel;
// lighten mixes t toward white.
function _clampByteI(n) { return n < 0 ? 0 : (n > 255 ? 255 : Math.round(n)); }
function _darkenInt(hex, factor) {
const r = (hex >> 16) & 0xff, g = (hex >> 8) & 0xff, b = hex & 0xff;
return (_clampByteI(r * factor) << 16) | (_clampByteI(g * factor) << 8) | _clampByteI(b * factor);
}
function _lightenInt(hex, t) {
const r = (hex >> 16) & 0xff, g = (hex >> 8) & 0xff, b = hex & 0xff;
return (_clampByteI(r + (255 - r) * t) << 16) | (_clampByteI(g + (255 - g) * t) << 8) | _clampByteI(b + (255 - b) * t);
}
// Default per-string gem gradient stops [topHighlight, bottomShade] —
// sampled from the original colour PNGs. Used verbatim for the built-in
// palettes (and for unchanged slots of a custom palette) so the stock look
// is byte-for-byte preserved; custom slots derive their stops from the
// chosen base color via _lightenInt/_darkenInt. Strings 6/7 have no entry
// and fall back to flat gNote.
const DEFAULT_GEM_GRADIENTS = [
[0xec0816, 0xbd0400], // 0 red
[0xefd20b, 0xceaa00], // 1 yellow
[0x0b93e9, 0x0e69b2], // 2 blue
[0xf77b0b, 0xdb5808], // 3 orange
[0x37c40b, 0x139305], // 4 green
[0xaf10db, 0x8907af], // 5 violet
];
// Default palette at module scope so out-of-IIFE consumers (e.g. the
// out-of-range warning's reference to "palette size") still have a
// canonical length to compare against.
const S_COL = PALETTES.default;
const SCALE = 2.25;
const K = SCALE / 300;
// Horizontal stretch factor for fret X positions. Increasing this widens
// the lane (frets, board plane, strings, notes, lane strip) without
// affecting K-based vertical dimensions (string gap, note height, camera).
const FRET_SCALE = SCALE * 1.1;
const NFRETS = 24;
const NSTR = 6;
/**
* Pure 12-semitone spacing compresses toward the bridge; multiply each
* segment **above** this fret by the factor so high positions stay
* slightly more playable/readable in 3D.
*/
const FRET_SPACING_STRETCH_ABOVE12 = 1.1;
const FRET_SPACING_ANCHOR_F = 12;
// Per-string materials and projection meshes are built via S_COL.map(),
// so the renderer can only address strings 0..S_COL.length-1. Using a
// higher count would index undefined into mGlow/mStr/mSus/projMeshArr.
// Extend S_COL above to support more strings.
const MAX_RENDER_STRINGS = S_COL.length;
// Resolve the string count for the active arrangement. Prefer
// bundle.stringCount (exposed by slopsmith core since #93 — derived
// from notes/chords/tuning, so it works for 5-string bass, 7- and
// 8-string guitar, etc.). Fall back to arrangement-name detection
// for older slopsmith cores that don't emit the field. Clamp to the
// palette size so a malformed bundle or a 12-string chart doesn't
// index past the per-string material arrays.
function resolveStringCount(bundle) {
const sc = bundle && bundle.stringCount;
if (Number.isFinite(sc) && sc >= 1) {
return Math.min(Math.trunc(sc), MAX_RENDER_STRINGS);
}
return /bass/i.test(bundle?.songInfo?.arrangement || '') ? 4 : NSTR;
}
/** Chart-format tuning entries are semitone offsets from instrument standard. */
const _NOTE_NAMES_SHARP = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
// Open-string MIDI (thick → thin), matched to RS string index 0 low.
const _BASE_OPEN_MIDI_BASS4 = Object.freeze([28, 33, 38, 43]);
const _BASE_OPEN_MIDI_BASS5 = Object.freeze([23, 28, 33, 38, 43]);
const _BASE_OPEN_MIDI_GUITAR6 = Object.freeze([40, 45, 50, 55, 59, 64]);
const _BASE_OPEN_MIDI_GUITAR7 = Object.freeze([35, 40, 45, 50, 55, 59, 64]);
// F#/B/E standard extension — low string is a fifth below RS 7string low B.
const _BASE_OPEN_MIDI_GUITAR8 = Object.freeze([28, 35, 40, 45, 50, 55, 59, 64]);
function _baseOpenStringMidis(sc, arrangement) {
const isBass = /bass/i.test(arrangement || '');
if (sc === 4 && isBass) return _BASE_OPEN_MIDI_BASS4.slice();
if (sc === 4) return _BASE_OPEN_MIDI_GUITAR6.slice(0, 4);
if (sc === 5 && isBass) return _BASE_OPEN_MIDI_BASS5.slice();
if (sc === 5) return _BASE_OPEN_MIDI_GUITAR6.slice(0, 5);
if (sc === 7) return _BASE_OPEN_MIDI_GUITAR7.slice();
if (sc === 8) return _BASE_OPEN_MIDI_GUITAR8.slice();
if (Number.isFinite(sc) && sc > 8) {
const out = Array.from(_BASE_OPEN_MIDI_GUITAR8);
let last = out[out.length - 1];
while (out.length < sc) {
last += 5;
out.push(last);
}
return out.slice(0, sc);
}
const g6 = _BASE_OPEN_MIDI_GUITAR6.slice();
if (Number.isFinite(sc) && sc < 6 && sc >= 1) return g6.slice(0, sc);
return g6;
}
function _midiToPitchLabel(midi) {
const m = Math.round(midi);
const octave = Math.floor(m / 12) - 1;
const n = _NOTE_NAMES_SHARP[(m % 12 + 12) % 12];
return n + octave;
}
/**
* @param {number} nEffective string count clamped like nStr / resolveStringCount
* @param {Record<string, unknown>} songInfo WS song_info blob (subset)
*/
function _openStringPitchLabelsForTuning(bundle, songInfo, nEffective) {
const n = Number.isFinite(nEffective) ? Math.min(Math.max(1, Math.trunc(nEffective)), MAX_RENDER_STRINGS) : resolveStringCount(bundle);
let tuning = (songInfo && songInfo.tuning) || bundle.tuning;
let cap = songInfo && songInfo.capo;
cap = Number.isFinite(cap) ? cap : (Number.isFinite(bundle.capo) ? bundle.capo : 0);
if (!Array.isArray(tuning)) tuning = [];
const base = _baseOpenStringMidis(n, songInfo?.arrangement);
const labels = [];
for (let s = 0; s < n; s++) {
const offRaw = tuning[s];
const off = Number.isFinite(offRaw) ? offRaw : 0;
const midi = (base[s] !== undefined ? base[s] : 40) + off + cap;
labels.push(_midiToPitchLabel(midi));
}
return labels;
}
const STR_THICK = 0.25 * K;
// Fret wires — bowed metal tubes (backported from highway_babylon's
// "hit-zone fret bars"). All frets share one bowed TubeGeometry whose
// middle (the middle strings) pushes away from the camera so the row of
// frets reads as wrapping a cylindrical neck — chart-format depth cue.
// Negative Z = away from camera (into the highway). All tunable.
const FRET_BOW_DZ = -1.2 * K; // middle-of-span Z offset
const FRET_TUBE_RADIUS = STR_THICK * 0.55; // ~matches old box thickness
const FRET_TUBE_SEG = 12; // tubular segments along the curve
const FRET_TUBE_RADIAL = 6; // radial segments (cross-section)
// metalness kept moderate, NOT ~1.0: MeshStandardMaterial is PBR and the
// scene has no envMap, so a full-metal fret would reflect black and render
// dark (the nut/headstock use metalness 0.02 for the same reason). At ~0.4
// the lit albedo body survives while the directional light still throws a
// glossy specular streak across the rounded tube. The dim emissive floor
// keeps frets from going muddy far down the (fogged) neck.
const FRET_METALNESS = 0.4; // lit steel / brass when gold
const FRET_ROUGHNESS = 0.3;
const FRET_EMISSIVE = 0x12141a; // cool dim floor, never fully black
const S_BASE = 3 * K;
const S_GAP = 4 * K;
const AHEAD = 3.0;
const BEHIND = 0.5;
// How long a note/chord-frame stays renderable past the hit line while a
// note-state provider (slopsmith#254) is attached. The provider's
// hit/miss verdict is asynchronous — the engine-side verifier reports it
// ~0.35-0.5 s after the line — so the default ~50 ms note linger /
// ~0.48 s chord linger lapses before the tint can apply. Drives both
// the outer-loop cull (ndVerdictT0) and the smart drawNote cull below.
const NOTEDETECT_GEM_VERDICT_WINDOW = 0.75;
// chDt threshold past the hit line at which the chord-frame scan
// gives up on an arpeggio-style frame whose constituents never come
// in. Must be < NOTEDETECT_GEM_VERDICT_WINDOW (the rim's draw life
// in detect mode); placing it at 0.55 s leaves ~0.2 s of the visible
// window for the latch to fire and skip subsequent scans.
const _ND_UNMATCHED_LATCH_AFTER = 0.55;
// Sample approach offsets dt in [0, AHEAD] into strips. Lane quads use
// z = dZ(dt) + TS*BEHIND = TS*(BEHIND - dt), while notes use z = dZ(n.t-now).
// So note hit line (z=0) aligns with dt=BEHIND, not dt=0. Chart time at
// lane parameter dt is now + dt - BEHIND (same z as a note at that time).
// Each strips <anchor> uses that chart time so the blue lane doesnt
// switch ~BEHIND seconds before the XML <anchor time="…"/>.
const HWY_LANE_TIME_SLICES = 96;
/** Odd columns (1st/3rd/…) darker teal; even columns brighter blue. */
const HWY_LANE_STRIPE_ODD_HEX = 0x103B5C;
const HWY_LANE_STRIPE_EVEN_HEX = 0x08283C;
/** Lane quad alpha: base + highwayIntensity * scale (readable on dark floor). */
const HWY_LANE_STRIPE_OP_BASE = 1.0;
const HWY_LANE_STRIPE_OP_INT = 0;
/** Venue mode: slight near-lane contrast boost (visual only). */
const VENUE_LANE_OP_BOOST = 1.1;
/** Venue mode: gem emissive pop (~12%, visual only). */
const VENUE_GEM_EMISSIVE_MUL = 1.12;
/** Venue steady-state haze coefficient — kept low for raster bg plate. */
const VENUE_HAZE_STEADY = 0.008;
/** Venue backdrop pushed slightly farther for parallax depth. */
const VENUE_BACKDROP_DISTANCE_MUL = 1.06;
/** Note travel speed. */
const TS = 230 * K;
const RENDER_ORDER_LAYER_STACK = Object.freeze([
'CHORD_FILL',
'CHORD_STRUM_FILL',
'CHORD_STRUM_LINE',
'SUSTAIN_TRAIL',
'CHORD_FRAME',
'CHORD_EDGE_GLOW',
'CONNECTOR_LINE',
'FRET_COLUMN',
'ARP_CONNECTOR_LINE',
'NOTE_OUTLINE',
'NOTE_CORE',
'TECHNIQUE_MARKER',
'BOARD_STRING',
'BOARD_FRET_WIRE',
'NOTE_FRET_LABEL',
'ARP_NOTE_FRET_LABEL',
'CHORD_FRET_LABEL',
]);
const RENDER_ORDER_LAYER_INDEX = Object.freeze(RENDER_ORDER_LAYER_STACK.reduce(
(indexByLayer, layerName, layerIndex) => {
indexByLayer[layerName] = layerIndex;
return indexByLayer;
},
Object.create(null)
));
const RENDER_ORDER_AT_Z_ZERO = 700;
const RENDER_ORDER_FAR_CLAMP = 50;
/**
* Computes renderOrder from world depth plus a named layer.
* Closer objects receive larger values and paint over farther objects; the
* layer stack breaks ties at the same depth, keeping labels above note gems.
*
* The layer index is added as a sub-unit fraction (< 1) so the integer
* depth bucket STRICTLY dominates: a farther object can never outrank a
* nearer one merely because it sits on a higher layer. Adding the raw index
* (0..N-1) directly would let the ~N-wide layer span leak across depth
* buckets and re-introduce far-over-near bleed for notes within ~N draw
* units of each other. Fraction granularity (1/N ≈ 0.06) stays well above
* the 0.0001 intra-element sub-increments used at some call sites.
*/
function renderOrderForLayerAtZ(worldZ, layerName) {
const layerIndex = RENDER_ORDER_LAYER_INDEX[layerName];
if (layerIndex === undefined) throw new Error(`Unknown 3D highway depth layer: ${layerName}`);
const depthRenderOrder = Math.max(
RENDER_ORDER_FAR_CLAMP,
Math.round(RENDER_ORDER_AT_Z_ZERO + worldZ / K)
);
return depthRenderOrder + layerIndex / RENDER_ORDER_LAYER_STACK.length;
}
/** Match `nextNoteByString` onset to this note (float + chart rounding; avoids ghost / glow flicker). */
const NEXT_ON_STRING_T_EPS = 0.06;
/** Fixed pre-impact ramp window for lead-note board ghosts (Primary + Upcoming slots). */
const GHOST_UPCOMING_WIN = 0.6;
/** Ghost starts at this fraction of full size/brightness and grows to 1.0 as it approaches. */
const PROJ_GROW_MIN = 0.45;
/**
* 3D highway post-strum tail — chord frame + ghost fret digit share the same
* hold and fade so timing stays consistent.
*/
const CHORD_HWY_LINGER_S = 0.75;
/** Linear fade at end of `CHORD_HWY_LINGER_S` (applies to chord UI and board ghost numbers). */
const CHORD_HWY_FADE_S = 0.32;
const GHOST_HOLD_AFTER_ONSET = CHORD_HWY_LINGER_S;
const GHOST_FRET_LBL_FADE_S = CHORD_HWY_FADE_S;
/** Purple lane rails: extend past last matched chord/note so Z reaches frame end. */
const ARP_HWY_RAIL_END_TAIL_S = 0.38;
/** Keep 0 — chord/note-based ``shapeLo`` already aligns to the visible frame. */
const ARP_HWY_RAIL_START_LEAD_S = 0;
/** Drives emissive (`mGlow` / accent fill) for notes with `.ac`; matches drawNote `linger` cutoff (0.05). */
const ACCENT_NOTE_STR_GLOW = 3.55;
const ACCENT_NOTE_LINGER_EPS = 0.05;
/** Extra emissive layered on accent-only body material (`mAccentCore`), after `strGlow * glowMul`. */
const ACCENT_NOTE_FILL_BOOST = 2.55;
/** Accent rim draws brighter than normal string-coloured outlines (`mStrHitOutline`). */
const ACCENT_RIM_BASE_EMISSIVE = 3.45;
/** Outline / core scale bump vs normal gems (accent reads slightly larger). */
const ACCENT_RIM_XY_SCALE_MUL = 1.09;
const ACCENT_RIM_Z_SCALE_MUL = 1.06;
// Soft neon-style outer bloom (AdditiveBlending) — layered shells behind outline/core.
const ACCENT_HALO_OP_NEAR = 0.68;
const ACCENT_HALO_OP_MID = 0.42;
const ACCENT_HALO_OP_FAR = 0.24;
const ACCENT_HALO_XY_INNER = 1.36;
const ACCENT_HALO_XY_MID = 1.82;
const ACCENT_HALO_XY_OUTER = 2.32;
const ACCENT_HALO_Z_INNER = 1.05;
const ACCENT_HALO_Z_MID = 1.12;
const ACCENT_HALO_Z_OUTER = 1.22;
/**
* Post-hit tail fade shared by ghost fret digits and 3D chord UI: full
* opacity until (holdS fadeS) after onset, then linear fade over fadeS;
* canceled when `nextSoon` — for ghosts: next note within `fadeS` of `now`;
* for chord frame: next chord onset lies in chart time [hold fade, hold]
* after the current chord (so fade does not run into a same-window handoff).
* @param {number} dt chart time minus now (negative once struck)
* @param {number} fadeS linear fade duration (default: GHOST_FRET_LBL_FADE_S)
*/
function hwyPostHitTailFadeMul(dt, holdS, nextSoon, fadeS = GHOST_FRET_LBL_FADE_S) {
if (nextSoon || dt >= 0) return 1;
const gone = -dt;
if (gone >= holdS) return 0;
const fS = Math.min(Math.max(fadeS, 1e-6), holdS);
const fadeStartT = Math.max(0, holdS - fS);
if (gone < fadeStartT) return 1;
return Math.max(0, 1 - (gone - fadeStartT) / fS);
}
// Shorter, flatter notes (joel style)
const NW = 5 * K, NH = 3 * K, ND = 0.25 * K;
// Sustain-trail X offset for fretted notes. Module-scoped + frozen
// so the hot path's `offsets.length` loop sees a stable singleton
// reference. The standalone-open-string path builds a fresh pair
// each call because its offset magnitude depends on the per-note
// `openWScale` (set in drawNote at line 7367 from the open-string
// body's lane width), so a module-scoped constant can't capture
// it; the allocation is the same one the prior code did via
// `const baseOff = NW * 3 * openWScale` plus the inline `[-, +]`
// literal in the chord-member branch — just consolidated.
const SINGLE_SUS_OFFSETS = Object.freeze([0]);
const BEND_HALFSTEP_WORLD_Y = S_GAP * 0.8;
const VIBRATO_HALF_WAVE_S = 0.08;
// Bend ribbon envelope: fraction of the sustain spent ramping up to
// the bent pitch, and releasing back down (rest is the held plateau).
const BEND_ENV_RISE_FRAC = 0.35;
const BEND_ENV_RELEASE_FRAC = 0.30;
const TREMOLO_BUMP_S = 0.06;
/** Longitudinal samples for sustain-technique prism (indexed BufferGeometry). */
const SLIDE_RIBBON_SAMPLES = 96;
/** Pre-built index buffer: `SLIDE_RIBBON_SAMPLES` × 8 tris × 3 verts. */
const SLIDE_RIBBON_INDICES = (() => {
const S = SLIDE_RIBBON_SAMPLES;
const idx = new Uint16Array(S * 24);
let o = 0;
for (let k = 0; k < S; k++) {
const b = k * 4;
const nx = (k + 1) * 4;
// Bottom (-Y outward)
idx[o++] = b; idx[o++] = b + 1; idx[o++] = nx + 1;
idx[o++] = b; idx[o++] = nx + 1; idx[o++] = nx;
// Top (+Y outward)
idx[o++] = b + 3; idx[o++] = nx + 3; idx[o++] = nx + 2;
idx[o++] = b + 3; idx[o++] = nx + 2; idx[o++] = b + 2;
// Left (-X outward)
idx[o++] = b; idx[o++] = nx; idx[o++] = nx + 3;
idx[o++] = b; idx[o++] = nx + 3; idx[o++] = b + 3;
// Right (+X outward)
idx[o++] = b + 1; idx[o++] = b + 2; idx[o++] = nx + 2;
idx[o++] = b + 1; idx[o++] = nx + 2; idx[o++] = nx + 1;
}
return idx;
})();
// Three r170's setIndex() only wraps plain Arrays into Uint16BufferAttribute;
// typed-array input gets assigned raw onto .index, which trips WebGL's
// byteLength check. Convert once at module init so each pooled geometry
// reuses the same Array reference instead of allocating per mesh.
const SLIDE_RIBBON_INDICES_ARR = Array.from(SLIDE_RIBBON_INDICES);
const N_RAD = 1.5 * K;
const SW = 2 * K, SH = 1.5 * K;
const CAM_H_BASE = 190 * K;
const CAM_DIST_BASE = 240 * K;
const REF_ASPECT = 16 / 9;
const FOCUS_D = 600 * K;
const CAM_LERP_BASE = 0.02;
// Zoom-dependent framing — height (h*) and depth (dist*) multipliers
// applied to cam.position. Interpolated by `dist`:
// NEAR = tight view (nut position, span<=4 -> dist~=93*K): lower/closer.
// FAR = wide view (midpoint fret 1<->20 -> dist~=141*K): higher/pulled back
// to fit the whole neck.
// Outside this range the values clamp at the endpoints.
const CAM_FRAME_DIST_NEAR = 93 * K;
const CAM_FRAME_DIST_FAR = 141 * K;
const CAM_FRAME_H_NEAR = 0.75;
const CAM_FRAME_H_FAR = 1.00;
const CAM_FRAME_D_NEAR = 0.575;
const CAM_FRAME_D_FAR = 0.60;
// Camera-X targeting (issue #34). The visible AHEAD = 4.0 s window is
// far too coarse for picking where the camera should sit — a single
// 17th-fret bend 2.5 s away yanks tgtX several frets even though the
// immediate playing area hasn't moved. These constants are bounds for
// a smoothing dial (0 = twitchy, 1 = calm); the runtime lerps between
// the pair using the user's `cameraSmoothing` setting.
const CAM_TGT_BEHIND = 0.2; // s behind hit line for X targeting
const CAM_TGT_AHEAD_T = 2.0; // s — twitchy: longer lookahead (more reactive)
const CAM_TGT_AHEAD_C = 0.7; // s — calm: shorter lookahead (ignore distant outliers)
const CAM_TGT_TAU_T = 0.35; // s — twitchy: short recency time-constant
const CAM_TGT_TAU_C = 0.9; // s — calm: longer time-constant (averages more)
const CAM_TGT_HYST_T = 0.25; // frets — twitchy: tiny dead zone
const CAM_TGT_HYST_C = 5.0; // frets — calm: ~5-fret dead zone, wide
// enough to swallow chord-to-chord
// alternations across a 6-fret span
// (e.g. Am ↔ D in first position).
// Zoom (tgtDist) damping. Controlled by its own `zoomSmoothing` setting
// so X-pan and zoom-pull-back can be tuned independently. New users
// (and existing users who never wrote zoomSmoothing) inherit
// cameraSmoothing's value on first read, so default behaviour is
// unchanged from when zoom + X shared a single slider.
const CAM_DIST_HYST_T = 0.5; // fret-span — twitchy: minimal dead zone
const CAM_DIST_HYST_C = 5.0; // fret-span — calm: 5-fret span change required
// Vertical-tilt damping. Drives the tgtLookY self-correction loop in
// camUpdate(): how far the fretboard's NDC Y can drift from
// DESIRED_NDC_Y before we nudge the camera, and how strongly each
// nudge corrects. Twitchy = narrow band + strong correction (re-frame
// aggressively); calm = wide band + weak correction (let small drift
// ride). Driven by `tiltSmoothing`, mirrors cameraSmoothing on first
// read like zoomSmoothing does.
// Bounds chosen so the midpoint (tiltSmoothing=0.5) reproduces the
// pre-PR hardcoded behaviour (band=0.15, str=0.5). Without that, a
// fresh install would silently change the vertical-tilt feel even
// though the PR description promises "default behaviour unchanged."
const CAM_TILT_BAND_T = 0.05; // NDC — twitchy: narrow tolerance
const CAM_TILT_BAND_C = 0.25; // NDC — calm: wide tolerance, fewer corrections
const CAM_TILT_STR_T = 0.8; // multiplier — twitchy: strong nudge per correction
const CAM_TILT_STR_C = 0.2; // multiplier — calm: weak nudge per correction
// Lock-low zoom range. The cameraLockZoom slider (0..1) blends between
// these two multipliers and scales the locked tgtDist. Defaults pick
// 1.0× at slider=0.5 so the previous locked view is the midpoint.
const CAM_LOCK_ZOOM_MIN = 0.55; // slider=0 — closest, biggest fretboard
const CAM_LOCK_ZOOM_MAX = 1.45; // slider=1 — furthest
const CAM_LOCK_CENTER_FRET = 6; // default camera X center (first-position midpoint)
// ── 3D preview: lookahead fret bounds + smoothed focal X / span ─────────
/** User-selectable via `cameraMode`. Legacy `classic` in storage maps to `steady`. */
const CAMERA_MODE_IDS = ['steady', 'lookahead'];
const CAM_LOOKAHEAD_SEC = 3.0; // fallback when no beats/measures are available
const CAM_LOOKAHEAD_MEASURES = 9; // lookahead window = N measures ahead
const CAM_FOCUS_BLEND_RATE = 0.7;
const CAM_FRET_EDGE_BLEND = 0.1;
const DEFAULT_LOOKAHEAD_FRET_SPAN = 4;
/** Schmitt: avoid lock↔dynamic flicker when lookahead maxF jitters at the 12th fret. */
const LOOKAHEAD_LOCK_RELEASE_MAXF = 13;
const LOOKAHEAD_LOCK_ENGAGE_MAXF = 10;
// Note: we deliberately do NOT scale the camUpdate lerp speed with
// cameraSmoothing. Smoothing widens the hysteresis dead zones so the
// camera stays put through small/repetitive shifts; but when a shift
// *does* clear the gate (a real jump to a far fret), we want the slide
// to be snappy, not lethargic. The dead zone gates "should we move?",
// the BPM-scaled lerp answers "how fast" — keeping those orthogonal
// gives the right feel.
const FOG_START = 200 * K;
const FOG_END = 670 * K;
const DOTS = [3, 5, 7, 9, 12, 15, 17, 19, 21, 24];
const DDOTS = new Set([12, 24]);
const INLAY_LABEL_FRETS = [3, 5, 7, 9, 12, 15, 17, 19, 22, 24]; // 22 not 21: intentional display choice
// Fret-column reference markers: floor-aligned fret-number sprites
// that scroll toward the hit line every Nth measure. When the chart
// has <anchor>, the row uses the inlay cadence (DOTS) around the
// anchor fret: two marker positions before and three after the
// snapped cadence cell (e.g. anchor fret 7 → 3,5,7,9,12,15).
const FRET_COL_MARKER_ANCHOR_BACK = 2;
const FRET_COL_MARKER_ANCHOR_FWD = 3;
/**
* @param {number} anchorFret Chart anchor `.fret` (world start fret).
* @param {number[]} [cadence] Ascending frets (e.g. DOTS).
* @returns {number[]}
*/
function fretColumnMarkersForAnchor(anchorFret, cadence = DOTS) {
const f0 = Math.round(Number(anchorFret));
if (!Number.isFinite(f0) || cadence.length === 0) return cadence.slice();
let iBest = 0;
let dBest = Infinity;
for (let i = 0; i < cadence.length; i++) {
const d = Math.abs(cadence[i] - f0);
if (d < dBest || (d === dBest && cadence[i] < cadence[iBest])) {
dBest = d;
iBest = i;
}
}
const i0 = Math.max(0, iBest - FRET_COL_MARKER_ANCHOR_BACK);
const i1 = Math.min(cadence.length, iBest + FRET_COL_MARKER_ANCHOR_FWD + 1);
return cadence.slice(i0, i1);
}
// Fast integer key for (t, s) pairs — avoids per-frame string allocation in
// hot-path Set lookups. Encodes chart time in 0.1 ms steps (sufficient for
// chart-format note precision) combined with the string index.
// t range 0600 s → 06,000,000; * 10 + s(07) = max 60,000,007 < 2^53 ✓.
// The |0 truncates to int32 but the outer multiply stays in float64, so the
// key is always a safe JS integer for songs ≤ 214,748 s (well above any song).
function _noteKey(t, s) { return ((t * 10000 + 0.5) | 0) * 10 + s; }
// Binary lower-bound: returns the first index i in arr where arr[i].t >= t.
// Assumes arr is sorted ascending by .t (bundle.notes / bundle.chords always are).
function lowerBoundT(arr, t) {
let lo = 0, hi = arr.length;
while (lo < hi) {
const mid = (lo + hi) >>> 1;
if (arr[mid].t < t) lo = mid + 1;
else hi = mid;
}
return lo;
}
// Last arrangement <anchor> at or before chart time `t` (sorted by .time).
// Mirrors static/highway.js getAnchorAt — until t reaches the first anchors
// time, the first anchor still defines fret/width.
// Binary search: this is called inside per-frame loops (lane slicing,
// lookahead sampling, marker spawning), so the linear scan was O(samples *
// numAnchors) on dense charts.
function getChartAnchorAt(anchorArr, t) {
if (!anchorArr || !anchorArr.length) return null;
let lo = 0, hi = anchorArr.length;
while (lo < hi) {
const mid = (lo + hi) >>> 1;
if (anchorArr[mid].time <= t) lo = mid + 1;
else hi = mid;
}
return lo === 0 ? anchorArr[0] : anchorArr[lo - 1];
}
/** @returns {{ dMin: number, dMax: number } | null} */
function laneBoundsFromAnchor(anc) {
if (!anc) return null;
let fStart = Math.round(Number(anc.fret));
// Match anchorPlayedFretInclusiveSpan(): fret 0 (and below) clamps
// to 1, otherwise the lane span ends up one fret narrower than the
// played-fret span / label highlighting on charts that emit
// <anchor fret="0" width="N">.
if (!Number.isFinite(fStart) || fStart < 1) fStart = 1;
let w = Number(anc.width);
if (!Number.isFinite(w)) w = 4;
w = Math.max(1, Math.round(w));
const fLast = Math.min(NFRETS, fStart + w - 1);
const dMin = Math.max(0, fStart - 1);
const dMax = Math.min(NFRETS, fLast);
return { dMin, dMax };
}
/** Same horizontal span as the dynamic highway lane: anchor at chart time `t`. */
function anchorLaneBoundsAt(anchorArr, t) {
if (!anchorArr || !anchorArr.length) return null;
return laneBoundsFromAnchor(getChartAnchorAt(anchorArr, t));
}
/**
* Inclusive chart-fret indices for the playing window (anchor `fret` + `width`),
* e.g. fret=5 width=4 → 5..8. Unlike {@link laneBoundsFromAnchor}'s `dMin`/`dMax`
* (diagram wire span), these are the labels shown on gems / row numbers.
* @returns {{ f0: number, f1: number } | null}
*/
function anchorPlayedFretInclusiveSpan(anc) {
if (!anc) return null;
let f0 = Math.round(Number(anc.fret));
if (!Number.isFinite(f0) || f0 < 1) f0 = 1;
let w = Number(anc.width);
if (!Number.isFinite(w)) w = 4;
w = Math.max(1, Math.round(w));
const f1 = Math.min(NFRETS, f0 + w - 1);
return { f0, f1 };
}
function anchorPlayedFretSpanAt(anchorArr, t) {
if (!anchorArr || !anchorArr.length) return null;
return anchorPlayedFretInclusiveSpan(getChartAnchorAt(anchorArr, t));
}
const FRET_COOLDOWN = 0.5; // seconds a lane fret stays active after last note
const DIAG_LINGER_S = 0.55;
const DIAG_ENTRANCE_S = 0.20;
const DIAG_CROSSFADE_S = 0.15;
const DIAG_SIZE_MIN = 0.08;
const DIAG_SIZE_MAX = 0.16;
const DIAG_CELL_MAX = 34;
// 'bl' and 'br' removed — diagram is top-only. Legacy localStorage values
// that contain 'bl'/'br' will fall back to BG_DEFAULTS.chordDiagramPosition
// via _bgCoerce (which rejects values not in this list).
const CHORD_DIAG_POSITION_IDS = ['tl', 'tr'];
/** Default chord-box rim / fill gradient (teal family). */
const CHORD_BOX_TEAL_HEX = 0x00d2d5;
const CHORD_BOX_TEAL_DARK_HEX = 0x003c3d;
/** Frame edge quads: premultiplied-ish alpha match (~128/255). */
const CHORD_BOX_EDGE_ALPHA = 128 / 255;
/** Interior gradient strip alpha on both stops (~32/255). */
const CHORD_BOX_FILL_GRAD_ALPHA = 32 / 255;
/** Arpeggio interior wash; dedicated gradient tex so teal map doesnt dominate. */
const ARPEGGIO_BOX_BLUE_HEX = 0x454BB6;
const ARPEGGIO_BOX_BLUE_DARK_HEX = 0x2D3190;
/** Arpeggio rim accent and lane tint. */
const ARPEGGIO_RIM_BLUE_HEX = 0x454BB6;
/** Post-hit chord-frame rim tints driven by the note-state provider
* (slopsmith#254). Applied only to the teal frame during the linger
* fade (chDt <= 0) when a scorer is attached.
* Matches the gem hit/miss colours so chord frame and note body
* give a consistent signal:
* hit → neon spring-green 0x22ff88 (same as mHitBright).
* miss → hot magenta-red 0xff0066 (same as mMissOutline). */
const CHORD_BOX_HIT_BRIGHT_HEX = 0x22ff88;
const CHORD_BOX_MISS_DARK_HEX = 0xff0066;
/** Fret-number label tints — gold on approaching/active notes, muted blue when idle. */
const FRET_LABEL_GOLD_HEX = '#D8A636';
const FRET_LABEL_IDLE_HEX = '#9ab8cc';
/** 3D chord-box rim bars (thin on all chords, including repeats in a sequence). */
const CHORD_FRAME_RIM_MIN = 0.055; // × K — floor thickness
const CHORD_FRAME_RIM_FRAC_H = 0.028; // × fullChordBoxH
const CHORD_FRAME_RIM_Z_MIN = 0.048; // × K — depth squash
const CHORD_FRAME_RIM_Z_SCAL = 0.68; // thickZ scales with ft
/**
* Highway arpeggio frame uses ``inferArpeggioFromNotePattern`` only inside this
* window around ``ch.t``. Hand-shape spans can cover many seconds and several
* separate strums of the same voicing; a full-span scan mis-detects arpeggio
* from beats that belong to different chord rows.
*/
const ARP_FRAME_ONSET_PAD_S = 0.06;
const ARP_FRAME_ONSET_CLUSTER_S = 0.26;
/**
* The chart format encodes fast alternating power chords (e.g. D5/D#5 gallops) as
* very short ``<handShape>`` rows (~0.050.2 s). Note-stream arpeggio
* inference must not treat strum spread across strings as arpeggio there —
* it false-triggers lavender highway rails / frames (see Frantic ~2:36).
*/
const ARP_INFER_MIN_HAND_SHAPE_SPAN_S = 0.21;
/**
* In a **short** chart window, chord strums (same voicing, strings picked
* within ~3045 ms) barely exceed this total spread; real arpeggios in that
* window are usually slower across strings OR have 4+ plucks.
*/
const ARP_INFER_STRUM_VS_ARP_SPREAD_MIN_S = 0.047;
/**
* If more than ``shape.size + ARP_INFER_MULTI_STRUM_HIT_SLACKS`` matching picks
* sit inside a non-trivial hand-shape window, the chart is almost certainly
* **repeated strums** of the same chord (or gallops), not one arpeggio sweep.
*/
const ARP_INFER_MULTI_STRUM_HIT_SLACK = 2;
/** ``timeWin`` span above which we apply the multi-strum hit-count cap. */
const ARP_INFER_MULTI_STRUM_WIN_MIN_S = 0.26;
/**
* Minimum staggered hits inside a hand-shape window for note-stream arpeggio
* inference. A genuine arpeggio sweeps several strings of the held shape;
* a 2-note melodic motif inside a multi-string ``<handShape>`` (e.g. Jackson 5
* "I Want You Back" ~0:27 — Fm7 transition fingering with two plucks on
* strings 45) earlier registered as arpeggio and produced a stray lavender
* chord frame + purple lane outer dividers. Cap at ``min(shape.size, 3)``
* so 2-string voicings still infer normally and 3+ string templates need
* a real sweep.
*/
const ARP_INFER_MIN_HITS_VS_SHAPE_CAP = 3;
/* ======================================================================
* Pure helpers
* ====================================================================== */
// Logarithmic spacing — mirrors real guitar fret geometry (12th root of 2).
const _fretXLog = f => {
if (f <= 0) return 0;
const raw = FRET_SCALE - FRET_SCALE / Math.pow(2, f / 12);
if (f <= FRET_SPACING_ANCHOR_F) return raw;
const rawAnchor = FRET_SCALE - FRET_SCALE / Math.pow(2, FRET_SPACING_ANCHOR_F / 12);
return rawAnchor + (raw - rawAnchor) * FRET_SPACING_STRETCH_ABOVE12;
};
// Uniform spacing — same column width per fret (chart-format style).
// Total board width equals the logarithmic NFRETS position for consistency.
const _fretXUniStep = _fretXLog(NFRETS) / NFRETS;
const _fretXUni = f => f <= 0 ? 0 : f * _fretXUniStep;
let _h3dFretUniform = true;
try { _h3dFretUniform = localStorage.getItem('highway_3d.fretSpacing') !== 'logarithmic'; } catch (_) {}
const fretX = f => _h3dFretUniform ? _fretXUni(f) : _fretXLog(f);
window.h3dSetFretSpacing = mode => {
// Validate against the two supported modes before persisting so an
// unexpected input can't leave an invalid value in localStorage
// (mirrors h3dBgSetFretNumberGhostScope's allowlist guard). No-op
// when the stored mode is already what was requested.
const m = mode === 'logarithmic' ? 'logarithmic' : 'uniform';
try {
if (localStorage.getItem('highway_3d.fretSpacing') === m) return;
localStorage.setItem('highway_3d.fretSpacing', m);
} catch (_) {}
location.reload();
};
const fretMid = f => (f <= 0 ? -2 * K : (fretX(f - 1) + fretX(f)) / 2);
/** World-space width of fret column (wires f1 .. f); used to scale row markers past ~12. */
function fretColumnWorldW(f) {
const fi = Math.round(Number(f));
if (!Number.isFinite(fi) || fi <= 0) return Math.abs(fretX(1) - fretX(0));
const lo = Math.min(NFRETS, Math.max(1, fi));
return Math.abs(fretX(lo) - fretX(lo - 1));
}
/** Reference column (~mid board): prior fixed K-based sprites matched this neighborhood. */
const FRET_LABEL_SCALE_REF_FRET = 5;
const _fretLabelScaleRefW = Math.max(1e-8, fretColumnWorldW(FRET_LABEL_SCALE_REF_FRET));
function fretLabelScaleForFret(f) {
const w = fretColumnWorldW(f);
const m = w / _fretLabelScaleRefW;
return Math.max(0.32, Math.min(1.45, m));
}
const dZ = dt => -dt * TS;
/**
* Pitched slide uses `sl`, unpitched uses `slu` (slide-to vs unpitched slide fields).
* Prefer `sl` when both are present — matches RS wire.
* @returns {{ endFret: number, unpitched: boolean } | null}
*/
function slideTrailEnd(n) {
const sl = n.sl;
const slu = n.slu;
if (Number.isFinite(sl) && sl >= 0) {
return { endFret: sl | 0, unpitched: false };
}
if (Number.isFinite(slu) && slu >= 0) {
return { endFret: slu | 0, unpitched: true };
}
return null;
}
/**
* Lateral slide offset along the fretboard during sustain — easing
* mirrors the pitched/unpitched slide offset convention above.
* @param {{ endFret: number, unpitched: boolean } | null} [st_] from slideTrailEnd
*/
function slideOffsetWorldX(n, chartTime, st_) {
const st = st_ || slideTrailEnd(n);
if (!st || n.f <= 0 || !(n.sus > 0)) return 0;
const denom = Math.max(n.sus, 1e-6);
const p = Math.max(0, Math.min(1, (chartTime - n.t) / denom));
const startX = fretMid(n.f);
const endX = fretMid(st.endFret);
const w = st.unpitched
? 1 - Math.sin((1 - p) * Math.PI / 2)
: Math.pow(Math.sin(p * Math.PI / 2), 3);
return (endX - startX) * w;
}
// Camera tgtDist building blocks. Both the dynamic (camera-follow)
// and locked (frets 1-12) branches compose tgtDist from these, so
// any future tuning of the base zoom curve or low-fret pullback
// lands in both branches without drift.
// span — camDistMax - camDistMin in fret-span units
// minFret — lowest fretted note in the camera window (or 1 for
// the locked branch, which assumes nut chords)
const camBaseDistU = span => 65 + Math.max(span, 4) * 3;
const camLowFretPullbackU = minFret => Math.max(0, 5 - minFret) * 4;
// World-units-per-fret near mid-neck. Used by the camera-X hysteresis
// gate (issue #34) to convert a fret-equivalent dead zone into world
// units. Pure function of SCALE — hoist out of update()'s hot path.
const FRET_WIDTH_MID = fretX(7) - fretX(6);
function computeBPM(beats, t) {
if (!beats || beats.length < 2) return 120;
let lo = 0, hi = beats.length;
while (lo < hi) {
const mid = (lo + hi) >> 1;
if (beats[mid].time < t) lo = mid + 1; else hi = mid;
}
let closest = lo;
if (lo === beats.length) closest = beats.length - 1;
else if (lo > 0 && Math.abs(beats[lo - 1].time - t) < Math.abs(beats[lo].time - t)) closest = lo - 1;
const start = Math.max(0, closest - 2);
const end = Math.min(beats.length - 1, closest + 2);
let sum = 0, count = 0;
for (let i = start; i < end; i++) {
const dt = beats[i + 1].time - beats[i].time;
if (dt > 0) { sum += dt; count++; }
}
return count > 0 && sum > 0 ? 60 / (sum / count) : 120;
}
// Build a horizontal gaussian DataTexture for the sustain-rail bloom effect.
// Returns a W×1 RGBA texture where alpha follows exp(-0.5*(u0.5)²/σ²),
// peaking at 1.0 in the centre. With the default σ=0.28 the edges retain
// ~0.20 alpha (not fully transparent) — a deliberately soft, wide falloff
// so the additive bloom fades gradually rather than cutting off sharply.
// Power-of-two width keeps WebGL mipmapping happy.
function _makeGaussTex(ThreeLib, w = 128, sigma = 0.28) {
const data = new Uint8Array(w * 4);
for (let i = 0; i < w; i++) {
const u = i / (w - 1);
const d = (u - 0.5) / sigma;
const v = Math.exp(-0.5 * d * d);
const a = Math.round(v * 255);
data[i * 4] = 255;
data[i * 4 + 1] = 255;
data[i * 4 + 2] = 255;
data[i * 4 + 3] = a;
}
const tex = new ThreeLib.DataTexture(data, w, 1, ThreeLib.RGBAFormat);
// LinearFilter on both axes so the bloom plane interpolates smoothly
// when scaled — the default NearestFilter causes visible banding.
tex.magFilter = ThreeLib.LinearFilter;
tex.minFilter = ThreeLib.LinearFilter;
tex.needsUpdate = true;
return tex;
}
/* ======================================================================
* Three.js module — lazily loaded, memoized
* ====================================================================== */
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('[3D-Hwy] Three.js load failed:', e);
threeLoadPromise = null;
throw e;
}));
}
return threeLoadPromise;
}
/* ======================================================================
* Splitscreen helpers
* ====================================================================== */
function _ssActive() {
const ss = window.slopsmithSplitscreen;
if (!ss || typeof ss.isActive !== 'function' || !ss.isActive()) return false;
return typeof ss.isCanvasFocused === 'function'
&& typeof ss.onFocusChange === 'function'
&& typeof ss.offFocusChange === 'function';
}
function _ssIsCanvasFocused(highwayCanvas) {
const ss = window.slopsmithSplitscreen;
if (!_ssActive()) return true;
return !!(ss && typeof ss.isCanvasFocused === 'function' &&
ss.isCanvasFocused(highwayCanvas));
}
/* ======================================================================
* Background animations (issue #13)
*
* Audio-reactive ambient scenery in the fog band beyond the highway.
* Module-level singletons share an AudioContext + AnalyserNode tap on
* the slopsmith core <audio id="audio"> element across all panel
* instances; per-panel settings live in localStorage with a global
* fallback so settings.html drives a single default while per-panel
* overrides (h3d_bg_panel<idx>_*) can be set for splitscreen layouts.
*
* Caveat: createMediaElementSource() can only be called once per
* element. 3dhighway owns that source for now; future plugins
* needing an analyser will have to share through a core API.
* ====================================================================== */
// Returned from _bgReadBands when reactive=false or analyser
// unavailable; shared so the per-frame non-reactive path doesn't
// allocate. Declared up-front because _bgBandsCache initializes to
// it during the same IIFE execution pass.
const BG_ZERO_BANDS = Object.freeze({ bass: 0, mid: 0, treble: 0 });
// Module-level AudioContext singleton. Intentionally never torn
// down: createMediaElementSource(<audio>) is irrevocable — once
// called, the element's audio is permanently routed through this
// context for the page's lifetime. Closing the context would
// silence playback. The leak (one AudioContext + one AnalyserNode,
// a few KB) is the cost of having a plugin tap audio at all.
let _bgAudio = null;
// The core (#audio-tap) cache is held separately from the stems cache so
// we can switch back to it without re-calling createMediaElementSource on
// #audio — that call is one-shot per element, and a second one throws
// InvalidStateError (which would then be marked permanent and disable
// reactivity forever on legacy songs after any sloppak detour).
let _bgAudioCore = null;
let _bgAudioFailedAt = 0; // performance.now() of last failure, 0 = never
const _BG_AUDIO_RETRY_MS = 1000;
// _bgReadBands sums bins 0..7 (bass), 8..39 (mid), 40..127 (treble),
// so the frequency buffer must hold at least 128 bins regardless of
// the source analyser's fftSize.
const BG_FREQ_BINS = 128;
const _bgBridgeKeys = new Map();
function _bgRecordAudioBridge(bridgeId, legacySurface, outcome = 'handled', reason = '', status = 'used') {
const key = `${outcome}:${status}:${reason}`;
if (_bgBridgeKeys.get(bridgeId) === key) return;
_bgBridgeKeys.set(bridgeId, key);
const session = window.slopsmith && window.slopsmith.audioSession;
if (!session || typeof session.recordBridgeHit !== 'function') return;
try {
session.recordBridgeHit({
domain: 'audio-mix',
bridgeId,
legacySurface,
participantId: 'highway_3d',
outcome,
status,
reason,
});
} catch (_) { /* diagnostics are best-effort */ }
}
function _bgGetAnalyser() {
// Prefer the stems plugin's side-chain analyser when a sloppak is
// loaded. As of slopsmith-plugin-stems 0.5.0 (sample-locked playback)
// the #audio element is a silent virtual transport on sloppaks, so
// tapping it sees only silence; the stems mix is exposed at
// window.slopsmith.stems.getAnalyser() instead. The stems plugin
// creates and destroys that AnalyserNode per song, so we re-check
// each call and key the cache on its identity — when the node
// changes (song switch), the cache is replaced automatically.
const stemsApi = window.slopsmith && window.slopsmith.stems;
const stemsAnalyser = (stemsApi && typeof stemsApi.getAnalyser === 'function')
? stemsApi.getAnalyser() : null;
if (stemsAnalyser) {
if (!_bgAudio || _bgAudio.source !== 'stems' || _bgAudio.analyser !== stemsAnalyser) {
// Adopt the live stems analyser. Do NOT close its context — it's
// shared with stem playback and the stems plugin owns its
// lifecycle. No play-event resume hooks either; the stems
// plugin manages context resume itself.
_bgAudio = {
ctx: stemsAnalyser.context,
analyser: stemsAnalyser,
// _bgReadBands reads bins 0..127 unconditionally. Always
// allocate at least 128 bytes so a smaller analyser (e.g.
// fftSize < 256) can't leave undefined values in the loop.
freq: new Uint8Array(Math.max(BG_FREQ_BINS, stemsAnalyser.frequencyBinCount)),
source: 'stems',
};
_bgRecordAudioBridge('audio-mix.analyser', 'window.slopsmith.stems.getAnalyser', 'handled', '', 'stems');
}
return _bgAudio;
}
// No sloppak active — drop a stale stems-sourced cache, restoring the
// core-tap cache if we'd already built one. Without this, the next
// step would try to createMediaElementSource(#audio) a second time
// (one-shot per element) and throw InvalidStateError — disabling
// reactivity for the rest of the page lifetime.
if (_bgAudio && _bgAudio.source === 'stems') _bgAudio = _bgAudioCore;
if (_bgAudio && !_bgAudio.failed) return _bgAudio;
if (_bgAudio && _bgAudio.failed) {
// Distinguish permanent failures from transient ones.
// InvalidStateError on createMediaElementSource means the
// <audio> element is already tapped by another consumer —
// there's no recovering from that without a page reload, so
// don't retry. Transient failures (NotAllowedError before
// first user gesture, etc.) get a once-per-second retry so
// reactivity recovers once the blocking condition clears.
if (_bgAudio.permanent) return null;
if (performance.now() - _bgAudioFailedAt < _BG_AUDIO_RETRY_MS) return null;
}
const audio = document.getElementById('audio');
if (!audio) return null;
// Hoist ctx out of the try so we can close() it if a later step
// throws (e.g. createMediaElementSource on an element that
// already has a source node). Otherwise the AudioContext leaks.
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' };
_bgRecordAudioBridge('audio-mix.analyser', 'HTMLAudioElement analyser tap', 'handled', '', 'core');
// Remember the core analyser so a later stems-then-back-to-core
// transition can re-use it instead of re-tapping #audio (which
// would throw InvalidStateError on the one-shot per element).
_bgAudioCore = _bgAudio;
// Browsers with autoplay restrictions hand back a suspended
// AudioContext; createMediaElementSource then routes the
// <audio> through that suspended graph and playback goes
// silent (and the analyser reads zeros) until we resume.
// Try once now (fine if the page already had a user gesture)
// and again on every play event so the first successful
// user-initiated play unblocks the graph.
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 (_) { /* close errors during failure path are noise */ }
}
console.warn('[3D-Hwy] failed to set up audio analyser:', e);
const permanent = !!(e && e.name === 'InvalidStateError');
_bgRecordAudioBridge('audio-mix.analyser', 'HTMLAudioElement analyser tap', 'failed', e && e.message ? e.message : String(e), permanent ? 'permanent-failure' : 'transient-failure');
_bgAudio = { failed: true, permanent };
_bgAudioFailedAt = performance.now();
return null;
}
}
// Bands cache: in splitscreen, every panel asks for bands per frame.
// The analyser is shared, so the answer is identical — cache for a
// few ms so 4-up splitscreen pays one getByteFrequencyData + one sum
// pass per frame instead of four.
const _BG_BANDS_CACHE_MS = 5;
let _bgBandsLastT = -Infinity;
// Mutable cache reused across reads — refreshing in place keeps the
// per-frame allocation count at zero. Style.update() uses the bands
// synchronously within the same frame so the live-mutation contract
// is safe.
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;
}
const BG_DEFAULTS = { style: 'particles', intensity: 0.5, reactive: true, palette: 'default', showFretOnNote: true, fretNumberGhostScope: 'chords', cameraSmoothing: 0.5, zoomSmoothing: 0.5, tiltSmoothing: 0.5, cameraLockLow: false, cameraLockZoom: 0.5, cameraMode: 'lookahead', nutHeadstockVisible: true, tuningLabelsVisible: true, nutColor: '#f5f3f0', headstockColor: '#d4b48a', textSize: 0.5, vibrancy: 0.85, glow: 0.25, customImageDataUrl: '', customImageName: '', customVideoName: '', chordDiagramVisible: true, chordDiagramSize: 0.5, chordDiagramPosition: 'tl', fretColumnMarkerCadence: 1, projectionVisible: true, inlayLabelsVisible: false, sectionLabelsOnHighway: false, sectionHudVisible: false, sectionHudPosition: 'tr', sectionHudSize: 0.5, toneHudVisible: false, toneHudPosition: 'tl', toneHudSize: 0.5, fpsVisible: false, fretDividersVisible: true, slideArrowApproachVisible: true, slideArrowNeckVisible: true, slideArrowChainPreviewVisible: true };
// User-selectable, persistable bg styles — must mirror settings.html's
// VALID_STYLES. 'venue' is deliberately NOT here: it is an internal effective
// style reached only via _venueSceneOverride (the viz-picker Venue flow), so
// _bgCoerce must reject a stored h3d_bg_style='venue' — otherwise venue could
// mount outside that flow and settings.html (which can't represent 'venue')
// would be unable to switch back. BG_STYLES still has a 'venue' renderer entry.
const BG_STYLE_IDS = ['off', 'particles', 'silhouettes', 'lights', 'geometric', 'image', 'video'];
const VENUE_SCENE_ASSET_BASE = '/static/assets/venue/themes/small-club/';
const VENUE_BG_PLATE_PNG = 'bg-plate.png';
const VENUE_BG_PLATE_WEBP = 'bg-plate.webp';
const VENUE_INSTRUMENT_PLATES = {
guitar: { webp: 'guitar-pov-bg.webp', png: 'guitar-pov-bg.png' },
bass: { webp: 'bass-pov-bg.webp', png: 'bass-pov-bg.png' },
drums: { webp: 'drums-pov-bg.webp', png: 'drums-pov-bg.png' },
piano: { webp: 'piano-pov-bg.webp', png: 'piano-pov-bg.png' },
vocals: { webp: 'vocals-pov-bg.webp', png: 'vocals-pov-bg.png' },
};
let _venueSceneOverride = false;
let _venueMoodState = 'idle';
let _venueInstrumentPov = 'guitar';
let _venueMotionMode = 'subtle';
let _venuePlateUrl = '';
let _venueSceneAssetsLoaded = false;
let _venueSceneLoadFailed = false;
const _venueTextureCache = new Map();
function _bgVenueMoodCoeffs(state) {
const s = String(state || 'idle').toLowerCase();
if (s === 'fire' || s === 'strong') {
return { light: 1.0, crowd: 0, haze: 0.012, warmth: 1.02 };
}
if (s === 'recovery' || s === 'smoke') {
return { light: 0.55, crowd: 0, haze: 0.032, warmth: 0.94 };
}
return { light: 0.72, crowd: 0, haze: VENUE_HAZE_STEADY, warmth: 0.96 };
}
function _venueResolvePovFromInput(input) {
if (typeof window !== 'undefined' && window.v3VenueInstrumentPov &&
typeof window.v3VenueInstrumentPov.resolveVenueInstrumentPov === 'function') {
return window.v3VenueInstrumentPov.resolveVenueInstrumentPov(input);
}
const s = String(input == null ? '' : input).trim().toLowerCase();
if (!s) return 'guitar';
if (/\b(drums?)\b/.test(s)) return 'drums';
if (/\b(bass)\b/.test(s)) return 'bass';
if (/\b(piano|keys|keyboard)\b/.test(s)) return 'piano';
if (/\b(karaoke|vocal|vocals|lyric|lyrics|sing|singing)\b/.test(s)) return 'vocals';
if (/\b(lead|rhythm|guitar|combo)\b/.test(s)) return 'guitar';
return 'guitar';
}
function _venueMotionProfile(mode) {
if (typeof window !== 'undefined' && window.v3VenueMoodFx &&
typeof window.v3VenueMoodFx.venueMotionProfile === 'function') {
return window.v3VenueMoodFx.venueMotionProfile(mode);
}
const m = String(mode || 'subtle').toLowerCase();
if (m === 'off') {
return { breathe: 0, parallax: 0, hazeDrift: 0, warmthPulse: 0, shimmer: 0 };
}
if (m === 'full') {
return { breathe: 0.014, parallax: 0.010, hazeDrift: 0.020, warmthPulse: 0.028, shimmer: 0.10 };
}
return { breathe: 0.005, parallax: 0.004, hazeDrift: 0.007, warmthPulse: 0.010, shimmer: 0.04 };
}
function _venuePrefersReducedMotion() {
if (typeof window !== 'undefined' && window.v3VenueMoodFx &&
typeof window.v3VenueMoodFx.prefersReducedMotion === 'function') {
return window.v3VenueMoodFx.prefersReducedMotion();
}
return false;
}
function _venueEffectiveMotionMode() {
if (!_venueSceneOverride) return 'off';
if (_venuePrefersReducedMotion()) return 'off';
return _venueMotionMode;
}
function _venueApplyFakeDepthMotion(s, coeffs, t) {
const motion = _venueMotionProfile(_venueEffectiveMotionMode());
if (!motion.breathe && !motion.parallax && !motion.hazeDrift && !motion.warmthPulse) {
if (s.haze && s.haze.mesh) {
s.haze.mesh.position.set(s.haze.baseX, s.haze.baseY, s.haze.baseZ);
}
return motion;
}
const breath = Math.sin(t * 0.38);
const parallax = Math.sin(t * 0.21);
const shimmer = Math.sin(t * 0.55);
if (s.backdrop && s.backdrop.loaded && s.backdrop.mesh) {
const mesh = s.backdrop.mesh;
const vh = s.backdrop.lastVisibleHeight || 1;
const vw = s.backdrop.lastVisibleWidth || vh;
const offX = parallax * motion.parallax * vh;
const offY = breath * motion.breathe * vh * 0.35;
mesh.position.x += offX;
mesh.position.y += offY;
const scaleMul = 1 + breath * motion.breathe * 2.5;
mesh.scale.set(vw * scaleMul, vh * scaleMul, 1);
if (s.backdrop.mat) {
const warm = coeffs.warmth;
const warmPulse = 1 + shimmer * motion.warmthPulse;
s.backdrop.mat.color.setRGB(
warm * warmPulse,
warm * 0.98 * warmPulse,
warm * 0.95 * (1 + shimmer * motion.warmthPulse * 0.6),
);
}
} else if (s.backdrop && s.backdrop.mat) {
const warm = coeffs.warmth;
s.backdrop.mat.color.setRGB(warm, warm * 0.98, warm * 0.95);
}
if (s.haze && s.haze.mesh) {
const driftX = Math.sin(t * 0.18) * motion.hazeDrift * 8 * K;
const driftY = Math.cos(t * 0.14) * motion.hazeDrift * 4 * K;
s.haze.mesh.position.set(
s.haze.baseX + driftX,
s.haze.baseY + driftY,
s.haze.baseZ,
);
if (s.haze.mat) {
const baseOp = (s.haze.baseOp || VENUE_HAZE_STEADY) * (coeffs.haze / VENUE_HAZE_STEADY);
s.haze.mat.opacity = baseOp * (1 + shimmer * motion.shimmer * 0.12);
}
}
return motion;
}
function _venuePlateUrlChain(pov) {
const plate = VENUE_INSTRUMENT_PLATES[pov] || VENUE_INSTRUMENT_PLATES.guitar;
const base = VENUE_SCENE_ASSET_BASE;
return [
base + plate.webp,
base + plate.png,
base + VENUE_BG_PLATE_WEBP,
base + VENUE_BG_PLATE_PNG,
];
}
function _venueLoadCachedTexture(loader, url, onSuccess, onFail) {
const cached = _venueTextureCache.get(url);
if (cached) {
onSuccess(cached, url);
return;
}
loader.load(
url,
(tex) => {
_venueTextureCache.set(url, tex);
onSuccess(tex, url);
},
undefined,
onFail,
);
}
function _venueApplyPlateTexture(backdrop, tex, url) {
backdrop.tex = tex;
backdrop.plateUrl = url;
_venuePlateUrl = url;
backdrop.mat.map = tex;
backdrop.mat.needsUpdate = true;
if (backdrop.applyCoverCrop) backdrop.applyCoverCrop();
backdrop.loaded = true;
backdrop.mesh.visible = true;
}
function _venueLoadPlateForPov(loader, pov, backdrop, onSuccess, onFail) {
const chain = _venuePlateUrlChain(pov);
let idx = 0;
function tryNext() {
if (idx >= chain.length) {
onFail();
return;
}
const url = chain[idx++];
_venueLoadCachedTexture(loader, url, (tex, loadedUrl) => {
_venueApplyPlateTexture(backdrop, tex, loadedUrl);
onSuccess(tex, loadedUrl);
}, tryNext);
}
tryNext();
}
function _venueSwapPlateIfNeeded(s) {
if (!s || s.failed || s.plateLoading || !s.loader || !s.backdrop) return;
const pov = _venueInstrumentPov;
if (s.instrumentPov === pov && s.backdrop.loaded) return;
s.plateLoading = true;
_venueLoadPlateForPov(
s.loader,
pov,
s.backdrop,
() => {
s.instrumentPov = pov;
s.plateLoading = false;
s.loaded = true;
_venueSceneAssetsLoaded = true;
_venueSceneLoadFailed = false;
// The POV may have changed while this load was in flight (the
// plateLoading latch made concurrent swaps no-op). Re-sync to the
// current target so the backdrop isn't stranded on a stale plate.
if (_venueInstrumentPov !== pov) _venueSwapPlateIfNeeded(s);
},
() => {
s.plateLoading = false;
if (s.backdrop.loaded) return;
s.failed = true;
_venueSceneLoadFailed = true;
_venueSceneAssetsLoaded = false;
console.warn('[venue-scene] failed to load venue bg plate for pov ' + pov);
_venueSceneOverride = false;
_bgEmitChange('venueScene');
try {
if (typeof window !== 'undefined' && window.v3VenueScene3d &&
typeof window.v3VenueScene3d.onAssetsFailed === 'function') {
window.v3VenueScene3d.onAssetsFailed('failed to load venue bg plate');
}
} catch (_) { /* visual-only */ }
},
);
}
const FRET_NUMBER_GHOST_SCOPE_IDS = ['chords', 'all'];
function _bgPanelKey(canvas) {
const ss = window.slopsmithSplitscreen;
const idx = (ss && typeof ss.panelIndexFor === 'function') ? ss.panelIndexFor(canvas) : null;
return (idx == null) ? 'main' : 'panel' + idx;
}
// In-memory fallback for when localStorage is blocked (private mode,
// sandboxed iframes, some test runners). _bgWriteGlobal stages the
// value here unconditionally, so it always reflects the most recent
// in-session intent — _bgReadSetting prefers it over the global
// localStorage slot to avoid serving a stale persisted value when
// a write failed silently (quota exceeded, etc.). Per-panel
// localStorage overrides still win because they're an explicit
// per-instance opt-out and shouldn't be shadowed by a global edit.
const _bgMemFallback = Object.create(null);
function _bgReadSetting(panelKey, key) {
let panelVal = null;
let globalVal = null;
try {
// 'palette' + 'customColors' are GLOBAL-only: the per-panel palette
// control was removed in favour of the global "Highway String Colors"
// UI, so a panel must never be shadowed by a stale per-panel override
// (h3d_bg_panel<idx>_palette / _customColors). Neither is a
// BG_DEFAULTS key, so per-panel scoping never applied to them.
if (key !== 'palette' && key !== 'customColors') {
panelVal = localStorage.getItem('h3d_bg_' + panelKey + '_' + key);
}
globalVal = localStorage.getItem('h3d_bg_' + key);
} catch (_) { /* storage blocked — both stay null */ }
if (panelVal !== null && panelVal !== undefined) return _bgCoerce(key, panelVal);
// Prefer the in-memory staged value over the persisted global slot.
// _bgWriteGlobal always writes to _bgMemFallback first, so the
// memory value is at least as fresh as the persisted one.
if (key in _bgMemFallback) return _bgCoerce(key, _bgMemFallback[key]);
if (globalVal !== null && globalVal !== undefined) return _bgCoerce(key, globalVal);
return BG_DEFAULTS[key];
}
// Shared "stored string -> bool" coercion for every boolean
// setting. Mirrors settings.html's coerceBool so the renderer and
// the UI hydration always agree on what a corrupted/unknown value
// means (fall back to default rather than silently flipping to
// false). Add new boolean keys to BG_DEFAULTS and they pick this
// up via the dispatch below.
const _BG_BOOL_KEYS = new Set(['reactive', 'showFretOnNote', 'cameraLockLow', 'inlayLabelsVisible', 'sectionLabelsOnHighway', 'sectionHudVisible', 'nutHeadstockVisible', 'tuningLabelsVisible', 'projectionVisible', 'chordDiagramVisible', 'fpsVisible', 'toneHudVisible', 'fretDividersVisible', 'slideArrowApproachVisible', 'slideArrowNeckVisible', 'slideArrowChainPreviewVisible']);
function _bgCoerceBool(val, fallback) {
if (val === 'true' || val === '1') return true;
if (val === 'false' || val === '0') return false;
return fallback;
}
// Settings stored as 0..1 floats. cameraSmoothing controls X-pan
// hysteresis; zoomSmoothing the zoom dead zone; tiltSmoothing the
// vertical-tilt deadband + correction strength. All three slider-
// shaped settings share the same parse + clamp behaviour.
const _BG_FLOAT_KEYS = new Set(['intensity', 'cameraSmoothing', 'zoomSmoothing', 'tiltSmoothing', 'cameraLockZoom', 'textSize', 'vibrancy', 'glow', 'chordDiagramSize', 'sectionHudSize', 'toneHudSize']);
function _bgCoerce(key, val) {
if (_BG_FLOAT_KEYS.has(key)) {
const n = parseFloat(val);
return Number.isFinite(n) ? Math.max(0, Math.min(1, n)) : BG_DEFAULTS[key];
}
if (_BG_BOOL_KEYS.has(key)) return _bgCoerceBool(val, BG_DEFAULTS[key]);
if (key === 'style') return BG_STYLE_IDS.includes(val) ? val : BG_DEFAULTS.style;
if (key === 'palette') return (PALETTE_IDS.includes(val) || val === 'custom') ? val : BG_DEFAULTS.palette;
if (key === 'chordDiagramPosition')
return CHORD_DIAG_POSITION_IDS.includes(val) ? val : BG_DEFAULTS.chordDiagramPosition;
if (key === 'sectionHudPosition')
return ['tl', 'tr', 'bl', 'br'].includes(val) ? val : BG_DEFAULTS.sectionHudPosition;
if (key === 'toneHudPosition')
return ['tl', 'tr', 'bl', 'br'].includes(val) ? val : BG_DEFAULTS.toneHudPosition;
if (key === 'cameraMode') {
if (val === 'classic') val = 'steady';
return CAMERA_MODE_IDS.includes(val) ? val : BG_DEFAULTS.cameraMode;
}
if (key === 'fretNumberGhostScope')
return FRET_NUMBER_GHOST_SCOPE_IDS.includes(val) ? val : BG_DEFAULTS.fretNumberGhostScope;
if (key === 'nutColor' || key === 'headstockColor') {
if (typeof val !== 'string') return BG_DEFAULTS[key];
const t = val.trim();
if (/^#[0-9a-fA-F]{6}$/.test(t)) return t.toLowerCase();
return BG_DEFAULTS[key];
}
if (key === 'fretColumnMarkerCadence') {
const n = parseInt(val, 10);
if (!Number.isFinite(n)) return BG_DEFAULTS.fretColumnMarkerCadence;
return Math.max(0, Math.min(16, n));
}
return val;
}
// Mirror-at-first-read fallback: returns true if the user has ever
// explicitly written `key` (per-panel, in-memory, or global). When
// false, callers should treat the value as "unset" — useful for
// zoomSmoothing / tiltSmoothing which inherit cameraSmoothing's
// value the first time they're read so existing users who calmed
// the camera don't lose calmness on the new axes by default.
function _bgHasStored(panelKey, key) {
try {
if (localStorage.getItem('h3d_bg_' + panelKey + '_' + key) != null) return true;
} catch (_) {}
if (key in _bgMemFallback) return true;
try {
if (localStorage.getItem('h3d_bg_' + key) != null) return true;
} catch (_) {}
return false;
}
function _bgWriteGlobal(key, val) {
const s = String(val);
// Stage in memory FIRST so _bgReadSetting's "memory beats global
// localStorage" precedence has a true freshness guarantee even
// if localStorage.setItem throws partway through. Without this
// ordering, a quota exception thrown after the persisted slot
// was already mutated would leave a stale value in localStorage
// that's newer than _bgMemFallback.
_bgMemFallback[key] = s;
try { localStorage.setItem('h3d_bg_' + key, s); } catch (_) { /* storage blocked */ }
_bgEmitChange(key);
}
// Pub-sub so settings.html can update live across all panel instances.
const _bgListeners = new Set();
function _bgSubscribe(fn) { _bgListeners.add(fn); }
function _bgUnsubscribe(fn) { _bgListeners.delete(fn); }
function _bgEmitChange(key) {
for (const fn of _bgListeners) {
try { fn(key); } catch (e) { console.error('[3D-Hwy] bg listener threw', e); }
}
}
// Settings.html setters — global keys; per-panel overrides via direct
// localStorage edits today, runtime UI in a follow-up.
window.h3dBgSetStyle = (v) => _bgWriteGlobal('style', v);
window.h3dBgSetIntensity = (v) => _bgWriteGlobal('intensity', v);
window.h3dBgSetReactive = (v) => _bgWriteGlobal('reactive', !!v);
window.h3dBgSetPalette = (v) => _bgWriteGlobal('palette', v);
// Apply a user-defined per-string color set (core theming UI). `hexArray`
// is up to 8 hex strings; invalid/missing entries fall back to the default
// palette per index. Writes the colors, then flips the palette to 'custom'
// — the palette listener retints all materials + rebuilds the board live.
// Pass null/[] then h3dBgSetPalette('default') to revert.
window.h3dBgSetStringColors = (hexArray) => {
const arr = Array.isArray(hexArray) ? hexArray : [];
const norm = [];
for (let i = 0; i < MAX_RENDER_STRINGS; i++) {
const n = _h3dHexToInt(arr[i]);
norm[i] = (n != null) ? '#' + n.toString(16).padStart(6, '0') : null;
}
_bgWriteGlobal('customColors', JSON.stringify(norm));
_bgWriteGlobal('palette', 'custom');
};
window.h3dBgSetShowFretOnNote = (v) => _bgWriteGlobal('showFretOnNote', !!v);
window.h3dBgSetFretNumberGhostScope = (v) => {
const s = String(v);
_bgWriteGlobal('fretNumberGhostScope', FRET_NUMBER_GHOST_SCOPE_IDS.includes(s) ? s : BG_DEFAULTS.fretNumberGhostScope);
};
window.h3dBgSetCameraSmoothing = (v) => _bgWriteGlobal('cameraSmoothing', v);
window.h3dBgSetZoomSmoothing = (v) => _bgWriteGlobal('zoomSmoothing', v);
window.h3dBgSetTiltSmoothing = (v) => _bgWriteGlobal('tiltSmoothing', v);
window.h3dBgSetCameraLockLow = (v) => _bgWriteGlobal('cameraLockLow', !!v);
window.h3dBgSetCameraLockZoom = (v) => _bgWriteGlobal('cameraLockZoom', v);
window.h3dBgSetCameraMode = (v) => {
let s = String(v);
if (s === 'classic') s = 'steady';
_bgWriteGlobal('cameraMode', s);
};
window.h3dBgSetNutHeadstockVisible = (v) => _bgWriteGlobal('nutHeadstockVisible', !!v);
window.h3dBgSetTuningLabelsVisible = (v) => _bgWriteGlobal('tuningLabelsVisible', !!v);
window.h3dBgSetNutColor = (v) => _bgWriteGlobal('nutColor', v);
window.h3dBgSetHeadstockColor = (v) => _bgWriteGlobal('headstockColor', v);
window.h3dBgSetTextSize = (v) => _bgWriteGlobal('textSize', v);
window.h3dBgSetVibrancy = (v) => _bgWriteGlobal('vibrancy', v);
window.h3dBgSetGlow = (v) => _bgWriteGlobal('glow', v);
window.h3dBgSetToneHudVisible = (v) => _bgWriteGlobal('toneHudVisible', !!v);
window.h3dBgSetToneHudPosition = (v) => _bgWriteGlobal('toneHudPosition', v);
window.h3dBgSetToneHudSize = (v) => _bgWriteGlobal('toneHudSize', v);
window.h3dBgSetFpsVisible = (v) => _bgWriteGlobal('fpsVisible', !!v);
window.h3dBgSetFretDividersVisible = (v) => _bgWriteGlobal('fretDividersVisible', !!v);
window.h3dBgSetChordDiagramVisible = (v) => _bgWriteGlobal('chordDiagramVisible', !!v);
window.h3dBgSetChordDiagramSize = (v) => _bgWriteGlobal('chordDiagramSize', v);
window.h3dBgSetChordDiagramPosition = (v) => _bgWriteGlobal('chordDiagramPosition', v);
window.h3dBgSetFretColumnMarkerCadence = (v) => _bgWriteGlobal('fretColumnMarkerCadence', v);
window.h3dBgSetInlayLabelsVisible = (v) => _bgWriteGlobal('inlayLabelsVisible', !!v);
window.h3dBgSetSectionLabelsOnHighway = (v) => _bgWriteGlobal('sectionLabelsOnHighway', !!v);
window.h3dBgSetSectionHudVisible = (v) => _bgWriteGlobal('sectionHudVisible', !!v);
window.h3dBgSetSectionHudPosition = (v) => _bgWriteGlobal('sectionHudPosition', v);
window.h3dBgSetSectionHudSize = (v) => _bgWriteGlobal('sectionHudSize', v);
window.h3dBgSetProjectionVisible = (v) => _bgWriteGlobal('projectionVisible', !!v);
window.h3dBgSetSlideArrowApproachVisible = (v) => _bgWriteGlobal('slideArrowApproachVisible', !!v);
window.h3dBgSetSlideArrowNeckVisible = (v) => _bgWriteGlobal('slideArrowNeckVisible', !!v);
window.h3dBgSetSlideArrowChainPreviewVisible = (v) => _bgWriteGlobal('slideArrowChainPreviewVisible', !!v);
// Custom image asset for the 'image' bg style (#19). Composite setter:
// writes both the data URL (the bytes that drive the texture) and the
// display filename, each emitting a change event. The listener
// rebuilds on customImageDataUrl change when the image style is
// active; customImageName is display-only and skips rebuild.
window.h3dBgSetCustomImage = (asset) => {
const a = asset || {};
_bgWriteGlobal('customImageDataUrl', a.dataUrl || '');
_bgWriteGlobal('customImageName', a.name || '');
};
window.h3dBgClearCustomImage = () => {
_bgWriteGlobal('customImageDataUrl', '');
_bgWriteGlobal('customImageName', '');
};
// Custom video asset for the 'video' bg style (#19 follow-up).
// Bytes live on disk under {config_dir}/plugin_uploads/highway_3d/
// and are served by routes.py — localStorage only stores the
// filename, which the renderer maps to the served URL. Single
// global slot; the file picker in settings.html POSTs to the
// upload route and then calls this setter with the response name.
window.h3dBgSetCustomVideo = (asset) => {
_bgWriteGlobal('customVideoName', (asset && asset.name) || '');
};
window.h3dBgClearCustomVideo = () => _bgWriteGlobal('customVideoName', '');
window.h3dVenueSceneSetActive = (on) => {
const next = !!on;
if (_venueSceneOverride === next) return;
_venueSceneOverride = next;
if (!next) {
_venueSceneAssetsLoaded = false;
_venueSceneLoadFailed = false;
}
_bgEmitChange('venueScene');
};
window.h3dVenueSceneSetMood = (state) => {
_venueMoodState = String(state || 'idle').toLowerCase();
};
window.h3dVenueSceneSetInstrumentPov = (input) => {
const next = _venueResolvePovFromInput(input);
if (_venueInstrumentPov === next) return;
_venueInstrumentPov = next;
_bgEmitChange('venueInstrumentPov');
};
window.h3dVenueSceneSetMotionMode = (mode) => {
const next = String(mode || 'subtle').toLowerCase();
const allowed = { off: 1, subtle: 1, full: 1 };
_venueMotionMode = allowed[next] ? next : 'subtle';
};
window.h3dVenueSceneGetState = () => {
const motionMode = _venueEffectiveMotionMode();
const motionProfile = _venueMotionProfile(motionMode);
return {
active: _venueSceneOverride,
mood: _venueMoodState,
instrumentPov: _venueInstrumentPov,
motionMode: _venueMotionMode,
motionEffective: motionMode,
motionEnabled: motionMode !== 'off',
motionIntensity: motionProfile.breathe + motionProfile.parallax + motionProfile.hazeDrift,
motionProfile,
plateUrl: _venuePlateUrl || null,
assetsLoaded: _venueSceneAssetsLoaded,
loadFailed: _venueSceneLoadFailed,
};
};
// Back-compat alias for any caller that picked up the original
// (inconsistent) name during this PR's review window.
window.h3dSetPalette = window.h3dBgSetPalette;
// Procedural silhouette bitmap, drawn once and shared across panels.
// The Canvas2D bitmap is module-level (cheap, CPU-only); each layer
// wraps it in its own CanvasTexture so per-layer texture.offset.x
// can drive a seam-free scroll without coupling to other layers /
// panels (a shared CanvasTexture would synchronize all offsets).
let _silCanvas = null;
function _bgEnsureSilhouetteCanvas() {
if (_silCanvas) return _silCanvas;
const c = document.createElement('canvas');
c.width = 1024; c.height = 64;
const cx = c.getContext('2d');
if (!cx) {
// Restrictive environments (some sandboxed iframes, headless
// tests) can return null. Without a guard, the clearRect/
// fillRect calls below would throw TypeError and the silhouette
// style would never become available.
throw new Error('[3D-Hwy] 2D canvas context unavailable for silhouette texture');
}
cx.clearRect(0, 0, c.width, c.height);
cx.fillStyle = '#000814';
let x = 0;
while (x < c.width) {
const w = 8 + Math.random() * 30;
const h = 20 + Math.random() * 40;
cx.fillRect(x, c.height - h, w, h);
x += w + Math.random() * 10;
}
_silCanvas = c;
return c;
}
// Helpers shared by the asset-driven bg styles (image, video).
// Both render a "stage backdrop" plane that's full-bleed: sized
// each frame to fill the camera's view frustum at a fixed
// distance and positioned to track the camera (so the user's
// image/video reads as the entire visible BG, with highway and
// notes painting on top via renderOrder).
//
// Distance is chosen far enough back that no note ever lands
// beyond it; depthWrite=false on the plane material plus
// renderOrder=-1 means notes still paint on top regardless.
const BG_BACKDROP_DISTANCE = FOG_END * 0.95;
// Module-level scratch vector reused each frame to avoid GC
// churn from per-frame Vector3 allocation. Only valid for the
// duration of a single update() call.
const _bgBackdropTmp = (() => {
// Lazily created when T is available (T isn't bound at module
// parse time — initScene assigns it inside loadThree().then).
// Returning a getter that allocates on first read keeps the
// dependency timing clean.
let v = null;
return () => v || (v = new T.Vector3());
})();
// Frustum-fit a plane mesh: scale a unit PlaneGeometry to exactly
// fill the camera's view at the configured distance, then position
// it `distance` units in front of the camera and orient it so the
// texture faces the camera. Called whenever cam.aspect changes
// (resize) and to position-track the camera each frame.
function _bgFitBackdropPlane(state) {
const cam = state.cam;
const d = state.distance;
const halfFovRad = cam.fov * Math.PI / 360;
const visibleHeight = 2 * Math.tan(halfFovRad) * d;
const visibleWidth = visibleHeight * cam.aspect;
if (state.lastAspect !== cam.aspect ||
state.lastVisibleHeight !== visibleHeight) {
state.mesh.scale.set(visibleWidth, visibleHeight, 1);
state.lastAspect = cam.aspect;
state.lastVisibleHeight = visibleHeight;
state.lastVisibleWidth = visibleWidth;
// Aspect change shifts the cover-crop ratio; re-apply.
if (state.applyCoverCrop) state.applyCoverCrop();
}
// Track camera each frame: position = cam.position +
// cam.forward * distance, orient toward camera.
const fwd = cam.getWorldDirection(_bgBackdropTmp());
state.mesh.position.copy(cam.position).addScaledVector(fwd, d);
state.mesh.lookAt(cam.position);
}
// Cover-crop a texture to the plane aspect: the larger axis fills
// the plane (cropped if needed), centered. For wider-than-plane
// textures the X offset is left at the centered value but the
// image style's drift loop overwrites it per frame; the video
// style leaves it centered.
function _bgCoverCrop(tex, srcW, srcH, planeAspect) {
if (srcW <= 0 || srcH <= 0) return;
tex.repeat.set(1, 1);
tex.offset.set(0, 0);
const srcAspect = srcW / srcH;
if (srcAspect > planeAspect) {
tex.repeat.x = planeAspect / srcAspect;
tex.offset.x = (1 - tex.repeat.x) * 0.5;
} else {
tex.repeat.y = srcAspect / planeAspect;
tex.offset.y = (1 - tex.repeat.y) * 0.5;
}
tex.needsUpdate = true;
}
// Background-style registry. Each entry returns a per-panel state
// object from build() and reads from it in update() / teardown().
// T (THREE) is set by the time these are invoked (initScene runs
// inside loadThree().then).
const BG_STYLES = {
off: {
build() { return null; },
update() {},
teardown() {},
},
particles: {
build(scene, 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;
// Spawn within the visible fog range. Fog reaches
// its far limit at FOG_END * 1.2 from the camera,
// and cam.position.z is updated each frame in
// camUpdate() (`dist * 0.75`, where dist tracks
// aspectScale). Anything beyond that camera-relative
// distance gets fully fogged out, so the cutoff in
// world z is dynamic — the earlier "push past notes"
// fix placed particles at -FOG_END * (0.95..1.20)
// which sat past fog far at any camera z, making
// them invisible. renderOrder = -1 on the bg stage
// already keeps particles behind notes regardless
// of z, so depth-based separation wasn't needed and
// was actively breaking visibility.
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({
// size 5*K (bumped from 1.5*K). At distance ~700*K
// with sizeAttenuation the prior sprite shrank
// below 2 pixels — practically invisible against
// dark fog. 5*K reads as a small bright dot.
// Build-time opacity is overridden every frame in
// update() — the runtime formula is the source of
// truth.
color: 0xa0c0ff, size: 5 * K, transparent: true,
blending: T.AdditiveBlending, depthWrite: false, sizeAttenuation: true,
});
const points = new T.Points(geo, mat);
scene.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;
// Bumped opacity floor 0.4 → 0.55 + treble headroom
// 0.4 → 0.45 so particles read as visible specks even
// when bgReactive is false / treble≈0 (was effectively
// 0.4 floor, below noise floor against dark fog).
s.mat.opacity = 0.55 + bands.treble * 0.45;
},
teardown(s) {
if (!s) return;
s.points.parent?.remove(s.points);
s.geo.dispose();
s.mat.dispose();
},
},
silhouettes: {
build(scene, settings) {
const canvas = _bgEnsureSilhouetteCanvas();
// Inside the visible fog range. Fog far = FOG_END * 1.2
// from the camera, and cam.position.z is dynamic
// (camUpdate() sets `dist * 0.75`). renderOrder = -1
// on the bg stage handles "behind notes" regardless
// of z. Spread the three layers across the back half
// of the visible fog band for parallax separation.
const depths = [-FOG_END * 0.55, -FOG_END * 0.70, -FOG_END * 0.85];
const layers = [];
const allocated = [];
try {
for (const z of depths) {
// Per-layer CanvasTexture wrapping the shared
// canvas: lets each layer scroll independently
// via texture.offset.x without coupling to its
// siblings or to other panels.
const tex = new T.CanvasTexture(canvas);
tex.wrapS = T.RepeatWrapping;
const geo = new T.PlaneGeometry(800 * K, 50 * K);
const mat = new T.MeshBasicMaterial({
map: tex, transparent: true, opacity: 0.4, depthWrite: false,
});
const mesh = new T.Mesh(geo, mat);
mesh.position.set(0, -10 * K, z);
scene.add(mesh);
// Parallax: nearer layers move more than farther
// ones (perspective). distance = -z; small d ->
// large parallax. Scaled so the nearest sits
// around 0.32 and farthest around 0.18.
const distance = -z;
const parallax = Math.max(0.05, 1 - distance / (FOG_END * 1.4));
const layer = { mesh, geo, mat, tex, z, drift: 0, parallax };
layers.push(layer);
allocated.push(layer);
}
return { layers, intensity: settings.intensity };
} catch (e) {
// Build threw partway — clean up any per-layer
// textures we already created. _bgMountStyle's catch
// disposes the stage tree's meshes, but a partial-
// build's CanvasTextures aren't reachable from any
// mesh yet, so this catch owns them.
for (const L of allocated) {
L.tex?.dispose?.();
}
throw e;
}
},
update(s, bands, dt) {
// Intensity multiplier: 0 dims to ~50% of base, 1
// brightens to ~120%. Below-base values still leave the
// silhouettes faintly visible so users know the style
// is on; above-base lets the layers read as a real
// backdrop on louder passages.
const intensityMul = 0.5 + s.intensity * 0.7;
for (const L of s.layers) {
// Scroll via texture.offset.x with RepeatWrapping —
// unbounded, no modulus snap. The mesh stays put;
// the texture wraps continuously across the visible
// surface. (offset is in normalized texture space,
// so we keep it small and let the wrap do the job.)
L.drift += dt * (0.05 + bands.mid * 0.15) * L.parallax;
L.mat.map.offset.x = L.drift;
L.mesh.position.y = -10 * K + bands.bass * 4 * K;
L.mat.opacity = (0.25 + 0.5 * L.parallax) * intensityMul;
}
},
teardown(s) {
if (!s) return;
for (const L of s.layers) {
L.mesh.parent?.remove(L.mesh);
L.geo.dispose();
L.mat.dispose();
L.tex.dispose();
}
},
},
lights: {
build(scene, settings) {
// Lights count scales 6 → 14 over intensity 0 → 1.
// _bgCoerce clamps intensity to [0,1] before it reaches
// here, so no further clamp is needed.
const N = Math.floor(6 + 8 * settings.intensity);
const lights = [];
// Palette comes from the calling panel's settings so
// each splitscreen panel picks its own (issue #10).
// Falls back to the default palette if the caller
// doesn't supply one (e.g. an older code path).
const palette = settings.palette || PALETTES.default;
for (let i = 0; i < N; i++) {
const color = palette[i % palette.length];
// 30*K plane reads as a real stage glow at distance.
// Build-time opacity is overridden every frame in
// update() — the runtime formula is the source of
// truth.
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.position.set(
(Math.random() - 0.5) * 600 * K,
(Math.random() - 0.3) * 80 * K,
// Inside visible fog range; renderOrder = -1
// keeps lights behind notes regardless of z.
-FOG_START - Math.random() * (FOG_END - FOG_START) * 0.85
);
scene.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) {
// Bumped opacity floor 0.35 → 0.55 + treble headroom
// 0.3 → 0.4 so lights read as visible stage glows at
// distance instead of faint specks (was effectively
// 0.35 floor since the build-time bump was overridden
// by this formula).
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) {
L.mesh.parent?.remove(L.mesh);
L.geo.dispose();
L.mat.dispose();
}
},
},
geometric: {
build(scene, settings) {
const meshes = [];
// Bumped opacity floor (0.25 → 0.45) + ceiling so the
// wireframes read as real shapes instead of barely-
// there ghosts at low intensity.
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 }),
);
// Inside visible fog range; renderOrder = -1 keeps
// wireframes behind notes regardless of z.
ico.position.set(-100 * K, 30 * K, -FOG_END * 0.65);
scene.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);
scene.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) {
m.parent?.remove(m);
m.geometry.dispose();
m.material.dispose();
}
},
},
// Venue visualization — generated small-club raster bg plate
// behind the highway. Activated via h3dVenueSceneSetActive(true)
// when Visualization = Venue; does not persist as a user bg style.
venue: {
build(scene, settings) {
const coeffs = _bgVenueMoodCoeffs(_venueMoodState);
const state = {
backdrop: null,
haze: null,
loader: null,
instrumentPov: _venueInstrumentPov,
plateLoading: false,
pending: 1,
loaded: false,
failed: false,
};
function _venueMarkLoaded() {
state.pending--;
if (state.pending <= 0 && !state.failed) {
state.loaded = true;
_venueSceneAssetsLoaded = true;
_venueSceneLoadFailed = false;
try {
if (typeof window !== 'undefined' && window.v3VenueScene3d &&
typeof window.v3VenueScene3d.onAssetsLoaded === 'function') {
window.v3VenueScene3d.onAssetsLoaded();
}
} catch (_) { /* visual-only */ }
}
}
function _venueMarkFailed(msg) {
if (state.failed) return;
state.failed = true;
_venueSceneLoadFailed = true;
_venueSceneAssetsLoaded = false;
console.warn('[venue-scene] ' + msg);
_venueSceneOverride = false;
_bgEmitChange('venueScene');
try {
if (typeof window !== 'undefined' && window.v3VenueScene3d &&
typeof window.v3VenueScene3d.onAssetsFailed === 'function') {
window.v3VenueScene3d.onAssetsFailed(msg);
}
} catch (_) { /* visual-only */ }
}
const loader = new T.TextureLoader();
state.loader = loader;
const backdrop = {
mesh: null, geo: null, mat: null, tex: null,
cam: settings.cam, distance: BG_BACKDROP_DISTANCE * VENUE_BACKDROP_DISTANCE_MUL,
lastAspect: 0, lastVisibleHeight: 0, lastVisibleWidth: 0, loaded: false,
};
backdrop.geo = new T.PlaneGeometry(1, 1);
backdrop.mat = new T.MeshBasicMaterial({
color: 0xffffff, transparent: false, depthWrite: false, fog: false,
});
backdrop.mesh = new T.Mesh(backdrop.geo, backdrop.mat);
backdrop.mesh.visible = false;
scene.add(backdrop.mesh);
state.backdrop = backdrop;
backdrop.applyCoverCrop = function () {
if (!backdrop.tex || !backdrop.tex.image) return;
_bgCoverCrop(
backdrop.tex,
backdrop.tex.image.width || 0,
backdrop.tex.image.height || 0,
backdrop.cam.aspect,
);
};
_venueLoadPlateForPov(
loader,
_venueInstrumentPov,
backdrop,
() => _venueMarkLoaded(),
() => _venueMarkFailed('failed to load small-club bg plate'),
);
const hazeGeo = new T.PlaneGeometry(280 * K, 40 * K);
const hazeMat = new T.MeshBasicMaterial({
color: 0x101820, transparent: true, opacity: coeffs.haze,
depthWrite: false, fog: false,
});
const hazeMesh = new T.Mesh(hazeGeo, hazeMat);
hazeMesh.position.set(0, -12 * K, -FOG_END * 0.70);
scene.add(hazeMesh);
state.haze = {
mesh: hazeMesh, geo: hazeGeo, mat: hazeMat, baseOp: coeffs.haze,
baseX: 0, baseY: -12 * K, baseZ: -FOG_END * 0.70,
};
return state;
},
update(s, bands, dt, t) {
if (!s || s.failed) return;
_venueSwapPlateIfNeeded(s);
const coeffs = _bgVenueMoodCoeffs(_venueMoodState);
if (s.backdrop && s.backdrop.loaded) {
_bgFitBackdropPlane(s.backdrop);
}
const motion = _venueApplyFakeDepthMotion(s, coeffs, t);
if (s.backdrop && s.backdrop.loaded && s.backdrop.mat && !motion.breathe && !motion.warmthPulse) {
const warm = coeffs.warmth;
s.backdrop.mat.color.setRGB(warm, warm * 0.98, warm * 0.95);
}
if (s.haze && s.haze.mat && !motion.hazeDrift && !motion.shimmer) {
s.haze.mat.opacity = (s.haze.baseOp || VENUE_HAZE_STEADY)
* (coeffs.haze / VENUE_HAZE_STEADY);
}
},
teardown(s) {
if (!s) return;
_venueSceneAssetsLoaded = false;
for (const key of ['backdrop', 'haze']) {
const p = s[key];
if (!p) continue;
p.mesh?.parent?.remove(p.mesh);
p.geo?.dispose?.();
if (p.mat) {
p.mat.map = null;
p.mat.dispose?.();
}
}
// Dispose the cached plate textures too — the module-level cache
// otherwise keeps every loaded POV plate GPU-resident for the
// page lifetime (steady VRAM growth across POV/arrangement swaps).
try {
_venueTextureCache.forEach((tex) => { tex?.dispose?.(); });
} catch (_) { /* visual-only */ }
_venueTextureCache.clear();
},
},
// Custom image backdrop (#19). User uploads a JPG/PNG/WebP
// through settings.html; the bytes are persisted as a base64
// data URL in localStorage under h3d_bg_customImageDataUrl and
// passed in via settings.customImageDataUrl. Renders as a
// PlaneGeometry in the silhouette parallax band, "cover" cropped
// (via texture.repeat / offset) so non-matching aspects fill
// the plane without distortion. Slow horizontal drift on
// texture.offset.x for life. When no asset is uploaded, build
// returns null and the style is inert (settings.html disables
// the picker option in that case).
image: {
build(scene, settings) {
// Upfront validation: only accept the same raster image
// formats settings.html lets the user upload (jpeg /
// png / webp). Without this, a corrupt localStorage
// value (truncated base64, wrong scheme, plain string)
// OR an unsupported type (e.g. data:image/svg+xml)
// reaches TextureLoader and can fail asynchronously
// after the plane has been mounted — a silent black
// backdrop with no clear cause. Returning null here
// treats invalid bytes the same as "no asset uploaded":
// style is inert, the user can clear and re-upload
// from settings.html.
const dataUrl = (typeof settings.customImageDataUrl === 'string')
? settings.customImageDataUrl.trim() : '';
if (!/^data:image\/(jpeg|png|webp);/i.test(dataUrl)) return null;
// Renderer-side encoded-length cap. settings.html
// enforces the same limit on upload, but a manually
// edited localStorage value (or legacy data from
// before the upload guard existed) could still feed
// an arbitrarily large data URL into TextureLoader
// and burn memory / CPU during decode. Treat overlong
// values as "no asset" — style is inert, user can
// clear and re-upload from settings.
if (dataUrl.length > 2.5 * 1024 * 1024) return null;
// Renderer-side decompression-bomb caps. Mirror
// settings.html's upload-time guard so a manual
// localStorage edit (or legacy data from before that
// guard existed) can't sneak a 50000×50000 PNG past
// and OOM the GPU on texture upload.
const MAX_IMAGE_DIM = 4096;
const MAX_IMAGE_PIXELS = 16 * 1024 * 1024;
// Full-bleed backdrop: unit plane, scaled per frame in
// _bgFitBackdropPlane to fill the camera's view at
// BG_BACKDROP_DISTANCE. fog: false so the backdrop
// shows in full color; notes drawn on top still pick
// up atmospheric fog as before.
const state = {
mesh: null, geo: null, mat: null, tex: null,
drift: 0.5, intensity: settings.intensity, loaded: false,
cam: settings.cam, distance: BG_BACKDROP_DISTANCE,
lastAspect: 0, lastVisibleHeight: 0,
};
// Helper closure for cover-crop refresh — called both
// on async decode (initial) and from _bgFitBackdropPlane
// when the camera aspect changes (resize).
state.applyCoverCrop = function () {
if (!state.tex || !state.tex.image) return;
_bgCoverCrop(
state.tex,
state.tex.image.width || 0,
state.tex.image.height || 0,
state.cam.aspect,
);
};
const tex = new T.TextureLoader().load(
dataUrl,
(loaded) => {
// Image dimensions are only known after async decode.
const imgW = loaded.image?.width || 0;
const imgH = loaded.image?.height || 0;
if (imgW > MAX_IMAGE_DIM || imgH > MAX_IMAGE_DIM || (imgW * imgH) > MAX_IMAGE_PIXELS) {
// Bail before the texture gets uploaded to
// the GPU (Three.js uploads on first render
// of a visible mesh — hiding the mesh here
// skips that). Disposing the texture too,
// belt-and-suspenders, in case anything
// else holds a reference.
console.warn('[3D-Hwy] custom image dimensions too large to render', imgW + 'x' + imgH);
if (state.mesh) state.mesh.visible = false;
loaded.dispose();
return;
}
state.applyCoverCrop();
// Reset drift to the centered triangle-wave
// phase now that repeat.x is final. Without
// this reset, drift accumulated during the
// async decode would phase-shift the initial
// offset by a non-deterministic amount —
// wider images would open at whatever crop
// the elapsed-decode-time happened to land on.
state.drift = 0.5;
state.loaded = true;
},
undefined,
// Async-failure path: the upfront regex catches the
// common "corrupted/truncated bytes" case, but a
// valid-looking data URL can still fail to decode
// (e.g. wrong MIME / unsupported codec). Hide the
// mesh so we don't paint a frozen blank plane on
// top of fog, and log so the failure isn't silent.
(err) => {
console.error('[3D-Hwy] custom image decode failed', err);
if (state.mesh) state.mesh.visible = false;
},
);
tex.colorSpace = T.SRGBColorSpace;
// ClampToEdge on both axes — user uploads are non-
// power-of-two in general, and WebGL1 rejects RepeatWrapping
// on NPOT textures (renders black or emits GL errors). The
// drift logic below uses a triangle-wave so the offset
// stays inside [0, 1-repeat] and never needs wrap.
tex.wrapS = T.ClampToEdgeWrapping;
tex.wrapT = T.ClampToEdgeWrapping;
// User uploads aren't power-of-two in general; mipmaps
// are noisy for a single static backdrop and burn memory.
tex.generateMipmaps = false;
tex.minFilter = T.LinearFilter;
tex.magFilter = T.LinearFilter;
const geo = new T.PlaneGeometry(1, 1);
const mat = new T.MeshBasicMaterial({
map: tex, transparent: false, depthWrite: false, fog: false,
});
const mesh = new T.Mesh(geo, mat);
scene.add(mesh);
state.mesh = mesh;
state.geo = geo;
state.mat = mat;
state.tex = tex;
// Initial fit so the first frame is correctly sized
// and positioned, even if update() hasn't run yet.
_bgFitBackdropPlane(state);
return state;
},
update(s, bands, dt) {
if (!s) return;
// Track camera position / aspect every frame. The
// helper resizes the plane and refreshes cover-crop
// when aspect changes, and re-positions the plane to
// stay BG_BACKDROP_DISTANCE in front of the camera.
_bgFitBackdropPlane(s);
// Skip drift advance until the texture has finished
// decoding. Without this guard, drift accumulates
// during the async load while repeat.x is still 1
// (its default), and once the cover-crop applies the
// image opens at a phase-shifted offset whose value
// depends on how long the decode took — the
// "centered start" intent becomes non-deterministic.
if (!s.loaded) return;
// Triangle-wave ping-pong drift inside the cropped slack.
// ClampToEdge on wrapS means we cannot wrap across the
// texture boundary (would render edge pixels stretched);
// ping-pong oscillates the visible window between the
// image's left and right edges, which gives the same
// "alive" feel without the WebGL1 NPOT-Repeat hazard.
// Slack is the horizontal margin between the cropped
// window and the texture edges; for taller-than-plane
// images repeat.x stays 1, slack collapses to 0, and
// the offset stays at 0 — the image sits still, which
// is correct (it's already filling horizontally).
s.drift += dt * 0.02 * s.intensity;
const slack = Math.max(0, 1 - s.tex.repeat.x);
// Period of 2 drift units ≈ 100 s at intensity = 0.5;
// gentle, cinematic. cyc ∈ [0, 2), tri ∈ [0, 1] then back.
const cyc = ((s.drift % 2) + 2) % 2;
const tri = cyc < 1 ? cyc : 2 - cyc;
s.tex.offset.x = tri * slack;
},
teardown(s) {
if (!s) return;
s.mesh.parent && s.mesh.parent.remove(s.mesh);
s.geo.dispose();
s.mat.dispose();
// This style owns the texture lifecycle (per the comment
// at _bgDisposeGroupTree: tree dispose does NOT touch
// material.map textures).
s.tex.dispose();
},
},
// Custom video backdrop (#19 follow-up). User uploads a
// .mp4/.webm via settings.html; routes.py stores it on disk and
// serves a same-origin URL (avoids CORS taint on VideoTexture).
// localStorage holds only the filename — bytes live in
// {config_dir}/plugin_uploads/highway_3d/. Per-panel video
// element so each panel can mount/teardown independently;
// browsers cache the video bytes after first fetch so multi-
// panel splitscreen pays only the decoder cost, not the
// network or disk-read cost.
video: {
build(scene, settings) {
// Lowercase before validation so a manual localStorage
// edit like `current.MP4` doesn't pass a case-insensitive
// regex check and then 404 against the server, which
// only ever produces and serves lowercase
// current.<ext> (the upload route lowercases the
// extension; routes.py's GET pattern is case-sensitive).
const filename = (typeof settings.customVideoName === 'string')
? settings.customVideoName.trim().toLowerCase() : '';
// Strict pattern matches routes.py's deterministic
// single-slot naming. Any other shape (corrupt
// localStorage, future schema change) → style is
// inert, no <video> created, no orphan request to a
// 404 endpoint.
if (!/^current\.(mp4|webm)$/.test(filename)) return null;
const url = '/api/plugins/highway_3d/files/' + filename;
// Track partial allocations so a throw between any of
// them can clean up. _bgMountStyle's failure path
// disposes the stage tree but explicitly does NOT
// dispose textures (per the comment at
// _bgDisposeGroupTree), and the <video> element is
// parented to document.body — not the stage — so
// neither would be reached without an explicit catch.
let videoEl = null, tex = null, geo = null, mat = null, mesh = null;
try {
// muted + playsInline + autoplay is the cross-
// browser recipe that bypasses gesture requirements
// (Chrome, Firefox, Safari desktop + mobile).
// preload='auto' lets the first frame land before
// play() is called. src is deliberately NOT set
// yet — we want every piece of state (mesh, tex)
// to exist before the browser can fire
// loadedmetadata or error events on a cached
// resource. The handlers close over state.tex /
// state.mesh; setting src first would create a
// window where a fast cache hit could fire an
// event into half-initialized state.
videoEl = document.createElement('video');
// No crossOrigin attribute: the URL is same-origin
// (/api/plugins/highway_3d/files/…), so VideoTexture
// never sees a tainted canvas. Setting
// `crossOrigin = "anonymous"` would also strip
// cookies from the fetch, which would 401 against
// any cookie-protected slopsmith deployment. If
// this ever needs to fetch cross-origin, switch
// to `use-credentials` AND have the server send
// the matching CORS headers.
videoEl.muted = true;
videoEl.playsInline = true;
videoEl.loop = true;
videoEl.autoplay = true;
videoEl.preload = 'auto';
videoEl.style.display = 'none';
document.body.appendChild(videoEl);
// Build mesh + texture before registering listeners
// and before setting src. By the time loadedmetadata
// or error can fire, state.tex and state.mesh are
// both populated.
tex = new T.VideoTexture(videoEl);
tex.colorSpace = T.SRGBColorSpace;
tex.wrapS = T.ClampToEdgeWrapping;
tex.wrapT = T.ClampToEdgeWrapping;
tex.minFilter = T.LinearFilter;
tex.magFilter = T.LinearFilter;
tex.generateMipmaps = false;
geo = new T.PlaneGeometry(1, 1);
mat = new T.MeshBasicMaterial({
map: tex, transparent: false, depthWrite: false, fog: false,
});
mesh = new T.Mesh(geo, mat);
scene.add(mesh);
// Full-bleed backdrop: scaled and positioned each
// frame in update() via _bgFitBackdropPlane.
// cam + distance + lastAspect / lastVisibleHeight
// power that helper.
const state = {
videoEl, mesh, geo, mat, tex,
cam: settings.cam, distance: BG_BACKDROP_DISTANCE,
lastAspect: 0, lastVisibleHeight: 0,
};
state.applyCoverCrop = function () {
if (!state.videoEl) return;
_bgCoverCrop(
state.tex,
state.videoEl.videoWidth || 0,
state.videoEl.videoHeight || 0,
state.cam.aspect,
);
};
// Cover-crop math runs on loadedmetadata since
// video dimensions aren't known until then.
// _bgFitBackdropPlane will also re-apply when the
// camera aspect changes.
videoEl.addEventListener('loadedmetadata', () => {
state.applyCoverCrop();
});
videoEl.addEventListener('error', () => {
// Fired for: codec unsupported, 404 from
// server, truncated file, etc. Hide the mesh
// so we don't paint a frozen blank plane on
// top of fog.
console.error('[3D-Hwy] custom video load failed', videoEl.error);
state.mesh.visible = false;
});
// Set src last — this is what triggers the async
// load. With handlers and state in place, any
// synchronous-feeling event from a cached resource
// is still safely received and handled.
videoEl.src = url;
// play() can reject for transient reasons (tab
// backgrounded at mount time, low-power mode,
// brief autoplay-policy timing window) even with
// muted + autoplay set — but the browser retries
// on its own once conditions improve (visibility
// change, foregrounding, gesture). Real load /
// codec failures come through the `error` event
// we registered above and DO hide the mesh. So
// just log here and leave the mesh visible; the
// next ready frame will paint.
videoEl.play().catch((err) => {
console.warn('[3D-Hwy] custom video play() rejected (will retry on visibility/gesture)', err);
});
// Initial fit so the first frame is correctly
// sized and positioned even before update() runs.
_bgFitBackdropPlane(state);
return state;
} catch (err) {
// Best-effort cleanup of whatever was allocated
// before the throw. Each step is independently
// guarded so a secondary failure (e.g. dispose
// throwing on an already-disposed object) can't
// mask the original error.
try {
if (videoEl) {
videoEl.pause();
videoEl.removeAttribute('src');
videoEl.load();
if (videoEl.parentNode) videoEl.parentNode.removeChild(videoEl);
}
} catch (_) { /* ignore */ }
try { if (mesh && mesh.parent) mesh.parent.remove(mesh); } catch (_) { /* ignore */ }
try { if (geo) geo.dispose(); } catch (_) { /* ignore */ }
try { if (mat) mat.dispose(); } catch (_) { /* ignore */ }
try { if (tex) tex.dispose(); } catch (_) { /* ignore */ }
throw err;
}
},
update(s) {
if (!s) return;
// VideoTexture auto-updates from the playing element —
// Three.js samples the current frame each render. No
// per-frame texture mutation here. Drift on offset.x
// is intentionally omitted: the video's own motion is
// the "life", drifting the crop on top would feel
// busy and compete with playback. The only per-frame
// work is keeping the plane camera-locked and resized
// when aspect changes (handled inside the helper).
_bgFitBackdropPlane(s);
},
teardown(s) {
if (!s) return;
if (s.videoEl) {
try { s.videoEl.pause(); } catch (_) {}
s.videoEl.removeAttribute('src');
// load() with no src tells the browser to release
// any decoder/buffer state for this element.
try { s.videoEl.load(); } catch (_) {}
if (s.videoEl.parentNode) s.videoEl.parentNode.removeChild(s.videoEl);
}
if (s.mesh) s.mesh.parent && s.mesh.parent.remove(s.mesh);
if (s.geo) s.geo.dispose();
if (s.mat) s.mat.dispose();
if (s.tex) s.tex.dispose();
},
},
};
/* ======================================================================
* Per-instance counter
* ====================================================================== */
let _nextInstanceId = 0;
/* ======================================================================
* Factory — slopsmith#36 setRenderer contract
* ====================================================================== */
function createFactory() {
const _instanceId = ++_nextInstanceId;
// ── Per-instance Three.js state ───────────────────────────────────
let scene = null, cam = null, ren = null;
let wrap = null;
// highway:visibility listener (slopsmith#246). Hides the .h3d-wrap
// overlay when slopsmith's canvas is display:none'd (splitscreen
// case). Without this, the wrap is a *sibling* of #highway so
// hiding #highway leaves the WebGL scene painting full-screen.
// Bound in initScene after wrap creation, unbound in destroy().
let _visibilityHandler = null;
// highway:canvas-replaced listener — keeps highwayCanvas up to
// date across context-type swaps (e.g. swapping back to a 2D
// viz). The visibility handler's identity gate (event.detail.
// canvas === highwayCanvas) would otherwise stop matching
// after the swap; this listener follows the documented plugin
// contract from CLAUDE.md.
let _canvasReplacedHandler = null;
let ambLight = null, dirLight = null;
let fretG = null, tuningLblG = null, noteG = null, beatG = null, lblG = null;
let gNote = null, gSus = null, gBeat = null, gTapChevron = null;
// Per-string gradient gem geometries (index 0..5). Built in initScene
// from sampled colour PNGs; each carries a per-vertex colour attribute.
let gNoteGrad = [];
let mStr = [], mGlow = [], mSus = [], mStrHitOutline = [], mAccentOutline = [], mAccentCore = [], mAccentHaloNear = [], mAccentHaloMid = [], mAccentHaloFar = [];
// Pre-built accent-halo shell descriptors per string. Populated after
// mAccentHaloFar/Mid/Near are materialised; consumed in drawNote()'s
// hot path so the inner per-note `accentShells = [...]` array literal
// (3 plain-object allocations per accent gem per frame) is replaced
// by a stable read. Index 0 = outer, 1 = mid, 2 = near.
let _accentShellsByString = [];
let mWhiteOutline = null, mSusOutline = null;
// Dedicated sustain-trail outline material for the hit verdict.
// Drawn at opacity 0.45 — lower than mSusOutline (0.75) so the
// bright green emissive doesn't tint the body interior, and the
// verdict shows mostly on the outline fringe past the body edges.
// Only the hit-side rim ships; the verdict on miss is carried by
// mMissOutline (the gem-border material) instead of a dedicated
// sustain outline — matches the "outline-only verdict, body retains
// string colour" doctrine for the rest of the rendering path.
let mHitSusOutline = null;
// Shared materials for the legato technique meshes — one per geometry
// type, reused across every pooled mesh instance to avoid per-mesh
// material allocation in dense HO/PO/tap passages. Allocated in
// initScene() alongside the other scene materials and disposed in
// teardown.
let mTapChevron = null;
// Barre indicator material (white vertical line at the barre fret
// during chord linger). Promoted from inline pool-factory authoring
// to a named module-scope reference so _applyGlow() can mutate
// emissiveIntensity in place when the user drags the glow slider.
let mBarre = null;
// Notedetect feedback outlines (issue #9). Created in initScene
// alongside mWhiteOutline; swapped onto the note's outline mesh
// when a recent notedetect:hit / :miss event matches the note's
// (s, f, t). The miss gem border uses mMissOutline; the hit side
// uses per-string mHitBright[s] for the cyan-shifted flash.
let mMissOutline = null;
// Per-string hit verdict material used for outline + lateral face fill.
// Built in initScene() after mGlow. Array share the same material
// instances so outline and face fill always match exactly.
let mHitBright = [], mHitBrightArrays = [];
// [verdict glow] Per-frame accumulation of the note-state provider's
// alpha (note_detect drives this from the live input level for held
// sustains, and as a time-fade for fresh strikes). Applied at the top of
// update() to scale the verdict-glow materials' emissiveIntensity so the
// gem brightness tracks how hard the string is actually ringing. Stays
// at "no provider" (vg = 1, unchanged brightness) for the legacy event
// path or when note_detect is off.
let _ndVerdictMaxAlpha = 0;
let _ndVerdictSawAlpha = false;
// Magenta-red face fill for miss — see initScene() for construction
// (uses mMissOutline ×4 + mEdgeTransparent ×2).
let mMissEdgeArrays = null;
let mEdgeTransparent = null;
let pSusOutline = null, pNoteEdge = null;
let projMeshArr = null;
let _probe = null;
/** Snapshotted in update() for drawNote() ghost / glow (single source vs per-caller isNext). */
let _drawNextByString = null;
/** Most-recent past event time per string (within 0.6 s back), for _nextAnyT deadline. */
let _drawRecentByString = null;
/** Snapshotted in update() — drawNote() is a sibling of update(), not nested in its closure. */
let _drawChordTemplates = null;
let _laneTargetColor = null;
let _renderScale = 1;
let lyricsCanvas = null, lyricsCtx = null;
// FPS counter overlay. EMA-smoothed over ~30 frames so the readout doesn't
// jitter every rAF tick. Controlled by the 'fpsVisible' setting (BG_DEFAULTS).
// Legacy 'h3d_showFps' localStorage key and window.h3dShowFps are no longer
// consulted — use the Settings → 3D Highway — Camera → Show FPS counter checkbox.
let _fpsLastT = 0;
let _fpsEma = 0;
let _fpsDisplay = 0;
let _fpsLastSampleT = 0;
let _diagChord = null;
// Chord diagram render cache. Keys: static layout inputs joined as a
// string. Values: OffscreenCanvas (or <canvas>) rendered at opacity=1
// entranceT=1 — composited each frame via drawImage + globalAlpha.
// Cleared on canvas resize (bx/by depend on canvasW/H/lyricsBottom)
// and on teardown/destroy.
const _diagRenderCache = new Map();
// Cap chosen to cover the ~56 active chord shapes per phrase while
// keeping the cached-OffscreenCanvas footprint bounded (~50 MB per
// panel at typical 1920×1080). A structural fix — caching a
// tightly-sized box surface instead of the full overlay canvas —
// is tracked as a follow-up.
const _DIAG_CACHE_MAX = 6;
let pSusRail = null, gSusRail = null, mSusRailBase = null;
let pSusRailBloom = null, gSusRailBloom = null, mSusRailBloomBase = null, _bloomGaussTex = null;
let pTechPlane = null, gTechPlane = null;
// ── InstancedMesh for PM/FH X markers ────────────────────────────────
// Replaces pTechPlane pool entries for PM and FH mute techniques,
// collapsing O(visible-muted-notes) draw calls to 2 per type.
// pTechPlane pool is still used for H/P triangles, harmonics and bends.
let imPMTech = null, imFHTech = null;
let _imGPMTech = null, _imGFHTech = null; // cloned geometries (own instanceAlpha attr)
let _imPMTechMat = null, _imFHTechMat = null;
const IM_TECH_CAP = 256;
const _imPMTechAlphaArr = new Float32Array(IM_TECH_CAP);
const _imFHTechAlphaArr = new Float32Array(IM_TECH_CAP);
let _imPMTechCount = 0, _imFHTechCount = 0;
// ── InstancedMesh for chord strum indicators ──────────────────────────
// Replaces pPMXFill, pMuteXLines, pFHXFill, pFHXLines pools.
// Fixed renderOrder per type — no per-instance sort needed.
let imPMXFill = null, imPMXLines = null, imFHXFill = null, imFHXLines = null;
let _imPMXFillMat = null, _imPMXLinesMat = null;
let _imFHXFillMat = null, _imFHXLinesMat = null;
const IM_STRUM_CAP = 64;
const _imPMXFillAlphaArr = new Float32Array(IM_STRUM_CAP);
const _imPMXLinesAlphaArr = new Float32Array(IM_STRUM_CAP);
const _imFHXFillAlphaArr = new Float32Array(IM_STRUM_CAP);
const _imFHXLinesAlphaArr = new Float32Array(IM_STRUM_CAP);
let _imPMXFillCount = 0, _imPMXLinesCount = 0, _imFHXFillCount = 0, _imFHXLinesCount = 0;
// Temporaries for InstancedMesh matrix composition — allocated once in
// initScene() after Three.js loads, reused every frame without allocation.
let _imM4 = null, _imPos = null, _imSca = null, _imQ = null, _imAZ = null, _imColor = null;
let _diagPrev = null;
let _diagPrevOpacity = 0;
let _diagPrevStartOpacity = 0;
let _diagPrevStartT = null; // bundle.currentTime when crossfade began (drives rewindable fade)
let _diagEntranceT = 1.0;
let _diagLastKey = null; // chord identity: name + '|' + frets.join(',')
// Per-wave cache for fret-column reference markers. Keyed by the
// wave's beat timestamp. We snapshot { hasLow, hasHigh, fretList,
// anchorKeyed } at first sight of a wave so its render gate stays consistent through the
// wave's flight even as activeFrets shifts mid-song. Entries are
// pruned each frame once their wave has passed `now`.
let _fretMarkerWaveCache = new Map();
// Per-frame booleans: handShapes[i] passes inferArpeggioFromNotePattern
// once (see fillArpeggioGhostInferFlags) so the note loop skips O(hs×notes)
// rescans — ref fillArpeggioGhostInferFlags in update().
let _arpGhostHsInferScratch = [];
// Handshape start-times where ghost fret numbers show but [ ] brackets are suppressed
// (synth-chord onset-match cases — not genuine arpeggios).
let _arpSynthOnsetHsSet = new Set();
/** Per-frame: ``handShapeIsArpeggioForLaneRail`` baked once — lane slices were O(96 × hs × infer). */
let _arpLaneRailHsScratch = [];
let _arpRailBoundLoScratch = [];
let _arpRailBoundHiScratch = [];
// ── Cross-frame caches for chart-static derivations ──────────────
// The merge + arp-flag fills below depend only on chart-static
// input arrays (handShapes / chords / chordTemplates / notes),
// not on `now`. The bundle hands us the same array refs every
// frame within an arrangement, so we can skip the recompute when
// the inputs are identity-equal to the previous frame's. On dense
// arrangements this avoids per-frame Set construction, nested
// O(hs × notes) scans, and a sort — significant FPS recovery.
let _mergeCacheResult = null;
let _mergeCacheChordsRef = null;
let _mergeCacheHsRef = null;
let _mergeCacheTplRef = null;
// Fret connector-label visibility cache: tracks which (time, fret)
// pairs may show their indicator number per the measure-skip rule
// (show only the first note with a given fret in a measure; suppress
// the same fret for the following measure, then allow it again).
let _fretLabelAllowed = new Set();
let _fretLabelNotesRef = null;
// Cache of measure-start times (beats with measure !== -1), rebuilt when
// the beats array changes. Drives the camera lookahead window
// (CAM_LOOKAHEAD_MEASURES measures instead of a fixed number of seconds).
let _measureStarts = [];
let _measureStartsRef = null;
// Frame-level dedup: tracks which (40ms-rounded-time, fret) pairs have already
// rendered a label this frame so that multiple strings at the same fret/onset
// (arpeggio chords, synthetic chords) never produce stacked duplicate labels.
const _frameLabeledKeys = new Set();
let _arpGhostInferRefHs = null;
let _arpGhostInferRefNotes = null;
let _arpGhostInferRefTpl = null;
// Slide-target gem suppression. A Set of "t_s" keys for notes in
// bundle.notes that are the linkNext destination of a preceding note
// (single or chord). The gem is suppressed (skipBody=true) but the
// sustain/slide trail still renders so the slide motion stays visible.
let _slideTargetSet = null;
let _slideTargetNotesRef = null;
let _slideTargetChordsRef = null;
let _laneRailFlagsRefHs = null;
let _laneRailFlagsRefTpl = null;
let _laneRailBoundsRefHs = null;
let _laneRailBoundsRefChords = null;
let _laneRailBoundsRefTpl = null;
let _laneRailBoundsRefNotes = null;
let _lastHwW = 0, _lastHwH = 0;
// Last logical (CSS px) size handed to applySize(). #highway is a
// flex:1 item, so its real rendered box (canvasSize()) can change as
// the player layout settles after a song opens WITHOUT the backing
// store (canvas.width) changing — which the _lastHwW/H check below
// would miss. Tracking the applied logical size lets draw() detect
// that CSS-box drift and re-frame, instead of the user having to
// un/re-maximize the window.
let _appliedW = 0, _appliedH = 0;
let mBeatM = null, mBeatQ = null;
let txtCache = {};
// Cloned sprite materials cached on individual sprite instances
// (e.g. pmMark._pmMat). pLbl pool reuses sprites across labels,
// so when a sprite is later assigned a different material the
// _pmMat stays referenced on the sprite itself but isn't reached
// by the scene.traverse-based dispose. Track them here so
// teardown can dispose them explicitly.
const _ownedClonedMats = [];
// Per-mesh technique-marker clones — keyed by mesh, disposed when
// the source sprite's map changes or on teardown. Replaces the old
// unbounded push-per-frame approach in _spriteMat2MeshMat.
const _techMeshMatClones = new Set();
// Shared (non-clone) materials and geometries that pool factories
// reference but that aren't guaranteed to be reachable via
// scene.traverse() — e.g. mLaneEven is only reached if at least one
// even-numbered fret stripe ever spawns. Track them here so teardown
// disposes the GPU resource regardless.
const _ownedSharedMats = [];
const _ownedSharedGeos = [];
// Background animation state (issue #13). bgGroup is the parent
// container for all bg meshes so teardown is one remove + dispose
// pass. bgState is the active style's per-panel state object.
let bgGroup = null, bgStage = null, bgState = null;
let bgMountedStyleId = null;
let bgStyleId = 'particles', bgIntensity = 0.5, bgReactive = true;
// Per-render opt-out for plugins borrowing the highway as a viz: when the
// mount bundle sets bgReactive === false, suppress the audio-reactive
// background for THIS instance only (no shared h3d_bg_* write). Captured
// from the bundle in init(); applied in _bgLoadSettings() so it survives
// later setting reloads. See init() for the rationale.
let _bgReactiveOptOut = false;
// Active palette for this panel (issue #10). Materials and per-
// frame color reads inside createFactory all consult this rather
// than the module-level S_COL, so a palette swap re-tints the
// panel live without touching module-level state.
let activePalette = PALETTES.default;
// Content signature of the colors last applied to materials; lets
// _bgLoadSettings force a retint when the in-place custom palette
// changes values without changing array identity.
let _bgPaletteSig = '';
// Fret digits on the board ghost (hollow preview at Z=0), not on
// flying note bodies — see fretNumberGhostScope for chord-hand vs all.
let showFretOnNote = false;
let fretNumberGhostScope = 'chords';
// Camera-X smoothing dial (issue #34). 0 = twitchy (track every
// upcoming fret), 1 = calm (ignore small intra-cluster shifts).
// Cached here and refreshed via the bg listener to avoid a
// per-frame localStorage hit inside update().
let cameraSmoothing = 0.5;
// Per-axis follow-ups: zoom (tgtDist hysteresis) and vertical-tilt
// (tgtLookY NDC self-correction) each get their own dial. Same
// 0..1 shape; same caching pattern. Both mirror cameraSmoothing's
// value when not explicitly stored, so existing users who only
// ever moved the camera-smoothing slider get the same calmness on
// the new axes by default.
let zoomSmoothing = 0.5;
let tiltSmoothing = 0.5;
// Camera lock: when true, pin the camera to a fixed wide view of
// frets 1-12 unless an upcoming note would otherwise be off-screen.
// The lock disengages while any note above fret 12 is in the
// lookahead window so the camera can briefly widen to include it,
// then re-engages once the high note ages out.
let cameraLockLow = false;
// Zoom-level for the locked view. Slider 0..1 maps to a multiplier
// on the locked tgtDist: 0 → CAM_LOCK_ZOOM_MIN (closest, biggest
// fretboard), 0.5 → 1.0× (the default locked view), 1 → CAM_LOCK_ZOOM_MAX
// (furthest). Inactive when the lock isn't engaged.
let cameraLockZoom = 0.5;
/** 'steady' = recency-weighted centroid + hysteresis (#34); 'lookahead' = wide preview window + smooth focal. */
let cameraMode = BG_DEFAULTS.cameraMode;
// Global text-size multiplier for in-scene text sprites (chord
// names, fret labels, section banners, technique markers, etc.).
// Slider is 0..1; mapped to a 0.5..1.5× multiplier with 0.5 = 1.0×
// (current default behaviour). _textSizeMul is the materialized
// multiplier — refreshed once per frame at the top of update()
// and consumed by every text-sprite scale.set call inside update
// and drawNote.
let textSize = 0.5;
let _textSizeMul = 1.0;
let _textSizeMulApplied = -1;
// Visual look dials (issue: pastel/washed-out feel + too-much-glow
// complaint). vibrancy raises idle string/note opacity and de-whites
// the hit-note body; glow scales every emissive contribution +
// projection glow layer opacity. Sliders are 0..1; defaults lean
// vivid + minimal-glow to match the requested out-of-box look.
// _vibrancyIdleOp / _vibrancyProjOp are cached so
// updateStringHighlights() and drawNote() don't recompute the
// linear blend every frame.
let vibrancy = BG_DEFAULTS.vibrancy;
let glowMul = BG_DEFAULTS.glow;
let fpsVisible = BG_DEFAULTS.fpsVisible;
let fretDividersVisible = BG_DEFAULTS.fretDividersVisible;
let chordDiagramVisible = BG_DEFAULTS.chordDiagramVisible;
let chordDiagramSize = BG_DEFAULTS.chordDiagramSize;
let chordDiagramPosition = BG_DEFAULTS.chordDiagramPosition;
let fretColumnMarkerCadence = BG_DEFAULTS.fretColumnMarkerCadence;
let inlayLabelsVisible = BG_DEFAULTS.inlayLabelsVisible;
let sectionLabelsOnHighway = BG_DEFAULTS.sectionLabelsOnHighway;
let sectionHudVisible = BG_DEFAULTS.sectionHudVisible;
let sectionHudPosition = BG_DEFAULTS.sectionHudPosition;
let sectionHudSize = BG_DEFAULTS.sectionHudSize;
let toneHudVisible = BG_DEFAULTS.toneHudVisible;
let toneHudPosition = BG_DEFAULTS.toneHudPosition;
let toneHudSize = BG_DEFAULTS.toneHudSize;
let nutHeadstockVisible = BG_DEFAULTS.nutHeadstockVisible;
let tuningLabelsVisible = BG_DEFAULTS.tuningLabelsVisible;
let nutColor = BG_DEFAULTS.nutColor;
let headstockColor = BG_DEFAULTS.headstockColor;
let projectionVisible = BG_DEFAULTS.projectionVisible; // board "note preview" ghost on the fretboard
let slideArrowApproachVisible = BG_DEFAULTS.slideArrowApproachVisible; // slide-direction arrow riding with the note/gem
let slideArrowNeckVisible = BG_DEFAULTS.slideArrowNeckVisible; // slide-direction arrow preview on the neck
let slideArrowChainPreviewVisible = BG_DEFAULTS.slideArrowChainPreviewVisible; // early neck preview for chained/multi-leg slides
let _vibrancyIdleOp = 0.4 + 0.6 * BG_DEFAULTS.vibrancy;
let _vibrancyProjOp = 0.15 + 0.35 * BG_DEFAULTS.vibrancy;
// Custom image asset (issue #19). Data URL is the bytes that
// drive the 'image' bg style's texture; name is display-only
// metadata that settings.html shows next to the file picker.
let bgCustomImageDataUrl = '';
let bgCustomImageName = '';
// Custom video asset (issue #19 follow-up). Stores the
// server-side filename only; bytes live on disk via routes.py.
// The renderer composes the served URL from this filename in
// BG_STYLES.video.build.
let bgCustomVideoName = '';
let _bgListener = null;
let _bgLastT = 0; // ms timestamp for dt
// Notedetect feedback (issue #9). Per-panel mark queues populated
// by two event sources: (a) legacy `notedetect:hit` /
// `notedetect:miss` window CustomEvents, and (b) Slopsmith
// event-bus `note:hit` / `note:miss` events (subscribed in
// initScene() when window.slopsmith exposes both `on` and `off`).
// Both sources feed the same _ndPushMark() helper which dedupes
// dual emissions. drawNote looks up its (s, f, t) against these
// arrays each frame and swaps the outline material when a match
// is current. Marks expire after _ND_TTL_MS so the visual flash
// is brief. Marks self-prune unconditionally in the listener and
// once per frame in update() to keep the arrays small.
const _ND_TTL_MS = 500;
const _ND_TIME_EPS = 0.01;
let _ndHitMarks = [];
let _ndMissMarks = [];
let _ndOnHit = null, _ndOnMiss = null;
let _ndOnBusHit = null, _ndOnBusMiss = null;
let _ndLabels = [];
// Per-chord-occurrence verdict latch for the chord-frame rim
// tint. Once a chord is observed all-hit/active during its linger
// fade we latch 'green' here so subsequent frames can't undo it
// as individual constituent glows decay and getNoteState starts
// returning null again (which would otherwise flicker the rim
// back to red mid-linger). Keyed by `${ch.id}|${ch.t}` — ch.id
// alone is the chord *template* id and is reused across every
// occurrence of the same shape, so id-only latching would bleed
// a single clean grab onto every later occurrence of that chord.
// Pre-hit-line invalidation (chDt > 0 path in the rim selection)
// evicts a chord's latch the next time it's seen approaching, so
// loops/rewinds re-judge from scratch and the Map can't grow
// beyond the current pre-hit-line frontier. Also cleared in
// destroy().
let _chordVerdicts = new Map();
// Previous-frame `now` for the chord-verdicts pruner — on a
// backward seek the latches behind that time become "future"
// entries the forward-only prune can't reach, so we wipe the
// map instead of paying an O(n) scan per frame to find them.
let _chordVerdictsLastNow = null;
// Numeric encoding for the _chordVerdicts key — replaces
// ``${ch.id}|${ch.t}`` which allocated a string per chord per
// frame in detect mode. Encoded so the key is monotonic in
// chord time and the prune sweep can compare keys directly
// (no parseFloat / String.slice). The time component sits in
// the upper bits; chord-template ids share the lower 1e6 slot
// and ch.id == null reserves idSlot 0 (no real chord id can
// collide with it because real ids encode as id + 1).
// ``time * 1e4`` keeps a 0.1 ms resolution — more than enough
// to disambiguate distinct chord onsets — and stays under the
// safe-integer limit for any realistic song length.
const _CV_KEY_TIME_MUL = 1e4;
const _CV_KEY_TIME_SLOT = 1e6;
function _encodeChordVerdictKey(ch) {
const tSlot = Math.round(ch.t * _CV_KEY_TIME_MUL) * _CV_KEY_TIME_SLOT;
const idSlot = ch.id != null ? ((Number(ch.id) | 0) + 1) : 0;
return tSlot + idSlot;
}
// Per-frame timestamp captured by update() and used by its
// prune pass for the notedetect mark arrays. drawNote itself
// no longer reads it — pruning lives once per frame so
// drawNote's hot path is just the bounded (s, f, t) match.
let _ndFrameNowMs = 0;
// slopsmith#254 — core's per-note judgment provider, captured
// from `bundle.getNoteState` at the top of each update(). When
// present it's authoritative over the event-driven marks above:
// 'hit'/'active' → bright string-tinted outline (mGlow[s]) +
// bright body + glowing sustain trail + a contained sparkle on
// the overlay (a held sustain keeps glowing/sparkling for as
// long as it stays 'active'); 'miss' → red outline (mMissOutline)
// + suppressed body. null on cores without the API or songs
// with no scorer registered. Older note_detect builds that only
// emit notedetect:hit/miss events still work via _ndHitMarks.
let _ndGetNoteState = null;
let _ndHasProvider = false; // true iff a note-state provider is registered (slopsmith#254)
// Sustain verdict latch — persists a provider's hit/miss verdict for the
// full duration of a sustained note. Once hitGlowDuration expires the
// provider stops returning state; the latch re-injects the last verdict
// so the green/red color stays alive until susEnd.
// Key: Math.round(n.t * 1e4) * 10 + n.s (matches _ghostPrevBuf scheme)
// Value: 'hit' | 'hit-live' | 'miss' ('hit-live' = a live provider hit,
// tagged live:true, which is NOT re-injected once the provider goes
// silent — see the live-latch handling in the per-gem loop below).
let _susVerdictLatch = new Map();
// ── Score FX (notedetect game-scoring layer, notedetect ≥1.13) ──
// Two channels: (1) per-note "+N" score pops, sourced from the
// note-state provider's new { points, mult, popKey } fields at the
// moment a gem's verdict lands; (2) session-level bursts/pulses from
// the new `notedetect:fx` event (streak milestones, multiplier tier
// changes, streak breaks). Everything renders on the 2D overlay
// canvas (same layer as drawNotedetectLabels) — no Three.js objects,
// no txtMat() cache entries, nothing to dispose. Pools are fixed-
// size slot arrays created once per factory instance; when all slots
// are busy a new effect is simply dropped.
const _FX_POP_LIFE_MS = 700;
const _FX_BURST_LIFE_MS = 900;
const _FX_BURST_N = 36;
const _fxPops = Array.from({ length: 24 }, () => (
{ active: false, x: 0, y: 0, z: 0, bornMs: 0, text: '', mult: 1 }
));
const _fxBursts = Array.from({ length: 4 }, () => ({
active: false, bornMs: 0,
px: new Float32Array(_FX_BURST_N), py: new Float32Array(_FX_BURST_N),
vx: new Float32Array(_FX_BURST_N), vy: new Float32Array(_FX_BURST_N),
}));
// popKey -> expiry ms. Dedupes pops (chord members share the chord's
// popKey; sustains keep returning points for the whole glow window).
const _fxSeen = new Map();
let _fxOnFx = null; // notedetect:fx listener (window)
let _fxOnSkin = null; // notedetect:skin bus listener
// Generation counter: bumped by teardown() so the deferred window-
// copy fallback (a zero-delay task the listener removal can't cancel)
// bails instead of re-arming ring/burst state after teardown — or,
// worse, leaking a stale event into a subsequent init's fresh state.
let _fxGen = 0;
let _fxLastFxDetail = null; // reference dedup: window + instanceRoot dispatches share one detail
// Details seen via element-scoped (bubbled) dispatch. A WeakSet, not a
// single slot: one judged hit can emit several fx in the same task
// (milestone + multiplier tier-up), and the deferred window-copy
// fallback for the FIRST must still see that its element copy arrived
// after the SECOND overwrote any last-detail slot. GC reclaims
// entries once notedetect drops the detail objects.
let _fxElemSeen = new WeakSet();
let _fxRingMs = -1e9; // multiplier ring-pulse anchor
let _fxRingMult = 1;
let _fxBreakMs = -1e9; // streak-break flicker anchor
// Canvas-side palette per notedetect skin (mirrors the accents in
// notedetect's assets/plugin.css; fonts are document-loaded by that
// stylesheet so the overlay canvas can use the family names).
const _FX_PALETTES = {
neon: { accent: '#00f0ff', accent2: '#ff2ec4', miss: '#ff4444', font: 'Orbitron' },
esports: { accent: '#e8b43a', accent2: '#f5f5f4', miss: '#f87171', font: 'Rajdhani' },
metal: { accent: '#ffb347', accent2: '#ff6b35', miss: '#ef4444', font: 'Russo One' },
};
let _fxPalette = _FX_PALETTES.neon;
function _fxResolvePalette() {
let skin = null;
try { skin = localStorage.getItem('slopsmith_notedetect_skin'); } catch (e) {}
_fxPalette = _FX_PALETTES[skin] || _FX_PALETTES.neon;
}
function _fxSpawnPop(popKey, points, mult, x, y, z) {
if (_fxSeen.has(popKey)) return;
const nowMs = _ndFrameNowMs || performance.now();
_fxSeen.set(popKey, nowMs + 4000);
for (let i = 0; i < _fxPops.length; i++) {
const p = _fxPops[i];
if (p.active) continue;
p.active = true;
p.x = x; p.y = y; p.z = z;
p.bornMs = nowMs;
p.text = '+' + points;
p.mult = mult || 1;
return;
}
}
function _fxSpawnBurst(nowMs) {
for (let i = 0; i < _fxBursts.length; i++) {
const b = _fxBursts[i];
if (b.active) continue;
b.active = true;
b.bornMs = nowMs;
for (let j = 0; j < _FX_BURST_N; j++) {
const a = (j / _FX_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 _fxHandle(d) {
// Reference dedup — notedetect dispatches the SAME detail object
// on window and on its instanceRoot; whichever arrives first wins.
if (d === _fxLastFxDetail) return;
_fxLastFxDetail = d;
const nowMs = performance.now();
if (d.fxType === 'milestone') {
_fxSpawnBurst(nowMs);
} else if (d.fxType === 'multiplier' && d.mult > (d.prevMult || 1)) {
_fxRingMs = nowMs;
_fxRingMult = d.mult;
} else if (d.fxType === 'streakBreak') {
_fxBreakMs = nowMs;
}
}
// Object pools
let pNote, pSus, pLbl, pBeat, pSec;
let pFretLbl, pLane, pLaneDivider;
// Shared materials/geometry for the lane stripes — see initScene().
// Hoisted so draw() can reference them when assigning per-stripe.
let mLaneOdd = null, mLaneEven = null, gLanePlane = null;
/** Lane fret dividers: default white vs arpeggio frame tint on outer wires only. */
let mLaneDivider = null, mLaneDividerArp = null, mLaneDividerExt = null;
/** Shared XY plane for ghost fret digits (lies on board like proj, not billboarding). */
let gGhostFretPlane = null, pGhostFretLbl = null;
// Anchor-driven lane scratch buffers. Per-frame the loop builds up
// to HWY_LANE_TIME_SLICES segments, but consecutive slices that share
// an anchor (the common case) collapse into the same entry. Held as
// four parallel arrays so the per-frame work allocates nothing once
// the buffers reach their steady-state size.
const _laneSegDMin = [];
const _laneSegDMax = [];
const _laneSegZ0 = [];
const _laneSegZ1 = [];
/** Chart-time span per merged lane segment (for per-slice arpeggio rail tint). */
const _laneSegTLo = [];
const _laneSegTHi = [];
const _laneSegArp = [];
let _laneSegLen = 0;
let pChordBox, pChordFrameFill, pChordLbl, pBarreLine, pArpBracket, pPMXFill, pFHXFill;
let gPMXFill = null; // shared geometry for PM X fill — disposed in teardown
let gFHXFill = null; // shared geometry for FH X fill — disposed in teardown
let gPMXLines = null, pMuteXLines = null; // PM X lines combined geometry (8 segs as quads)
let gFHXLines = null, pFHXLines = null; // FH X lines combined geometry
let pNoteFretLabel, pConnectorLine, pDropLine, pTapChevron, pAccentHalo;
let pHaloBar = null, gHaloBar = null; // gradient halo bar geometry — replaces per-shell pChordAccentHalo
let gArpBracket = null; // shared 1×1×1 box geometry for pArpBracket; built once, disposed in teardown
let pSusRibbon = null, pSusRibbonOl = null;
let pFretColMarker;
/** Horizontal gradient for chord box interior fill. */
let chordFrameGradTex = null;
/** Lavender gradient for arpeggio box interior (cyan × lavender blend — fades back to cyan). */
let chordFrameGradTexArp = null;
// Dynamic glowing string meshes (BoxGeometry, one per string)
let stringLines = [];
// Static thin-line glow layer behind each string (one Line per
// string). Retained so _applyVibrancy() can mutate opacity in
// place — without this the layer stays at its built-in opacity
// until the next palette change rebuilds buildBoard().
let stringLineGlows = [];
// One MeshStandardMaterial per fret wire (index = fret 0..NFRETS).
// Updated each frame to gold when inside the active anchor range,
// gray otherwise. Reset to [] on every buildBoard() rebuild.
let fretWireMats = [];
// Shared bowed TubeGeometry for all fret wires (centered at x=0;
// each fret mesh only differs by position). Disposed on rebuild +
// teardown. See FRET_BOW_DZ constants.
let fretTubeGeo = null;
/** Nut + headstock 3D subtree; visibility toggled from settings without rebuild. */
let nutHeadstockGroup = null;
/** Left edge X of drawable string meshes; updated in buildBoard() at nut / fret junction. */
let boardStringStartX = fretX(0);
/** Open-string label column X — over headstock, left of nut (set in buildBoard()). */
let boardTuningLabelX = -4.2 * K;
// Fret inlay number label sprites (one per INLAY_LABEL_FRETS entry).
// Retained so update() can rescale them live when _textSizeMul changes.
let _inlayLabels = [];
// Cloned SpriteMaterials for the inlay labels — disposed on rebuild and
// destroy() to prevent GPU leaks across palette changes or panel reuse.
let _inlayMats = [];
// Open-string tuning labels beside the headstock (issue: per-song tuning).
let _tuningLabelSprites = [], _tuningLabelMats = [];
let _lastOpenStringLblSig = '';
// Cheap-key cache for _syncOpenStringPitchLabels: skip the expensive
// labels-array + signature-string build when the inputs that actually
// change the labels haven't changed reference/value since last frame.
let _lastSyncTuningRef = undefined;
let _lastSyncBundleTuningRef = undefined;
let _lastSyncCapo = NaN;
let _lastSyncArrIdx = undefined;
let _lastSyncPaletteRef = null;
let _lastSyncNStr = -1;
let _lastSyncTextSizeMul = NaN;
let _lastSyncStartX = NaN;
let _lastSyncLabelX = NaN;
// Scratch Color used by _applyVibrancy() to avoid allocating a
// fresh THREE.Color each time the user drags a slider.
// Allocated lazily once Three.js is loaded inside initScene().
let _paletteColorTmp = null;
// Per-fret last-active timestamp for lane persistence
let fretLastActiveTime = new Array(NFRETS + 1).fill(0);
// Active string count for the current arrangement (resolved each
// frame from bundle.stringCount and clamped to MAX_RENDER_STRINGS).
let nStr = NSTR;
// Set true once a chart with out-of-range s indices has triggered
// its warning. Reset only on teardown or when nStr changes (e.g.
// arrangement switch from guitar to bass) — same-nStr songs share
// the suppression, which is fine for what is purely a developer
// aid log.
let _oobStringWarned = false;
// Per-string bounds check used by every loop that indexes a
// per-string array (noteState.*, nextNoteByString, lastFretForString,
// mStr/mGlow/mSus, ...). Skipping out-of-range s upstream keeps
// sparse-array extension out of those arrays AND keeps drawNote's
// material lookup safe in one place.
function validString(s) {
const ok = Number.isInteger(s) && s >= 0 && s < nStr;
if (!ok && !_oobStringWarned) {
_oobStringWarned = true;
let msg = '[3D-Hwy] dropping notes with s out of range [0,' + nStr + ')';
if (nStr === S_COL.length) msg += ' (extended-range chart beyond palette size)';
console.warn(msg);
}
return ok;
}
// filter() allocates a new array per chord per frame, even though
// the vast majority of charts have no out-of-range strings. Scan
// first; only allocate when there's actually something to drop.
// The unfiltered array is reused as-is in the common case.
//
// Result is cached by ``ch.notes`` identity — call sites (chord
// render loop, camera pre-pass, strGlow / accent prepasses, cjNext
// peek) hit the same chord-notes array many times per frame, and
// the array contents are chart-static for the lifetime of the
// arrangement. The cache stores either the input array itself
// (common case) or the filtered copy, so the identity-preservation
// contract callers depend on is unchanged.
// NOTE: this cache (and _chordSigCache / _chordShapeCache below) keys on
// the notes/chord object but its result depends on validString() →
// nStr. If first computed while nStr is still the default 6 (an early
// frame before song_info applies stringCount), string-6+ notes get
// filtered out and would stay gone forever. The nStr-change handler
// resets all three via _resetStringDependentCaches() so extended-range
// (7+ string) charts recompute once the real string count arrives.
let _filterValidNotesCache = new WeakMap();
function filterValidNotes(notes) {
const cached = _filterValidNotesCache.get(notes);
if (cached !== undefined) return cached;
let filtered = notes;
for (let i = 0; i < notes.length; i++) {
if (!validString(notes[i].s)) {
filtered = notes.filter(cn => validString(cn.s));
break;
}
}
_filterValidNotesCache.set(notes, filtered);
return filtered;
}
/**
* Normalized fingering signature for chord repeat-run detection, or null.
* Cached via WeakMap so the sort+join only runs once per unique chord object
* across all frames — chart data never changes after load.
*/
let _chordSigCache = new WeakMap();
function chordShapeSignature(ch) {
if (!ch?.notes) return null;
if (_chordSigCache.has(ch)) return _chordSigCache.get(ch);
const chordNotes = filterValidNotes(ch.notes);
let sig = null;
if (chordNotes.length > 0) {
sig = chordNotes.slice().sort((a, b) => a.s - b.s).map(n => `${n.s}:${n.f}`).join('|');
}
_chordSigCache.set(ch, sig);
return sig;
}
// ── Per-frame scratch arrays (hoisted to avoid per-frame allocation) ─────
// Sized to MAX_RENDER_STRINGS / NFRETS+1 — always large enough for any
// arrangement. We fill only [0..nStr) each frame and reset with .fill().
// Holding these at closure scope keeps them in a GC root; the engine can
// keep them hot in L1/L2 across frames, and no allocation pressure from
// update() itself.
const _scrStringSustain = new Array(MAX_RENDER_STRINGS).fill(false);
const _scrStringAnticipation = new Array(MAX_RENDER_STRINGS).fill(0);
const _scrFretHeat = new Array(NFRETS + 1).fill(0);
const _scrStrGlow = new Array(MAX_RENDER_STRINGS).fill(0.5);
const _scrAccentFillBoost = new Array(MAX_RENDER_STRINGS).fill(0);
const _scrNextNoteByString = new Array(MAX_RENDER_STRINGS).fill(null);
const _scrLastFretForString = new Array(MAX_RENDER_STRINGS).fill(undefined);
// Scratch buffer for the recent-past-event prepass (~0.6 s back) — avoids
// re-allocating a per-string Array every frame. Re-filled with -Infinity
// at the top of each prepass run.
const _scrRecentByString = new Array(MAX_RENDER_STRINGS).fill(-Infinity);
// Scratch buffers for the ghost-preview gap prepass — refilled each
// frame to avoid the `new Array(nStr)` + `Object.create(null)` churn.
// The Map is cleared at the top of the prepass; live entries are
// consumed by drawNote() reads later in the same frame.
const _scrGhostLastT = new Array(MAX_RENDER_STRINGS).fill(-Infinity);
const _scrGhostPrevBuf = new Map();
// Per-string count of upcoming-ghost slots (1/2) claimed so far this
// frame (board ghost — up to 3 simultaneous previews per string).
// Reset to 0 each frame alongside the other pool .reset() calls.
const _scrGhostUpcomingCount = new Array(MAX_RENDER_STRINGS).fill(0);
// Hoisted scratch for the arp-bracket dedupe within a single draw().
// Keys are `${chordId}:${occurrenceStart}` strings (cheap to build, low
// cardinality per frame); values are Sets of string-indices that have
// already drawn brackets in the AHEAD note-stream pass. Cleared at the
// top of every chord pass so the Set objects (and the outer Map) are
// reused across frames instead of reallocated.
const _scrNoteStreamBracketStrings = new Map();
// Scratch object reused for chord-note drawNote calls so `{ ...cn, t: ch.t }`
// doesn't allocate a new object per chord note per frame.
const _scrChordNote = {};
// Scratch objects for the nextNoteByString prepass — chord notes need
// a merged `{ ...cn, t: ch.t }` object, but spread allocates every frame.
// One scratch object per string (max MAX_RENDER_STRINGS) is safe because:
// (a) the prepass writes each string's entry at most once per frame,
// (b) drawNote() reads nxFrame.t before the next frame's prepass can overwrite.
const _scrNextNoteByStringData = Array.from({ length: MAX_RENDER_STRINGS }, () => ({}));
// Reusable Set for arpeggio persistence key lookup — cleared each frame
// instead of reallocating a new Set.
const _scrArpPersistKeys = new Set();
// Reusable Set for active-fret cooldown tracking — cleared each frame.
const _scrActiveFrets = new Set();
// Reusable scratch for barre atMinFretStrings computation — avoids the
// [...chShape].filter().map().sort() chain (3 allocations per chord per frame).
const _scrAtMinFretArr = new Array(MAX_RENDER_STRINGS).fill(0);
let _scrAtMinFretLen = 0;
// Sorted scalar view of "next event time per string recent event
// time per string" — populated once per frame in update() after
// _drawNextByString and _drawRecentByString are set. drawNote() and
// the chord render loop both need "earliest event time strictly
// greater than t" to deadline-cap gem visibility; the previous
// implementation re-scanned both per-string arrays (2 * nStr
// lookups) per note/chord per frame, which is hot in dense
// PM/FH/arpeggio passages. With this scratch the same query is
// O(log N) over at most 2 * MAX_RENDER_STRINGS = 16 entries via
// _firstEventTimeGreaterThan(). Capacity is fixed (Float64Array)
// to keep the buffer in stable memory; _scrEventTimesLen tracks
// the live prefix.
const _scrEventTimes = new Float64Array(MAX_RENDER_STRINGS * 2);
let _scrEventTimesLen = 0;
function _firstEventTimeGreaterThan(t) {
let lo = 0, hi = _scrEventTimesLen;
while (lo < hi) {
const mid = (lo + hi) >>> 1;
if (_scrEventTimes[mid] <= t) lo = mid + 1;
else hi = mid;
}
return lo < _scrEventTimesLen ? _scrEventTimes[lo] : Infinity;
}
// Camera state
let _leftyCached = false;
const xFret = f => (_leftyCached ? -fretX(f) : fretX(f));
const xFretMid = f => (_leftyCached ? -fretMid(f) : fretMid(f));
const boardSpanX = () => {
const x0 = xFret(0);
const xN = xFret(NFRETS);
return {
min: Math.min(x0, xN),
max: Math.max(x0, xN),
center: (x0 + xN) / 2,
width: Math.abs(xN - x0),
};
};
let tgtX = xFretMid(CAM_LOCK_CENTER_FRET), curX = xFretMid(CAM_LOCK_CENTER_FRET);
let tgtDist = CAM_DIST_BASE, curDist = CAM_DIST_BASE;
// Last committed lowFretBonus contribution baked into tgtDist
// (see candidateDist block — bonus is applied on top of the
// hysteresis-gated base).
let prevLowFretBonus = 0;
// Tracks whether the camera lock was active on the previous
// frame, so the dynamic branch can bypass zoom hysteresis on
// the first frame after a lock release. Without this, a >12
// fret note that disengaged the lock could be swallowed by
// the dead zone and the camera would fail to widen — a UX
// promise of the lock toggle.
let prevLockActive = false;
let tgtLookY = 0, curLookY = 0; // lerped look-at Y for self-correcting camera
let aspectScale = 1;
// _camSnapped / _camPreScanned / _songKey: together they gate the first-data snap.
//
// On the first update() frame where bundle.notes is available,
// _camPreScanned is set and the full notes array is scanned (O(N), once)
// to check whether ANY fretted note (f > 0) exists. If none do (e.g. an
// all-open-string bass arrangement), _camSnapped is set to true immediately
// so the per-frame pre-pass is disabled for the entire song.
//
// For charts that do have fretted notes, a lightweight O(window) pre-pass
// runs before any drawNote() call on every frame until the first frame
// where fretted notes appear in the camera targeting window (preWSum > 0).
// At that point curX/curDist are snapped directly to the computed targets,
// eliminating the camera swoop for songs with long silent intros.
//
// Once _camSnapped is true it is never cleared for the current song; the
// pre-pass is a permanent no-op thereafter and the camera reverts to
// normal lerp-based tracking for the rest of the song.
//
// _songKey tracks the active song/arrangement so the snap state resets
// automatically when the user switches songs or arrangements via
// reconnect() (which does not call renderer.destroy/init).
let _camSnapped = false;
let _camPreScanned = false;
let _songKey = null;
// Smooth lookahead camera: fused world-X and displayed fret-span.
let _lookaheadCamX = xFretMid(CAM_LOCK_CENTER_FRET);
let _lookaheadFretSpan = DEFAULT_LOOKAHEAD_FRET_SPAN;
let _lookaheadCamPrevNow = null;
let _lookaheadLowBonusU = 0;
let _lookaheadHiNeckLatch = false;
// ── Sub-frame clock smoothing ─────────────────────────────────────
// bundle.currentTime is the browser's audio.currentTime, which only
// refreshes every ~2023 ms — coarser than a 60/144 Hz rAF frame. Fed
// straight into note Z-positions it makes the whole highway step in
// micro-jumps (12 static frames, then a jump), most visible as a
// "stutter" across a dense wall of repeated chords even when FPS is
// steady. smoothNow() interpolates forward with performance.now()
// between distinct audio samples (mirroring core highway.js
// getTime()), tracking the observed playback rate so the speed slider
// stays accurate, and falls back to the raw value on pause / seek /
// stall so the scroll never drifts against silent audio.
let _clkAudioT = NaN; // last distinct bundle.currentTime sample
let _clkPerf = NaN; // performance.now() when that sample arrived
let _clkRate = 1; // observed chart-seconds per real-second
let _frameNow = 0; // smoothed time for THIS frame (update → camUpdate)
// Low-overdraw sustain rendering (DEFAULT since perf profiling on
// dense palm-mute / fret-hand-mute passages). Those sections are GPU
// fill-bound: the transparent sustain trails/rails stack many blended
// fragments. Profiling (pinned A/B loop) showed ren.render() p50 at
// ~7.5 ms vs ~5.9 ms with all the sustain extras off. The additive
// rail bloom halo (wide gaussian planes, additive blending) is the
// single most expensive per-pixel contributor, so the lean default
// drops ONLY the bloom. The trail/ribbon white OUTLINE (mSusOutline,
// with hit/miss colour) is kept — it's a thin, cheap layer and gives
// tails their border, so it's worth the small fill cost. Opt back into
// the full look (re-enable the rail bloom) per browser, no rebuild:
// localStorage.h3d_full_sus = '1' // re-enable rail bloom halo
// delete localStorage.h3d_full_sus // back to lean default
// Read once per frame at the top of update() so the flag takes effect
// live. The bloom pool/material/gaussian texture are kept intact
// (still pinned by the bloom unit tests and used by the opt-out path).
let _leanSus = true;
// Lifecycle flags
let _isReady = false;
let _destroyed = false;
let _invertedCached = false;
let _invertedForBoard = false;
let _leftyForBoard = false;
let _initToken = 0;
let highwayCanvas = null;
// ── Focus state (splitscreen dim) ─────────────────────────────────
let _focusSubscribed = false;
let _isFocused = true;
const _onFocusChange = () => _updateFocusState();
function _unsubscribeFocus() {
if (!_focusSubscribed) return;
const ss = window.slopsmithSplitscreen;
if (ss && typeof ss.offFocusChange === 'function') ss.offFocusChange(_onFocusChange);
_focusSubscribed = false;
}
function _updateFocusState() {
if (_destroyed || !_isReady) return;
const focused = _ssIsCanvasFocused(highwayCanvas);
if (focused === _isFocused) return;
_isFocused = focused;
if (ambLight) ambLight.intensity = focused ? 0.85 : 0.4;
if (dirLight) dirLight.intensity = focused ? 0.8 : 0.35;
}
// ── String-to-Y (respects invert) ─────────────────────────────────
const sY = s => S_BASE + (_invertedCached ? s : (nStr - 1 - s)) * S_GAP;
// ── Text-sprite cache ──────────────────────────────────────────────
// ── Text-sprite style presets ─────────────────────────────────────
// Each preset describes how a class of label is rasterised.
// Tweak per-class look here (font, outline color/width, source
// canvas size). `wide` toggles a long aspect ratio for multi-char
// labels (chord/section names, "↑1/2", "~~~").
//
// Knobs:
// font — full CSS font shorthand (weight + size + family)
// wideFont — same, used when caller passes wide=true
// srcH — source-canvas height in px (square; wide=4×).
// Keep power-of-two so WebGL1 / Three.js retain
// mipmaps + linear-mip-linear filtering — NPOT
// textures silently fall back to no-mipmap and
// shimmer at distance.
// stroke — outline color (null = no outline)
// strokeW — outline line-width in source-canvas px
// shadow — { color, blur, dx, dy } or null
const TXT_STYLES = {
// The two fret-number sets the user wants to pop hardest.
fretRow: {
font: '900 160px "Arial Black", "Helvetica Neue", Arial, sans-serif',
wideFont: '900 128px "Arial Black", "Helvetica Neue", Arial, sans-serif',
srcH: 256, stroke: '#0a1018', strokeW: 18,
shadow: { color: 'rgba(0,0,0,0.7)', blur: 14, dx: 0, dy: 0 },
},
noteFret: {
font: '900 160px "Arial Black", "Helvetica Neue", Arial, sans-serif',
wideFont: '900 128px "Arial Black", "Helvetica Neue", Arial, sans-serif',
srcH: 256, stroke: '#0a1018', strokeW: 18,
shadow: { color: 'rgba(0,0,0,0.7)', blur: 14, dx: 0, dy: 0 },
},
// Ghost-fret labels on the board projection: same weight/size/outline
// as noteFret, but uses textAlign='center'; textBaseline='middle'
// (the standard branch in txtMat) so the glyph is truly centred on
// the PlaneGeometry UV. inkCenterFret's actualBoundingBox path is
// intentionally NOT activated for this style — that path was designed
// for Sprites and shifts the canvas origin, which causes visible
// lower-left drift on Mesh + MeshBasicMaterial (UV-direct mapping).
ghostFret: {
font: '900 160px "Arial Black", "Helvetica Neue", Arial, sans-serif',
wideFont: '900 128px "Arial Black", "Helvetica Neue", Arial, sans-serif',
srcH: 256, stroke: '#0a1018', strokeW: 18,
shadow: { color: 'rgba(0,0,0,0.7)', blur: 14, dx: 0, dy: 0 },
},
// Chord names — gold script-style label, lighter outline keeps
// the colour readable.
chord: {
font: 'bold 80px sans-serif',
wideFont: 'bold 64px sans-serif',
srcH: 128, stroke: '#0a1018', strokeW: 6, shadow: null,
},
// Section banners ("Verse", "Chorus") — same as chord weight.
section: {
font: 'bold 80px sans-serif',
wideFont: 'bold 64px sans-serif',
srcH: 128, stroke: '#0a1018', strokeW: 6, shadow: null,
},
// Technique markers (pinch-harmonic icon, PM, AC, H/P/T, etc.).
technique: {
font: 'bold 80px sans-serif',
wideFont: 'bold 64px sans-serif',
srcH: 128, stroke: '#0a1018', strokeW: 6, shadow: null,
},
// Open-string "0" label on the note body itself.
open: {
font: 'bold 80px sans-serif',
wideFont: 'bold 64px sans-serif',
srcH: 128, stroke: '#0a1018', strokeW: 6, shadow: null,
},
};
function txtMat(text, col, wide, style) {
const sName = style || 'technique';
const k = sName + '|' + (wide ? 'W' : '') + text + '|' + col;
if (txtCache[k]) return txtCache[k];
const sp = TXT_STYLES[sName] || TXT_STYLES.technique;
const h = sp.srcH;
const str = String(text);
const font = wide ? sp.wideFont : sp.font;
let w = wide ? h * 4 : h;
if (!wide && sName === 'noteFret') {
// Wide labels (D#2, Bb3) need a canvas wider than srcH; cap so
// glyphs stay centred at (w/2, h/2) without edge clipping.
const probe = document.createElement('canvas').getContext('2d');
probe.font = font;
const tw = probe.measureText(str).width;
let pad = 0;
if (sp.stroke && sp.strokeW > 0) pad += sp.strokeW * 2;
if (sp.shadow) {
pad += Math.abs(sp.shadow.dx) + sp.shadow.blur * 2;
}
w = Math.min(12 * h, Math.max(h, Math.ceil(tw + pad)));
}
const c = document.createElement('canvas');
c.width = w; c.height = h;
const x = c.getContext('2d');
x.font = font;
// Fret / open-string digits: anchor from actualBoundingBox so the
// glyph sits at the true optical centre of the canvas (fixes
// sprites looking off-centre inside the board ghost and elsewhere).
const inkCenterFret = !wide && (sName === 'noteFret' || sName === 'open');
// Ghost fret labels live on a PlaneGeometry Mesh (UV-direct), not a Sprite
// billboard. Sprites tolerate slight canvas off-centering because Three.js
// centres them at their world position; a Mesh does not — the digit lands
// wherever it sits in UV space. Use the advance-width centre as the initial
// pen position and then correct for any ink asymmetry via actualBoundingBox.
const inkCenterGhost = !wide && sName === 'ghostFret';
let drawX = w / 2;
let drawY = h / 2;
if (inkCenterFret) {
x.textAlign = 'left';
x.textBaseline = 'alphabetic';
const m = x.measureText(str);
const L = m.actualBoundingBoxLeft;
const R = m.actualBoundingBoxRight;
const A = m.actualBoundingBoxAscent;
const D = m.actualBoundingBoxDescent;
if (
L != null && R != null && A != null && D != null &&
Number.isFinite(L) && Number.isFinite(R) &&
Number.isFinite(A) && Number.isFinite(D)
) {
const inkW = R - L;
drawX = (w - inkW) / 2 - L;
drawY = (h + A - D) / 2;
// Tab digits sit visually a hair low vs bbox (stroke/shadow);
// small canvas nudge keeps sprites centred on the board ghost.
if (sName === 'noteFret') drawY -= h * 0.028;
} else {
x.textAlign = 'center';
x.textBaseline = 'middle';
drawX = w / 2;
drawY = h / 2;
}
} else if (inkCenterGhost) {
// Alpha-weighted centroid approach on FILL-ONLY ink (no shadow, no
// stroke) to find the true ink centre of mass without contamination
// from the isotropic shadow blur. For Arial Black "1" the shadow from
// the thin upper-left flag bleeds leftward and cancels part of the
// rightward correction when we include it in the scan. Measuring fill
// alone isolates the actual glyph shape.
// 1. Draw fill-only (no shadow, no stroke) at (w/2, h/2) on temp canvas.
// 2. Compute Σ(px·alpha) / Σ(alpha) → ink centroid.
// 3. Shift drawX/drawY so centroid lands exactly at (w/2, h/2).
// Max 4 unique digits (14) → cache-miss runs at most 4 times ever.
x.textAlign = 'center';
x.textBaseline = 'middle';
try {
const tmpC = document.createElement('canvas');
tmpC.width = w; tmpC.height = h;
const tc = tmpC.getContext('2d');
tc.font = font;
tc.textAlign = 'center';
tc.textBaseline = 'middle';
// Deliberately NO shadow and NO stroke — shadow spreads isotropically
// and muddles the centroid; fill alone gives the cleanest reading.
tc.fillStyle = '#ffffff';
tc.fillText(str, w / 2, h / 2);
const id = tc.getImageData(0, 0, w, h).data;
// Alpha-weighted centroid — heavier ink pixels (thick vertical stem
// of "1") outweigh thin/sparse pixels (diagonal flag), producing the
// correct perceptual centre rather than the geometric bbox midpoint.
let sumX = 0, sumY = 0, sumA = 0;
for (let py = 0; py < h; py++) {
for (let px = 0; px < w; px++) {
const a = id[(py * w + px) * 4 + 3];
if (a > 4) { sumX += px * a; sumY += py * a; sumA += a; }
}
}
if (sumA > 0) {
// shift pen so centroid → canvas centre, then add a small
// extra rightward nudge (8 %) so the vertical stroke of
// narrow digits like "1" sits visually at gem centre rather
// than the advance-width centre (which may be slightly left
// of the dominant ink mass for Arial Black numerals).
drawX = w / 2 + (w / 2 - sumX / sumA) + w * 0.08;
drawY = h / 2 + (h / 2 - sumY / sumA);
}
} catch (_) { /* fallback: draw at (w/2, h/2) */ }
// x (real canvas) still has textAlign='center'; textBaseline='middle'
} else {
x.textAlign = 'center';
x.textBaseline = 'middle';
}
if (sp.shadow) {
x.shadowColor = sp.shadow.color;
x.shadowBlur = sp.shadow.blur;
x.shadowOffsetX = sp.shadow.dx;
x.shadowOffsetY = sp.shadow.dy;
}
if (sp.stroke && sp.strokeW > 0) {
x.lineJoin = 'round';
x.miterLimit = 2;
x.strokeStyle = sp.stroke;
x.lineWidth = sp.strokeW;
x.strokeText(str, drawX, drawY);
}
x.fillStyle = col;
x.fillText(str, drawX, drawY);
const mat = new T.SpriteMaterial({
map: new T.CanvasTexture(c),
transparent: true,
// depthTest:false means later geometry never *fails* depth
// against these sprites, but without depthWrite:false the
// sprites still write to the depth buffer (Three.js default
// is depthWrite:true even for SpriteMaterial). That can
// make subsequent sprites/labels vanish — match the
// pattern used by the other sprite materials in this file.
depthTest: false,
depthWrite: false,
});
txtCache[k] = mat;
return mat;
}
function pinchHarmonicMat(col) {
const baseCol = new T.Color(col != null ? col : '#ffd84d');
// v5 — compact concentric ellipses:
// 1. black outer border rx=0.430h ry=0.255h
// 2. string-color body rx=0.418h ry=0.232h
// 3. black inner ring rx=0.407h ry=0.218h
// 4. string-color inner rx=0.264h ry=0.218h
// 5. black center dot rx=0.134h ry=0.120h
const k = 'technique|pinchHarmonicIcon|rs2014-v5b|' + baseCol.getHexString();
if (txtCache[k]) return txtCache[k];
const h = 512;
const c = document.createElement('canvas');
c.width = h; c.height = h;
const x = c.getContext('2d');
const TAU = Math.PI * 2;
const colStr = `rgb(${Math.round(baseCol.r * 255)},${Math.round(baseCol.g * 255)},${Math.round(baseCol.b * 255)})`;
x.clearRect(0, 0, h, h);
x.save();
x.translate(h / 2, h / 2);
// Form 1 — black outer border
x.fillStyle = '#000000';
x.beginPath(); x.ellipse(0, 0, h * 0.430, h * 0.255, 0, 0, TAU); x.fill();
// Form 2 — string-color main body
x.fillStyle = colStr;
x.beginPath(); x.ellipse(0, 0, h * 0.418, h * 0.232, 0, 0, TAU); x.fill();
// Form 3 — black inner ring
x.fillStyle = '#000000';
x.beginPath(); x.ellipse(0, 0, h * 0.407, h * 0.218, 0, 0, TAU); x.fill();
// Form 4 — string-color inner spot (narrower)
x.fillStyle = colStr;
x.beginPath(); x.ellipse(0, 0, h * 0.2637, h * 0.218, 0, 0, TAU); x.fill();
// Form 5 — black center dot
x.fillStyle = '#000000';
x.beginPath(); x.ellipse(0, 0, h * 0.134, h * 0.120, 0, 0, TAU); x.fill();
x.restore();
const mat = new T.SpriteMaterial({
map: new T.CanvasTexture(c),
transparent: true,
depthTest: false,
depthWrite: false,
});
txtCache[k] = mat;
return mat;
}
function naturalHarmonicMat() {
const k = 'technique|naturalHarmonicIcon|pink-ring-v3';
if (txtCache[k]) return txtCache[k];
const h = 256;
const c = document.createElement('canvas');
c.width = h; c.height = h;
const x = c.getContext('2d');
const cx = h / 2;
const cy = h / 2;
const TAU = Math.PI * 2;
x.clearRect(0, 0, h, h);
const glow = x.createRadialGradient(cx, cy, h * 0.03, cx, cy, h * 0.47);
glow.addColorStop(0, 'rgba(255,170,255,0.14)');
glow.addColorStop(0.55, 'rgba(0,0,0,0.22)');
glow.addColorStop(1, 'rgba(0,0,0,0)');
x.fillStyle = glow;
x.beginPath();
x.arc(cx, cy, h * 0.44, 0, TAU);
x.fill();
x.shadowColor = 'rgba(0,0,0,0.85)';
x.shadowBlur = 14;
x.fillStyle = 'rgba(255, 255, 255, 0.96)';
x.beginPath();
x.arc(cx, cy, h * 0.31, 0, TAU);
x.fill();
// Punch out the inner gap so the icon reads as a bright ring.
x.shadowBlur = 0;
x.globalCompositeOperation = 'destination-out';
x.beginPath();
x.arc(cx, cy, h * 0.20, 0, TAU);
x.fill();
x.globalCompositeOperation = 'source-over';
x.shadowColor = 'rgba(0, 0, 0, 0.7)';
x.shadowBlur = 10;
x.strokeStyle = 'rgba(255, 255, 255, 0.98)';
x.lineWidth = 8;
x.beginPath();
x.arc(cx, cy, h * 0.255, 0, TAU);
x.stroke();
x.shadowColor = 'rgba(0,0,0,0)';
x.fillStyle = 'rgba(255, 255, 255, 0.98)';
x.beginPath();
x.arc(cx, cy, h * 0.12, 0, TAU);
x.fill();
const mat = new T.SpriteMaterial({
map: new T.CanvasTexture(c),
transparent: true,
depthTest: false,
depthWrite: false,
opacity: 0.96,
});
txtCache[k] = mat;
return mat;
}
// Only two PM/FH variants exist (palm-mute = black-on-white,
// fret-hand mute = white-on-black). drawNote() hits muteXMat per
// muted chord-note per frame, so dense PM/FH passages were paying
// for a string concat + Map lookup on every call. Hoist both
// SpriteMaterial refs and short-circuit before touching the cache.
// They're populated lazily on first use; teardown still reaches
// them via the shared ``txtCache`` because muteXMat writes there.
let _pmXSpriteMat = null;
let _fhXSpriteMat = null;
function palmMuteXSpriteMat() {
return _pmXSpriteMat ?? (_pmXSpriteMat = muteXMat('#000000', '#ffffff'));
}
function fretHandMuteXSpriteMat() {
return _fhXSpriteMat ?? (_fhXSpriteMat = muteXMat('#ffffff', '#000000'));
}
function muteXMat(fillCol, strokeCol) {
const k = 'technique|muteX|v2|' + String(fillCol) + '|' + String(strokeCol);
if (txtCache[k]) return txtCache[k];
// lineCap:'square' gives flat tips. For a 45° diagonal the square-cap
// corners sit at ±outerW/2 rotated 45° from the endpoint — they land
// outside the canvas unless pad ≥ outerW/√2 (the common mistake is
// using outerW/2, which is too small). With the correct pad the white
// cap is fully inside the canvas and the border is visible at every tip.
const h = 512;
const outerW = 132, innerW = 114;
// pad must satisfy: pad ≥ outerW / Math.SQRT2 (≈ outerW × 0.707)
const pad = Math.ceil(outerW / Math.SQRT2) + 2; // 96
const c = document.createElement('canvas');
c.width = h; c.height = h;
const x = c.getContext('2d');
x.clearRect(0, 0, h, h);
x.lineCap = 'square';
// Draw each diagonal in its own stroke() call — caps of the two
// diagonals don't interact, and the white outer is drawn before the
// black inner so the border is clean at every edge and tip.
x.strokeStyle = strokeCol;
x.lineWidth = outerW;
x.beginPath(); x.moveTo(pad, pad); x.lineTo(h - pad, h - pad); x.stroke();
x.beginPath(); x.moveTo(h - pad, pad); x.lineTo(pad, h - pad); x.stroke();
x.strokeStyle = fillCol;
x.lineWidth = innerW;
x.beginPath(); x.moveTo(pad, pad); x.lineTo(h - pad, h - pad); x.stroke();
x.beginPath(); x.moveTo(h - pad, pad); x.lineTo(pad, h - pad); x.stroke();
const mat = new T.SpriteMaterial({
map: new T.CanvasTexture(c),
transparent: true,
depthTest: false,
depthWrite: false,
});
txtCache[k] = mat;
return mat;
}
// Technique-marker sprite materials (triangle / chevron). Keyed by a
// packed NUMBER, not a string — triMat/bendChevronMat are called from
// the drawNote hot path, so a string cache key would allocate per
// note per frame. Disposed in teardown. `hex` is a 0xRRGGBB number;
// the low nibble of the key tags the variant (0 ▲, 1 ▼, 3-6 chevron
// step-count) so triangle and chevron entries can't collide.
const _techMatCache = new Map();
// Hammer-on / pull-off triangle marker: a white ▲ (up) / ▼ (down)
// with a thick border in the gem's string colour.
function triMat(up, hex) {
const h = (hex >>> 0) & 0xffffff;
const key = h * 16 + (up ? 0 : 1);
const cached = _techMatCache.get(key);
if (cached) return cached;
const S = 256, m = S * 0.15;
const c = document.createElement('canvas');
c.width = c.height = S;
const g = c.getContext('2d');
g.beginPath();
if (up) { g.moveTo(S / 2, m); g.lineTo(S - m, S - m); g.lineTo(m, S - m); }
else { g.moveTo(S / 2, S - m); g.lineTo(S - m, m); g.lineTo(m, m); }
g.closePath();
g.lineJoin = 'round';
g.fillStyle = '#ffffff';
g.fill();
g.lineWidth = S * 0.122;
g.strokeStyle = '#' + (hex >>> 0).toString(16).padStart(6, '0');
g.stroke();
const mat = new T.SpriteMaterial({
map: new T.CanvasTexture(c), transparent: true,
depthTest: false, depthWrite: false,
});
_techMatCache.set(key, mat);
return mat;
}
// Strength-of-bend chevron stack: `steps` (1-4) chevrons in the gem's
// string colour (chart-format bend notation — 1 per half-step).
function bendChevronMat(steps, hex) {
const h = (hex >>> 0) & 0xffffff;
const key = h * 16 + 2 + steps; // steps 1-4 → low nibble 3-6
const cached = _techMatCache.get(key);
if (cached) return cached;
const S = 256;
const c = document.createElement('canvas');
c.width = c.height = S;
const g = c.getContext('2d');
g.strokeStyle = '#' + (hex >>> 0).toString(16).padStart(6, '0');
g.lineWidth = S * 0.10;
g.lineJoin = g.lineCap = 'round';
const padX = S * 0.18;
const rowH = S / steps;
const amp = Math.min(rowH * 0.55, S * 0.24);
for (let i = 0; i < steps; i++) {
const cy = (i + 0.5) * rowH;
g.beginPath();
g.moveTo(padX, cy + amp * 0.5);
g.lineTo(S / 2, cy - amp * 0.5);
g.lineTo(S - padX, cy + amp * 0.5);
g.stroke();
}
const mat = new T.SpriteMaterial({
map: new T.CanvasTexture(c), transparent: true,
depthTest: false, depthWrite: false,
});
_techMatCache.set(key, mat);
return mat;
}
// Darken a 0xRRGGBB colour by `factor` (0..1) for the slide-arrow
// marker — full string colour is too bright next to the gem.
function darkenHex(hex, factor) {
const h = (hex >>> 0) & 0xffffff;
const r = Math.round(((h >> 16) & 0xff) * factor);
const g = Math.round(((h >> 8) & 0xff) * factor);
const b = Math.round((h & 0xff) * factor);
return (r << 16) | (g << 8) | b;
}
// Slide-direction arrow (/): a filled triangle pointing toward the
// slide's destination fret, in the gem's (darkened) string colour.
// `hex` here is already the darkened colour — keep its own cache-key
// nibble range (8/9) so it can't collide with triMat (0/1) or
// bendChevronMat (3-6).
function slideArrowMat(pointRight, hex) {
const h = (hex >>> 0) & 0xffffff;
const key = h * 16 + 8 + (pointRight ? 0 : 1);
const cached = _techMatCache.get(key);
if (cached) return cached;
const S = 256, m = S * 0.18;
const c = document.createElement('canvas');
c.width = c.height = S;
const g = c.getContext('2d');
g.beginPath();
if (pointRight) { g.moveTo(S - m, S / 2); g.lineTo(m, m); g.lineTo(m, S - m); }
else { g.moveTo(m, S / 2); g.lineTo(S - m, m); g.lineTo(S - m, S - m); }
g.closePath();
g.fillStyle = '#' + h.toString(16).padStart(6, '0');
g.fill();
const mat = new T.SpriteMaterial({
map: new T.CanvasTexture(c), transparent: true,
depthTest: false, depthWrite: false,
});
_techMatCache.set(key, mat);
return mat;
}
function _meshMatForGhostFretDigit(spriteMat) {
let mb = spriteMat.userData.h3dGhostFretMeshMat;
if (!mb) {
mb = new T.MeshBasicMaterial({
map: spriteMat.map,
transparent: true,
depthTest: false,
depthWrite: false,
});
spriteMat.userData.h3dGhostFretMeshMat = mb;
}
return mb;
}
/**
* Convert any SpriteMaterial to a MeshBasicMaterial that shares its canvas
* texture, so technique markers can be applied to a rotatable PlaneGeometry
* mesh instead of a billboard Sprite. Cached on userData to avoid allocations.
*
* The cache is multi-entry: each pTechPlane mesh holds a Map<sm.map,
* clone> so a recycled mesh that's used for several techniques
* (hammer-on, palm-mute, harmonic, bend...) across frames keeps a
* clone for each one rather than disposing-and-recloning on every
* switch. With nStr-wide chords containing mixed PM/FH/HO/HP
* markers this collapses the per-frame allocation entirely while
* still being bounded — the per-mesh Map has at most one entry per
* distinct technique × colour the mesh has ever been used for.
*/
function _spriteMat2MeshMat(mesh, sm) {
let perMesh = mesh.userData.h3dTechMeshMatCloneByMap;
if (perMesh) {
const hit = perMesh.get(sm.map);
if (hit) return hit;
}
let base = sm.userData.h3dTechMeshMat;
if (!base) {
base = new T.MeshBasicMaterial({
map: sm.map,
transparent: true,
// depthTest: false — cross-note Z ordering is handled by
// per-note renderOrderForLayerAtZ(...) calls rather than the
// depth buffer. This is necessary because close notes often use
// mGlow (depthWrite:false), so the depth buffer can't reliably
// occlude far markers near the hit line. With per-note renderOrder,
// far labels render first and close note geometry renders last,
// appearing on top without depthTest.
depthTest: false,
depthWrite: false,
side: T.DoubleSide,
});
sm.userData.h3dTechMeshMat = base;
}
// First conversion for this mesh: the pTechPlane pool factory gave
// it a placeholder MeshBasicMaterial that the caller is about to
// overwrite with the clone below. Dispose it now — once
// mesh.material is reassigned the placeholder is orphaned and
// teardown's scene.traverse() pass can no longer reach it, so it
// would leak one GPU material per pooled mesh for the renderer's
// lifetime.
if (!perMesh && mesh.material && mesh.material !== base) {
mesh.material.dispose?.();
}
if (!perMesh) {
perMesh = new Map();
mesh.userData.h3dTechMeshMatCloneByMap = perMesh;
}
const clone = base.clone();
perMesh.set(sm.map, clone);
_techMeshMatClones.add(clone);
return clone;
}
function _disposeOpenStringPitchSprites() {
// Tuning-label materials are clones of cached txtMat() entries, so
// they share the .map (CanvasTexture) with the canonical txtCache
// material. Disposing the map here would invalidate every other
// material that references the same cached glyph; teardown()'s
// txtCache loop is the single owner of those textures.
for (const m of _tuningLabelMats) {
try { m.dispose(); } catch (_) { /* idempotent */ }
}
_tuningLabelMats = [];
_tuningLabelSprites = [];
_lastOpenStringLblSig = '';
if (!tuningLblG) return;
while (tuningLblG.children.length) tuningLblG.remove(tuningLblG.children[0]);
}
function _openStringLabelSignature(bundle, labels) {
const si = bundle && bundle.songInfo;
const tun = si && si.tuning;
let tStr = '';
if (Array.isArray(tun)) tStr = tun.slice(0, labels.length).join(',');
else if (bundle && Array.isArray(bundle.tuning)) tStr = bundle.tuning.slice(0, labels.length).join(',');
const capo =
si && Number.isFinite(si.capo) ? si.capo
: (bundle && Number.isFinite(bundle.capo) ? bundle.capo : '');
const arrIdx = si && si.arrangement_index != null ? si.arrangement_index : '';
let palSig = '';
const nLab = labels.length;
if (activePalette) {
// activePalette entries are numeric hex (PALETTES) or already hex strings;
// convert without instantiating T.Color per string — this signature is
// built every frame inside _syncOpenStringPitchLabels.
const lim = Math.min(activePalette.length, nLab);
for (let i = 0; i < lim; i++) {
if (i > 0) palSig += '/';
const c = activePalette[i];
palSig += typeof c === 'number' ? (c >>> 0).toString(16) : String(c);
}
}
return `${nStr}|${capo}|${tStr}|${arrIdx}|${labels.join(',')}|${palSig}|${_textSizeMul.toFixed(3)}|${boardStringStartX.toFixed(6)}|${boardTuningLabelX.toFixed(6)}`;
}
function _syncOpenStringPitchLabels(bundle) {
if (!tuningLblG || !T || !bundle) return;
if (!tuningLabelsVisible) {
tuningLblG.visible = false;
if (_tuningLabelSprites.length) _disposeOpenStringPitchSprites();
_lastOpenStringLblSig = '';
return;
}
tuningLblG.visible = true;
// Cheap-key fast path: compare the inputs that drive the label content
// against last frame. The signature string + labels array build are
// both per-frame allocators, so skipping them when nothing changed
// saves a chunk of GC pressure in the hot render loop.
const si = bundle.songInfo;
const tunRef = (si && Array.isArray(si.tuning)) ? si.tuning : null;
const bundleTunRef = Array.isArray(bundle.tuning) ? bundle.tuning : null;
const capo =
si && Number.isFinite(si.capo) ? si.capo
: (Number.isFinite(bundle.capo) ? bundle.capo : NaN);
const arrIdx = si && si.arrangement_index != null ? si.arrangement_index : undefined;
if (
_tuningLabelSprites.length === nStr &&
_lastSyncTuningRef === tunRef &&
_lastSyncBundleTuningRef === bundleTunRef &&
Object.is(_lastSyncCapo, capo) &&
_lastSyncArrIdx === arrIdx &&
_lastSyncPaletteRef === activePalette &&
_lastSyncNStr === nStr &&
_lastSyncTextSizeMul === _textSizeMul &&
_lastSyncStartX === boardStringStartX &&
_lastSyncLabelX === boardTuningLabelX
) return;
// One of the inputs changed — fall through to the canonical signature
// check (catches value-equal-but-different-ref tuning arrays).
const labels = _openStringPitchLabelsForTuning(bundle, si, nStr);
const sig = _openStringLabelSignature(bundle, labels);
// Refresh cheap-key cache regardless of signature outcome so future
// frames can fast-path even when the sig matched.
_lastSyncTuningRef = tunRef;
_lastSyncBundleTuningRef = bundleTunRef;
_lastSyncCapo = capo;
_lastSyncArrIdx = arrIdx;
_lastSyncPaletteRef = activePalette;
_lastSyncNStr = nStr;
_lastSyncTextSizeMul = _textSizeMul;
_lastSyncStartX = boardStringStartX;
_lastSyncLabelX = boardTuningLabelX;
if (sig === _lastOpenStringLblSig && _tuningLabelSprites.length === nStr) return;
_disposeOpenStringPitchSprites();
_lastOpenStringLblSig = sig;
// Left of nut/cordas — centered on headstock mass so text does not sit on the strings.
const labelX = boardTuningLabelX;
const zLabel = -0.08 * K;
const scalePx = 2.42 * _textSizeMul * K;
for (let s = 0; s < nStr; s++) {
const hex = '#' + new T.Color(activePalette[s % activePalette.length]).getHexString();
const mat = txtMat(labels[s] || '?', hex, false, 'noteFret').clone();
mat.depthTest = false;
mat.depthWrite = false;
mat.transparent = true;
const sp = new T.Sprite(mat);
sp.center.set(0, 0.5);
sp.scale.set(scalePx, scalePx, 1);
sp.position.set(labelX, sY(s), zLabel);
sp.renderOrder = 8;
tuningLblG.add(sp);
_tuningLabelSprites.push(sp);
_tuningLabelMats.push(mat);
}
}
// ── Object pool ────────────────────────────────────────────────────
// ── Opt-in perf bench harness (slopsmith#226) ──────────────────────
// Enable with `?h3dbench=1` on the player URL. Aggregates per-segment
// timings of update() into a console.log every _PB_REPORT_MS.
//
// When the bench is OFF, pbBeg/pbEnd/pbReportTick are bound to a
// single shared empty function literal when this renderer
// instance is created (createHighway() runs once per panel, not
// once per module load) — V8 typically inlines empty bodies and
// the call sites have minimized overhead in the hot path.
// (Previously they had `if (!_perfBench) return;` guards, which
// still cost a function-call frame per mark site per frame;
// Copilot review on #413.) Inlining is a JIT heuristic, not a
// language guarantee.
const _perfBench = (() => {
try { return new URLSearchParams(location.search).get('h3dbench') === '1'; }
catch (_) { return false; }
})();
let pbBeg, pbEnd, pbReportTick;
if (_perfBench) {
const _PB_NAMES = ['frame', 'state', 'next', 'mat', 'noteDraw', 'chordDraw', 'render'];
const _pbStart = new Float64Array(_PB_NAMES.length);
const _pbAcc = _PB_NAMES.map(() => []);
const _PB_REPORT_MS = 5000;
let _pbReportStart = 0;
let _pbFrameCount = 0;
pbBeg = function pbBeg(idx) { _pbStart[idx] = performance.now(); };
pbEnd = function pbEnd(idx) {
_pbAcc[idx].push(performance.now() - _pbStart[idx]);
};
pbReportTick = function pbReportTick() {
const now = performance.now();
if (_pbReportStart === 0) {
// First call: discard the sample(s) that already
// landed in _pbAcc from the very first frame's
// pbEnd() calls, so fps and segment stats span the
// same frame set on every reported window.
_pbReportStart = now;
_pbFrameCount = 0;
for (let i = 0; i < _PB_NAMES.length; i++) _pbAcc[i].length = 0;
return;
}
_pbFrameCount++;
if (now - _pbReportStart < _PB_REPORT_MS) return;
const dur = now - _pbReportStart;
const fps = (_pbFrameCount / dur * 1000).toFixed(1);
const parts = [];
for (let i = 0; i < _PB_NAMES.length; i++) {
const arr = _pbAcc[i];
if (!arr.length) { parts.push(`${_PB_NAMES[i]}=-`); continue; }
arr.sort((a, b) => a - b);
const n = arr.length;
// Nearest-rank: ceil(p · n) - 1, clamped to [0, n-1].
// Avoids the off-by-one where Math.floor(n * 0.95)
// returns the last element (effectively the max)
// for small samples (e.g. n=20 → idx 19).
const p50 = arr[Math.max(0, Math.ceil(0.50 * n) - 1)];
const p95 = arr[Math.max(0, Math.ceil(0.95 * n) - 1)];
const mx = arr[n - 1];
parts.push(`${_PB_NAMES[i]} p50=${p50.toFixed(2)} p95=${p95.toFixed(2)} max=${mx.toFixed(2)}`);
arr.length = 0;
}
console.log(`[h3dbench] ${fps}fps (${_pbFrameCount} frames) over ${(dur/1000).toFixed(1)}s — ${parts.join(' | ')}`);
_pbReportStart = now;
_pbFrameCount = 0;
};
} else {
pbBeg = pbEnd = pbReportTick = function () {};
}
function pool(parent, mk) {
const a = [];
let n = 0;
return {
get() {
if (n < a.length) {
const o = a[n++];
o.visible = true;
if (o.center && o.center.isVector2) o.center.set(0.5, 0.5);
return o;
}
const o = mk(); parent.add(o); a.push(o); n++; return o;
},
reset() { for (let i = 0; i < n; i++) a[i].visible = false; n = 0; },
// Pre-allocate `cap` slots at construction so the first dense
// playback frames don't pay the new-Mesh allocation cost
// mid-RAF (felt as a stall on 7/8-string charts where the
// visible-note count outruns the lazy-grow path). Lazy growth
// past `cap` still works — this is amortisation, not a cap.
//
// Coerce `cap` to a non-negative int32: a float would still
// work but a callsite passing `Infinity` (or `NaN`) would
// otherwise spin the while-loop until OOM. `cap | 0`
// truncates floats, clamps Infinity → 0, and turns NaN → 0;
// Math.max(0, …) keeps negatives out.
warm(cap) {
// Local rename to avoid shadowing the pool's outer
// `n` (the in-use index advanced by get() / reset()).
const targetLen = Math.max(0, cap | 0);
while (a.length < targetLen) { const o = mk(); o.visible = false; parent.add(o); a.push(o); }
return this;
},
};
}
// Returns indices of the longest consecutive run in a sorted integer
// array as { start, len } — `sorted[start..start+len)` is the run.
// Avoids the two per-call sub-array allocations of the previous
// implementation (best + cur arrays grown via .push), at the cost
// of one small 2-key result object. Net: callers in the chord-
// diagram render path no longer churn arrays per visible chord.
function longestConsecutiveRun(sorted) {
let bestStart = -1, bestLen = 0;
let curStart = -1, curLen = 0;
for (let i = 0; i < sorted.length; i++) {
if (curLen === 0 || sorted[i] === sorted[curStart + curLen - 1] + 1) {
if (curLen === 0) curStart = i;
curLen++;
} else {
if (curLen > bestLen) { bestLen = curLen; bestStart = curStart; }
curStart = i; curLen = 1;
}
}
if (curLen > bestLen) { bestLen = curLen; bestStart = curStart; }
return { start: bestStart, len: bestLen };
}
/* ── Lyrics overlay (2D canvas on top of WebGL) ─────────────────── */
function drawChordDiagram(ctx, opts) {
const {
name, frets,
opacity = 1,
entranceT = 1.0,
canvasW = 600, canvasH = 400,
inverted = false,
sizeSlider = 0.5,
position = 'tl',
nStr = 6,
lyricsBottom = 0,
stackOffset = 0,
} = opts;
// Responsive sizing — CELL derived from panel height + user slider.
// COLS is the resolved string count from the caller (via resolveStringCount)
// so bass (4), extended (7/8) arrangements render correctly.
const COLS = nStr, ROWS = 4;
// Minimum column span required for PATH B (bracket extension / detection).
// Math.min(COLS-1, 4) scales with string count:
// 4-string bass → 3 (max possible span, so 2-4-4-2 shapes qualify)
// 6-string → 4 (excludes D major span=2 / common 2-string coincidences)
// 8-string → 4 (muted outer strings still leave span ≥ 4 for real barres)
const MIN_BARRE_SPAN = Math.min(COLS - 1, 4);
// Maps diagram column index → chord-template frets-array index.
// Templates are high-e-first: frets[0]=high e, frets[COLS-1]=low E.
// Non-inverted display (col 0 = high e): getStrIdx(0) = 0 → frets[0] = high e.
// Inverted display (col 0 = low E): getStrIdx(0) = COLS-1 → frets[COLS-1] = low E.
const getStrIdx = col => inverted ? (COLS - 1 - col) : col;
const sizeF = DIAG_SIZE_MIN + (DIAG_SIZE_MAX - DIAG_SIZE_MIN) * sizeSlider;
// startFret / isFirstPos must be known before CELL so that fretLabelW
// can be measured and factored into the width cap. The old
// canvasW/(COLS+1.5) guard only approximated 2*PAD and ignored the
// extra left padding reserved for non-first-position "Nfr" labels.
const playedFrets = frets.filter(f => f > 0);
const minFret = playedFrets.length > 0 ? Math.min(...playedFrets) : 1;
const startFret = Math.max(1, minFret);
const isFirstPos = startFret === 1;
// Phase 1 — height + hard-cap estimate, used only to size the label font.
// Cap against the vertical space available below lyricsBottom so that the
// diagram does not overflow into the lyrics banner on short split panels with
// wrapped lyric rows. Only top-corner positions can overlap the lyrics banner,
// so lyricsBottom is only subtracted when position is 'tl' or 'tr'; for 'bl'
// and 'br' the full canvas height is available.
// Clamp to at least 1 so font/box calculations never receive 0-px input
// on very short panels (e.g. tiny split cells < 44 px tall).
const isTopCorner = position === 'tl' || position === 'tr';
const availH = canvasH - (isTopCorner ? lyricsBottom : 0);
const cellEst = Math.max(1, Math.min(
Math.round(availH * sizeF / (ROWS + 3)),
DIAG_CELL_MAX,
));
// Extra left padding for the "Nfr" label on non-first-position chords.
// Measured with ctx.measureText at cellEst so the estimate is exact.
let fretLabelW = 0;
if (!isFirstPos) {
// Measure inside a save/restore so this font assignment does not
// leak to the caller (the outer ctx.save() happens after CELL is derived).
ctx.save();
ctx.font = `italic ${Math.round(cellEst * 0.55)}px sans-serif`;
fretLabelW = Math.ceil(ctx.measureText(startFret + 'fr').width) + 6;
ctx.restore();
}
// Phase 2 — final CELL: cap against panel height, hard max, and panel width.
// Two width constraints are needed because PAD has a hard floor of 6:
// A) when PAD = CELL*0.65 (large CELL): CELL*(COLS+0.3) + fretLabelW ≤ canvasW
// B) when PAD = 6 floor (small CELL): CELL*(COLS-1) + 12 + fretLabelW ≤ canvasW
// Both are included so boxW ≤ canvasW in every regime.
// fretLabelW was measured at cellEst ≥ CELL, so the cap is conservative.
const CELL = Math.max(1, Math.min(
cellEst,
Math.floor((canvasW - fretLabelW) / (COLS + 0.3)),
Math.floor((canvasW - 2 * 6 - fretLabelW) / Math.max(1, COLS - 1)),
));
const HEADER = Math.round(CELL * 1.6);
const MARKER = Math.round(CELL * 0.7);
const DOT_R = CELL * 0.3;
const PAD = Math.max(6, Math.round(CELL * 0.65));
const gridW = CELL * (COLS - 1);
const gridH = CELL * ROWS;
const PAD_L = PAD + fretLabelW;
const boxW = gridW + PAD_L + PAD;
const boxH = HEADER + MARKER + gridH + PAD;
// Anchor to chosen corner. Top positions get extra vertical offset
// to clear the timeline plugin and song name displayed at the top.
// lyricsBottom is the actual bottom Y of the lyrics banner (returned by
// drawLyrics), so TOP_Y steps down past all lyric rows regardless of
// how many wrap lines the current panel width produces.
const E = PAD;
const TOP_Y = Math.round(Math.max(E + canvasH * 0.06, lyricsBottom + E));
let bx, by;
if (position === 'tr') { bx = canvasW - boxW - E; by = TOP_Y + stackOffset; }
else if (position === 'bl') { bx = E; by = canvasH - boxH - E - stackOffset; }
else if (position === 'br') { bx = canvasW - boxW - E; by = canvasH - boxH - E - stackOffset; }
else { bx = E; by = TOP_Y + stackOffset; }
// Clamp so the box never bleeds off-canvas on narrow panels or wide string counts.
bx = Math.max(0, Math.min(canvasW - boxW, bx));
by = Math.max(0, Math.min(canvasH - boxH, by));
// Guard: the canvasHboxH clamp above can push `by` above lyricsBottom when
// wrapped lyrics consume nearly the full panel height. This applies to ALL
// corner positions: a bottom-corner diagram anchored near the canvas bottom can
// still reach up into the lyrics banner on very short or narrow panels where
// boxH is larger than the space below the lyrics. In those cases skip drawing
// entirely rather than painting on top of the lyrics banner.
if (lyricsBottom > 0 && by < lyricsBottom) return 0;
const gx = bx + PAD_L, gy = by + HEADER + MARKER;
// Ease-out quadratic entrance scale: 0.85 → 1.0.
const scale = 1 - 0.15 * (1 - entranceT) * (1 - entranceT);
ctx.save();
ctx.globalAlpha = opacity;
if (scale !== 1.0) {
const cx = bx + boxW / 2, cy = by + boxH / 2;
ctx.translate(cx, cy);
ctx.scale(scale, scale);
ctx.translate(-cx, -cy);
}
// Background + border.
ctx.fillStyle = 'rgba(8, 14, 22, 0.88)';
ctx.beginPath(); ctx.roundRect(bx, by, boxW, boxH, 7); ctx.fill();
ctx.strokeStyle = 'rgba(255,255,255,0.15)'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.roundRect(bx, by, boxW, boxH, 7); ctx.stroke();
// Split-root typography: "Dm7" → "D" large bold + "m7" smaller.
const rootMatch = name.match(/^([A-G][#b]?)(.*)/);
const root = rootMatch ? rootMatch[1] : name;
const quality = rootMatch ? rootMatch[2] : '';
const rootSize = Math.round(CELL * 1.25);
const qualSize = Math.round(rootSize * 0.65);
ctx.textBaseline = 'middle';
const nameY = by + HEADER * 0.55;
ctx.font = `bold ${rootSize}px sans-serif`;
const rootW = ctx.measureText(root).width;
ctx.font = `${qualSize}px sans-serif`;
const qualW = quality ? ctx.measureText(quality).width : 0;
const nameBlockW = rootW + (quality ? qualW + 2 : 0);
const nameStartX = bx + boxW / 2 - nameBlockW / 2;
ctx.fillStyle = '#e8d080';
ctx.font = `bold ${rootSize}px sans-serif`;
ctx.textAlign = 'left';
ctx.fillText(root, nameStartX, nameY);
if (quality) {
ctx.font = `${qualSize}px sans-serif`;
ctx.fillStyle = 'rgba(232,208,128,0.75)';
ctx.fillText(quality, nameStartX + rootW + 2, nameY);
}
// Nut: CELL-proportional filled rect + subtle highlight line.
// Thickness is 40% of CELL, floored at 2 px so it stays visible on
// the smallest diagrams (CELL=1 on compact split panels).
const NUT_H = Math.round(Math.max(2, CELL * 0.4));
if (isFirstPos) {
ctx.fillStyle = '#ffffff';
ctx.fillRect(gx, gy - NUT_H, gridW, NUT_H);
ctx.fillStyle = 'rgba(255,255,255,0.4)';
ctx.fillRect(gx, gy - NUT_H, gridW, Math.max(1, Math.round(NUT_H * 0.25)));
}
// Fret label for non-first-position chords.
if (!isFirstPos) {
ctx.fillStyle = 'rgba(220,200,120,0.9)';
ctx.font = `italic ${Math.round(CELL * 0.55)}px sans-serif`;
ctx.textAlign = 'right';
ctx.textBaseline = 'middle';
ctx.fillText(startFret + 'fr', gx - 4, gy + CELL * 0.5);
}
// Fret lines.
ctx.strokeStyle = 'rgba(255,255,255,0.22)'; ctx.lineWidth = 1;
for (let r = (isFirstPos ? 1 : 0); r <= ROWS; r++) {
ctx.beginPath();
ctx.moveTo(gx, gy + r * CELL);
ctx.lineTo(gx + gridW, gy + r * CELL);
ctx.stroke();
}
// String lines with varying weight: low E heavier, high e lighter.
// With getStrIdx(col) = col (non-inverted): col 0 (high e) → strIdx=0 → t=0 thin;
// col COLS-1 (low E) → strIdx=COLS-1 → t=1 thick. Inverted mode naturally mirrors.
// Weights scale with CELL so strings never bleed into adjacent columns on
// small-CELL diagrams (e.g. CELL=1 on compact split panels).
for (let col = 0; col < COLS; col++) {
const strIdx = getStrIdx(col);
const t = COLS > 1 ? strIdx / (COLS - 1) : 1; // 1=low E (thick), 0=high e (thin); guard COLS=1
ctx.lineWidth = Math.max(0.5, CELL * (0.05 + t * 0.10));
ctx.strokeStyle = 'rgba(255,255,255,0.3)';
ctx.beginPath();
ctx.moveTo(gx + col * CELL, gy);
ctx.lineTo(gx + col * CELL, gy + ROWS * CELL);
ctx.stroke();
}
// Barre detection — two complementary paths:
//
// PATH A (F-shape / mini-barre): at least two ADJACENT columns are at startFret.
// Bracket is initially set to the consecutive run's own endpoints (not the full
// startFretCols range) so isolated bass notes at the same fret can't pull the
// bracket across an open gap (e.g. "2 0 2 2 0 0" stays bracketed at cols 2..3).
//
// PATH B (full-span barre / extension):
// When PATH A fired: extend the bracket outward to the full outer startFret span
// if the span ≥ MIN_BARRE_SPAN and every column between the outer startFret
// columns is fretted (f > 0).
// When PATH A did NOT fire: detect standalone full barres (e.g. x24442, x46654)
// where only the two outermost strings sit at startFret. An additional check
// ensures that no intermediate column is itself at startFret — this rules out
// alternating-fret voicings like "1 3 1 3 1 0" (col 2 at startFret would fire
// incorrectly) while still catching B-major-style shapes where the barre
// finger covers only the outer two strings.
//
// Templates are high-e-first: frets[0]=high e, frets[COLS-1]=low E.
// Examples (6-string, MIN_BARRE_SPAN=4):
// F major [1,1,2,3,3,1]: PATH A run=[4,5] → bracket 4..5; PATH B span=5, all fretted → extends to 0..5 ✓
// B major x24442: PATH A no run; PATH B span=4, all fretted, no inner at startFret → 1..5 ✓
// mini-A x02220: PATH A run=[2,3,4] → bracket 2..4; PATH B span=2<4 → no extension ✓
// D major xx0232: PATH A run length=1 → no PATH A; PATH B span<4 → no bracket ✓
// 2 0 2 2 0 0: PATH A run=[2,3] → bracket 2..3; PATH B span=3<4 → no extension ✓
// 1 3 1 3 1 0: PATH A no run; PATH B: inner col 2 at startFret → no bracket ✓
const startFretCols = [];
for (let col = 0; col < COLS; col++) {
if (frets[getStrIdx(col)] === startFret) startFretCols.push(col);
}
const barreRun = longestConsecutiveRun(startFretCols);
let hasBarreArc = barreRun.len >= 2; // PATH A
let barreMinCol = hasBarreArc ? startFretCols[barreRun.start] : -1;
let barreMaxCol = hasBarreArc ? startFretCols[barreRun.start + barreRun.len - 1] : -1;
if (startFretCols.length >= 2) { // PATH B
const minC = startFretCols[0];
const maxC = startFretCols[startFretCols.length - 1];
if (maxC - minC >= MIN_BARRE_SPAN) {
let allFretted = true;
for (let col = minC; col <= maxC; col++) {
if (frets[getStrIdx(col)] <= 0) { allFretted = false; break; }
}
if (allFretted) {
if (hasBarreArc) {
// PATH A fired: always safe to extend to full outer span.
barreMinCol = minC;
barreMaxCol = maxC;
} else {
// PATH A did not fire: only draw a bracket when no intermediate
// column sits at startFret. Intermediate startFret columns would
// indicate a scattered/alternating voicing rather than a clean
// outer-edge barre (e.g. "1 3 1 3 1 0" has col 2 at startFret).
let noInnerAtStartFret = true;
for (let col = minC + 1; col < maxC; col++) {
if (frets[getStrIdx(col)] === startFret) { noInnerAtStartFret = false; break; }
}
if (noInnerAtStartFret) {
hasBarreArc = true;
barreMinCol = minC;
barreMaxCol = maxC;
}
}
}
}
}
if (hasBarreArc) {
const barreY = gy + CELL * 0.5;
const capH = CELL * 0.22; // vertical offset from barreY to the bracket line
const capHalf = Math.max(1, Math.round(CELL * 0.3)); // half-height of the vertical end caps
// Straight bracket: a horizontal line with short vertical end caps.
// Stroke scales with CELL so it doesn't swamp tiny cells (floor at 1 px).
ctx.strokeStyle = 'rgba(255,255,255,0.6)'; ctx.lineWidth = Math.max(1, CELL * 0.2);
ctx.beginPath();
ctx.moveTo(gx + barreMinCol * CELL, barreY - capH);
ctx.lineTo(gx + barreMaxCol * CELL, barreY - capH);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(gx + barreMinCol * CELL, barreY - capH - capHalf);
ctx.lineTo(gx + barreMinCol * CELL, barreY - capH + capHalf);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(gx + barreMaxCol * CELL, barreY - capH - capHalf);
ctx.lineTo(gx + barreMaxCol * CELL, barreY - capH + capHalf);
ctx.stroke();
}
// Open/muted markers + finger dots.
// Non-inverted: col 0 = high e → getStrIdx(0)=0 → frets[0]; col COLS-1 = low E → frets[COLS-1].
// Inverted: col 0 = low E → getStrIdx(0)=COLS-1 → frets[COLS-1]; col COLS-1 = high e → frets[0].
for (let col = 0; col < COLS; col++) {
const f = frets[getStrIdx(col)];
const sx = gx + col * CELL;
const markerY = gy - MARKER * 0.5;
if (f < 0) {
const r = CELL * 0.20;
ctx.strokeStyle = '#cc4444'; ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.moveTo(sx - r, markerY - r); ctx.lineTo(sx + r, markerY + r); ctx.stroke();
ctx.beginPath(); ctx.moveTo(sx + r, markerY - r); ctx.lineTo(sx - r, markerY + r); ctx.stroke();
} else if (f === 0) {
ctx.strokeStyle = '#88bbff'; ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.arc(sx, markerY, CELL * 0.22, 0, Math.PI * 2); ctx.stroke();
} else {
const row = f - startFret;
if (row >= 0 && row < ROWS) {
const isBarreCol = hasBarreArc && f === startFret &&
col >= barreMinCol && col <= barreMaxCol;
ctx.shadowColor = 'rgba(0,0,0,0.5)';
ctx.shadowBlur = Math.min(4, CELL * 0.4);
ctx.shadowOffsetX = Math.max(0.5, CELL * 0.1);
ctx.shadowOffsetY = Math.max(0.5, CELL * 0.1);
ctx.fillStyle = isBarreCol ? 'rgba(255,255,255,0.85)' : '#ffffff';
ctx.beginPath();
ctx.arc(sx, gy + row * CELL + CELL * 0.5, DOT_R, 0, Math.PI * 2);
ctx.fill();
ctx.shadowColor = 'transparent'; ctx.shadowBlur = 0;
ctx.shadowOffsetX = 0; ctx.shadowOffsetY = 0;
}
}
}
ctx.restore();
return boxH;
}
// Cached wrapper for drawChordDiagram. When entranceT === 1 (scale
// transform is identity) the diagram is rendered once to an
// OffscreenCanvas and reused every subsequent frame via drawImage +
// globalAlpha. During the 0.2 s entrance animation (entranceT < 1)
// the scale transform is non-trivial so we fall through to a fresh
// render — that window is ~12 frames at 60 fps, negligible.
//
// Returns boxH (diagram card height in px) so the draw loop can
// accumulate per-corner stack offsets when multiple overlays share
// the same corner position.
function _drawDiagramCached(ctx, opts) {
const { opacity = 1, entranceT = 1.0, canvasW, canvasH } = opts;
if (opacity <= 0) return 0;
if (entranceT < 1.0) {
return drawChordDiagram(ctx, opts) || 0;
}
const { name, frets, nStr, inverted, sizeSlider, position, lyricsBottom = 0, stackOffset = 0 } = opts;
const key = name + '|' + (frets || []).join(',') + '|' + nStr + '|' +
(inverted ? 1 : 0) + '|' + sizeSlider + '|' + position + '|' +
canvasW + '|' + canvasH + '|' + lyricsBottom + '|' + stackOffset;
let entry = _diagRenderCache.get(key);
if (!entry) {
let oc;
try { oc = new OffscreenCanvas(canvasW, canvasH); }
catch (_) { oc = document.createElement('canvas'); oc.width = canvasW; oc.height = canvasH; }
const boxH = drawChordDiagram(oc.getContext('2d'), { ...opts, opacity: 1, entranceT: 1 }) || 0;
if (_diagRenderCache.size >= _DIAG_CACHE_MAX) {
_diagRenderCache.delete(_diagRenderCache.keys().next().value);
}
entry = { oc, boxH };
_diagRenderCache.set(key, entry);
}
ctx.save();
ctx.globalAlpha = opacity;
ctx.drawImage(entry.oc, 0, 0);
ctx.restore();
return entry.boxH;
}
// Two-line section card. Top line is "Now: <current>", bottom line
// is "Up Next: <next> in <countdown>". Explicit labels disambiguate
// current vs upcoming — earlier single-line variant rendered both
// states with the same word and was confusing during playback.
//
// Returns boxH on draw, 0 when nothing rendered. Position / size
// mirror the chord-diagram contract: 'tl' / 'tr' / 'bl' / 'br'
// anchor corners, sizeSlider in [0,1] scales card height.
//
// Hidden when:
// - no sections array, or
// - playback has not yet reached the first section AND there's
// no upcoming-only fallback rendered (we still show "Up Next"
// during the pre-roll so the user sees what's coming).
function drawSectionHud(ctx, opts) {
const {
sections, currentTime,
canvasW, canvasH,
position = 'tr',
sizeSlider = 0.5,
lyricsBottom = 0,
stackOffset = 0,
} = opts;
if (!sections || !sections.length) return 0;
// sections are time-ordered server-side; single forward scan.
let curIdx = -1;
for (let i = 0; i < sections.length; i++) {
if (sections[i].time <= currentTime) curIdx = i;
else break;
}
const cur = curIdx >= 0 ? sections[curIdx] : null;
const next = (curIdx + 1 < sections.length) ? sections[curIdx + 1] : null;
// Pre-first-section: nothing playing yet but next is coming —
// still useful to render "Up Next" alone so the user gets the
// anticipatory cue during the song's intro silence.
if (!cur && !next) return 0;
const nowName = cur ? cur.name : '';
// Render countdown as a separate span so it can take a calmer
// grey-white treatment while the section name itself stays
// cyan. Combining them into one string would inherit the cyan
// fill across both, defeating the visual hierarchy promised
// in the FR.
let nextName = '';
let nextCountdown = '';
if (next) {
const dt = next.time - currentTime;
nextName = next.name;
nextCountdown = dt > 10
? 'in ' + Math.round(dt) + 's'
: 'in ' + Math.max(0, dt).toFixed(1) + 's';
}
const sizeF = 0.65 + 0.85 * sizeSlider; // 0.65 .. 1.5
const baseH = Math.max(34, Math.min(72, Math.round(canvasH * 0.085 * sizeF)));
const PAD_X = Math.round(baseH * 0.45);
const PAD_Y = Math.round(baseH * 0.20);
// Per-text-element scale applied to nameSize / tagSize / lineH
// when the unscaled card would overflow a narrow panel
// (splitscreen quad layout, ultra-tall portrait). Computed
// below from the measured contentW vs the available width.
let textScale = 1.0;
const baseLineH = Math.round(baseH * 0.46);
const baseNameSize = Math.round(baseH * 0.36);
const baseTagSize = Math.round(baseH * 0.24);
const baseTagGap = Math.round(baseH * 0.14);
const TAG_NOW = 'Now:';
const TAG_NEXT = 'Up Next:';
// Phase-1 measurement at the unscaled font sizes — used to
// decide whether textScale needs to drop, and to lay out the
// final draw at whatever scale we land on.
ctx.save();
ctx.font = `${baseTagSize}px sans-serif`;
const tagNowWBase = ctx.measureText(TAG_NOW).width;
const tagNextWBase = ctx.measureText(TAG_NEXT).width;
const countdownWBase = nextCountdown ? ctx.measureText(nextCountdown).width : 0;
ctx.font = `bold ${baseNameSize}px sans-serif`;
const nowNameWBase = nowName ? ctx.measureText(nowName).width : 0;
const nextNameWBase = nextName ? ctx.measureText(nextName).width : 0;
ctx.restore();
const lineNowWBase = nowName ? tagNowWBase + baseTagGap + nowNameWBase : 0;
const lineNextWBase = nextName
? tagNextWBase + baseTagGap + nextNameWBase
+ (nextCountdown ? baseTagGap + countdownWBase : 0)
: 0;
const contentWBase = Math.max(lineNowWBase, lineNextWBase);
const numLines = (nowName ? 1 : 0) + (nextName ? 1 : 0);
if (numLines === 0) return 0;
// Target width budget: cap at canvasW - 16 and reserve PAD_X
// either side. If contentWBase exceeds the budget, scale the
// font proportionally — clamped to 0.55 so labels stay legible
// even on extreme split-panel widths.
const maxBoxW = Math.max(40, canvasW - 16);
const availContentW = Math.max(1, maxBoxW - PAD_X * 2);
if (contentWBase > availContentW) {
textScale = Math.max(0.55, availContentW / contentWBase);
}
const lineH = Math.max(1, Math.round(baseLineH * textScale));
const nameSize = Math.max(1, Math.round(baseNameSize * textScale));
const tagSize = Math.max(1, Math.round(baseTagSize * textScale));
const TAG_GAP = Math.max(1, Math.round(baseTagGap * textScale));
// Phase-2 re-measurement at the scaled font sizes for the
// final layout. measureText doesn't scale linearly with font
// size on every glyph, so re-measuring is cheaper than
// multiplying the base widths by textScale and risking a
// half-pixel overflow.
ctx.save();
ctx.font = `${tagSize}px sans-serif`;
const tagNowW = ctx.measureText(TAG_NOW).width;
const tagNextW = ctx.measureText(TAG_NEXT).width;
const countdownW = nextCountdown ? ctx.measureText(nextCountdown).width : 0;
ctx.font = `bold ${nameSize}px sans-serif`;
const nowNameW = nowName ? ctx.measureText(nowName).width : 0;
const nextNameW = nextName ? ctx.measureText(nextName).width : 0;
ctx.restore();
const lineNowW = nowName ? tagNowW + TAG_GAP + nowNameW : 0;
const lineNextW = nextName
? tagNextW + TAG_GAP + nextNameW + (nextCountdown ? TAG_GAP + countdownW : 0)
: 0;
const contentW = Math.max(lineNowW, lineNextW);
const boxW = Math.min(maxBoxW, Math.round(contentW + PAD_X * 2));
const boxH = Math.round(numLines * lineH + PAD_Y * 2);
const E = Math.round(baseH * 0.25);
const TOP_Y = Math.round(Math.max(E + canvasH * 0.06, lyricsBottom + E));
let bx, by;
if (position === 'tr') { bx = canvasW - boxW - E; by = TOP_Y + stackOffset; }
else if (position === 'bl') { bx = E; by = canvasH - boxH - E - stackOffset; }
else if (position === 'br') { bx = canvasW - boxW - E; by = canvasH - boxH - E - stackOffset; }
else { bx = E; by = TOP_Y + stackOffset; }
bx = Math.max(0, Math.min(canvasW - boxW, bx));
by = Math.max(0, Math.min(canvasH - boxH, by));
// Suppress overlap with the wrapped lyrics banner regardless
// of corner. Bottom-corner cards on short panels can still
// reach up into the banner once boxH exceeds the space below
// the lyrics — same shape the chord diagram uses.
if (lyricsBottom > 0 && by < lyricsBottom) return 0;
ctx.save();
ctx.fillStyle = 'rgba(8, 14, 22, 0.88)';
ctx.beginPath(); ctx.roundRect(bx, by, boxW, boxH, 7); ctx.fill();
ctx.strokeStyle = 'rgba(255,255,255,0.15)'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.roundRect(bx, by, boxW, boxH, 7); ctx.stroke();
ctx.textBaseline = 'middle';
ctx.textAlign = 'left';
// Layout each line with tag left-aligned, name in cyan after a
// small gap. Both lines share the same x origin (bx + PAD_X)
// so the tag column visually aligns vertically.
const lineX = bx + PAD_X;
let lineY = by + PAD_Y + lineH / 2;
const TAG_COLOR = 'rgba(180,190,205,0.85)';
const NAME_COLOR = '#00cccc';
const TIME_COLOR = 'rgba(220,225,235,0.9)';
if (nowName) {
ctx.font = `${tagSize}px sans-serif`;
ctx.fillStyle = TAG_COLOR;
ctx.fillText(TAG_NOW, lineX, lineY);
ctx.font = `bold ${nameSize}px sans-serif`;
ctx.fillStyle = NAME_COLOR;
ctx.fillText(nowName, lineX + tagNowW + TAG_GAP, lineY);
lineY += lineH;
}
if (nextName) {
ctx.font = `${tagSize}px sans-serif`;
ctx.fillStyle = TAG_COLOR;
ctx.fillText(TAG_NEXT, lineX, lineY);
const nextX = lineX + tagNextW + TAG_GAP;
ctx.font = `bold ${nameSize}px sans-serif`;
ctx.fillStyle = NAME_COLOR;
ctx.fillText(nextName, nextX, lineY);
if (nextCountdown) {
ctx.font = `${tagSize}px sans-serif`;
ctx.fillStyle = TIME_COLOR;
ctx.fillText(nextCountdown, nextX + nextNameW + TAG_GAP, lineY);
}
}
ctx.restore();
return boxH;
}
// Tone-change HUD — card showing the active tone and the next upcoming
// tone with a countdown. Mirrors drawSectionHud's layout contract
// (position, size slider, lyricsBottom) but uses an amber accent colour
// so it reads as distinct from the cyan section card.
function drawToneHud(ctx, opts) {
const {
toneChanges, toneBase = '',
currentTime,
canvasW, canvasH,
position = 'tl',
sizeSlider = 0.5,
lyricsBottom = 0,
stackOffset = 0,
} = opts;
// Resolve active tone: toneBase before all changes, else the most
// recent change at or before currentTime.
// toneChanges items use { t, name } (not { time, name }) — both
// the legacy import path (server.py xml_tone_changes) and the sloppak
// path (lib/tones.py sloppak_tone_changes) emit "t" as the key.
let curName = toneBase;
let nextChange = null;
if (toneChanges && toneChanges.length) {
for (let i = 0; i < toneChanges.length; i++) {
if (toneChanges[i].t <= currentTime) {
curName = toneChanges[i].name;
} else {
nextChange = toneChanges[i];
break;
}
}
}
if (!curName && !nextChange) return 0;
let nextName = '';
let nextCountdown = '';
if (nextChange) {
const dt = nextChange.t - currentTime;
nextName = nextChange.name;
nextCountdown = dt > 10
? 'in ' + Math.round(dt) + 's'
: 'in ' + Math.max(0, dt).toFixed(1) + 's';
}
const sizeF = 0.65 + 0.85 * sizeSlider;
const baseH = Math.max(34, Math.min(72, Math.round(canvasH * 0.085 * sizeF)));
const PAD_X = Math.round(baseH * 0.45);
const PAD_Y = Math.round(baseH * 0.20);
let textScale = 1.0;
const baseLineH = Math.round(baseH * 0.46);
const baseNameSize = Math.round(baseH * 0.36);
const baseTagSize = Math.round(baseH * 0.24);
const baseTagGap = Math.round(baseH * 0.14);
const TAG_CUR = 'Tone:';
const TAG_NEXT = 'Next:';
ctx.save();
ctx.font = `${baseTagSize}px sans-serif`;
const tagCurWBase = ctx.measureText(TAG_CUR).width;
const tagNextWBase = ctx.measureText(TAG_NEXT).width;
const countdownWBase = nextCountdown ? ctx.measureText(nextCountdown).width : 0;
ctx.font = `bold ${baseNameSize}px sans-serif`;
const curNameWBase = curName ? ctx.measureText(curName).width : 0;
const nextNameWBase = nextName ? ctx.measureText(nextName).width : 0;
ctx.restore();
const lineCurWBase = curName ? tagCurWBase + baseTagGap + curNameWBase : 0;
const lineNextWBase = nextName
? tagNextWBase + baseTagGap + nextNameWBase
+ (nextCountdown ? baseTagGap + countdownWBase : 0)
: 0;
const contentWBase = Math.max(lineCurWBase, lineNextWBase);
const numLines = (curName ? 1 : 0) + (nextName ? 1 : 0);
if (numLines === 0) return 0;
const maxBoxW = Math.max(40, canvasW - 16);
const availContentW = Math.max(1, maxBoxW - PAD_X * 2);
if (contentWBase > availContentW) {
textScale = Math.max(0.55, availContentW / contentWBase);
}
const lineH = Math.max(1, Math.round(baseLineH * textScale));
const nameSize = Math.max(1, Math.round(baseNameSize * textScale));
const tagSize = Math.max(1, Math.round(baseTagSize * textScale));
const TAG_GAP = Math.max(1, Math.round(baseTagGap * textScale));
ctx.save();
ctx.font = `${tagSize}px sans-serif`;
const tagCurW = ctx.measureText(TAG_CUR).width;
const tagNextW = ctx.measureText(TAG_NEXT).width;
const countdownW = nextCountdown ? ctx.measureText(nextCountdown).width : 0;
ctx.font = `bold ${nameSize}px sans-serif`;
const curNameW = curName ? ctx.measureText(curName).width : 0;
const nextNameW = nextName ? ctx.measureText(nextName).width : 0;
ctx.restore();
const lineCurW = curName ? tagCurW + TAG_GAP + curNameW : 0;
const lineNextW = nextName
? tagNextW + TAG_GAP + nextNameW + (nextCountdown ? TAG_GAP + countdownW : 0)
: 0;
const contentW = Math.max(lineCurW, lineNextW);
const boxW = Math.min(maxBoxW, Math.round(contentW + PAD_X * 2));
const boxH = Math.round(numLines * lineH + PAD_Y * 2);
const E = Math.round(baseH * 0.25);
const TOP_Y = Math.round(Math.max(E + canvasH * 0.06, lyricsBottom + E));
let bx, by;
if (position === 'tr') { bx = canvasW - boxW - E; by = TOP_Y + stackOffset; }
else if (position === 'bl') { bx = E; by = canvasH - boxH - E - stackOffset; }
else if (position === 'br') { bx = canvasW - boxW - E; by = canvasH - boxH - E - stackOffset; }
else { bx = E; by = TOP_Y + stackOffset; } // 'tl' default
bx = Math.max(0, Math.min(canvasW - boxW, bx));
by = Math.max(0, Math.min(canvasH - boxH, by));
if (lyricsBottom > 0 && by < lyricsBottom) return 0;
ctx.save();
ctx.fillStyle = 'rgba(8, 14, 22, 0.88)';
ctx.beginPath(); ctx.roundRect(bx, by, boxW, boxH, 7); ctx.fill();
ctx.strokeStyle = 'rgba(255,255,255,0.15)'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.roundRect(bx, by, boxW, boxH, 7); ctx.stroke();
ctx.textBaseline = 'middle';
ctx.textAlign = 'left';
const lineX = bx + PAD_X;
let lineY = by + PAD_Y + lineH / 2;
const TAG_COLOR = 'rgba(180,190,205,0.85)';
const NAME_COLOR = '#ff9a3c'; // amber — distinct from section cyan
const TIME_COLOR = 'rgba(220,225,235,0.9)';
if (curName) {
ctx.font = `${tagSize}px sans-serif`;
ctx.fillStyle = TAG_COLOR;
ctx.fillText(TAG_CUR, lineX, lineY);
ctx.font = `bold ${nameSize}px sans-serif`;
ctx.fillStyle = NAME_COLOR;
ctx.fillText(curName, lineX + tagCurW + TAG_GAP, lineY);
lineY += lineH;
}
if (nextName) {
ctx.font = `${tagSize}px sans-serif`;
ctx.fillStyle = TAG_COLOR;
ctx.fillText(TAG_NEXT, lineX, lineY);
const nextX = lineX + tagNextW + TAG_GAP;
ctx.font = `bold ${nameSize}px sans-serif`;
ctx.fillStyle = NAME_COLOR;
ctx.fillText(nextName, nextX, lineY);
if (nextCountdown) {
ctx.font = `${tagSize}px sans-serif`;
ctx.fillStyle = TIME_COLOR;
ctx.fillText(nextCountdown, nextX + nextNameW + TAG_GAP, lineY);
}
}
ctx.restore();
return boxH;
}
function drawLyrics(lyrics, currentTime, ctx, W, H) {
if (!lyrics._lines) {
const lines = [];
let line = null, word = null;
const flushWord = () => { if (word && word.length) line.words.push(word); word = null; };
const flushLine = () => { flushWord(); if (line && line.words.length) lines.push(line); line = null; };
for (let i = 0; i < lyrics.length; i++) {
const l = lyrics[i];
const raw = l.w || '';
const endsLine = raw.endsWith('+');
const continuesWord = raw.endsWith('-');
if (line && i > 0 && l.t - (lyrics[i - 1].t + lyrics[i - 1].d) > 4.0) flushLine();
if (!line) line = { words: [], start: l.t, end: l.t + l.d };
if (!word) word = [];
word.push(l);
line.end = Math.max(line.end, l.t + l.d);
if (!continuesWord) flushWord();
if (endsLine) flushLine();
}
flushLine();
lyrics._lines = lines;
}
const allLines = lyrics._lines;
if (!allLines.length) return 0;
let currentIdx = -1;
for (let i = 0; i < allLines.length; i++) {
if (allLines[i].start <= currentTime) currentIdx = i;
else break;
}
if (currentIdx === -1) {
if (allLines[0].start - currentTime > 2.0) return 0;
currentIdx = 0;
}
const currentLine = allLines[currentIdx];
const nextLine = allLines[currentIdx + 1] || null;
const gapToNext = nextLine ? (nextLine.start - currentLine.end) : Infinity;
if (currentTime > currentLine.end + 0.5 && gapToNext > 3.0) return 0;
const linesToShow = [currentLine];
if (nextLine && gapToNext <= 3.0) linesToShow.push(nextLine);
const fontSize = Math.max(18, H * 0.028) | 0;
const lineY = H * 0.04;
const sylText = s => { const t = s.w || ''; return (t.endsWith('+') || t.endsWith('-')) ? t.slice(0, -1) : t; };
ctx.font = `bold ${fontSize}px sans-serif`;
const spaceWidth = ctx.measureText(' ').width;
const maxWidth = W * 0.8;
const rows = [];
for (const authoredLine of linesToShow) {
let row = [], rowWidth = 0;
for (const wordSyls of authoredLine.words) {
const parts = [];
let wordWidth = 0;
for (const s of wordSyls) {
const text = sylText(s);
const w = ctx.measureText(text).width;
parts.push({ syl: s, text, width: w });
wordWidth += w;
}
const advance = wordWidth + spaceWidth;
if (row.length > 0 && rowWidth + advance > maxWidth) { rows.push(row); row = []; rowWidth = 0; }
row.push({ parts, advance });
rowWidth += advance;
}
if (row.length) rows.push(row);
}
const rowHeight = fontSize + 6;
const totalHeight = rows.length * rowHeight + 10;
let bgWidth = 0;
for (const row of rows) {
const rw = row.reduce((s, w) => s + w.advance, 0) - spaceWidth;
if (rw > bgWidth) bgWidth = rw;
}
bgWidth = Math.min(bgWidth + 30, W * 0.85);
ctx.fillStyle = 'rgba(0,0,0,0.7)';
ctx.beginPath();
const bx = W / 2 - bgWidth / 2, by = lineY - 4, br = 8;
ctx.moveTo(bx + br, by); ctx.lineTo(bx + bgWidth - br, by);
ctx.quadraticCurveTo(bx + bgWidth, by, bx + bgWidth, by + br);
ctx.lineTo(bx + bgWidth, by + totalHeight - br);
ctx.quadraticCurveTo(bx + bgWidth, by + totalHeight, bx + bgWidth - br, by + totalHeight);
ctx.lineTo(bx + br, by + totalHeight);
ctx.quadraticCurveTo(bx, by + totalHeight, bx, by + totalHeight - br);
ctx.lineTo(bx, by + br);
ctx.quadraticCurveTo(bx, by, bx + br, by);
ctx.closePath();
ctx.fill();
ctx.textAlign = 'left';
ctx.textBaseline = 'top';
for (let r = 0; r < rows.length; r++) {
const row = rows[r];
const rowWidth = row.reduce((s, w) => s + w.advance, 0) - spaceWidth;
let xPos = W / 2 - rowWidth / 2;
const yPos = lineY + r * rowHeight + 2;
for (const w of row) {
for (const part of w.parts) {
const l = part.syl;
const isActive = currentTime >= l.t && currentTime < l.t + l.d;
const isPast = currentTime >= l.t + l.d;
ctx.fillStyle = isActive ? '#4ae0ff' : isPast ? '#8899aa' : '#556677';
ctx.font = `${isActive ? 'bold' : 'normal'} ${fontSize}px sans-serif`;
ctx.fillText(part.text, xPos, yPos);
xPos += part.width;
}
xPos += spaceWidth;
}
}
// Return the actual bottom Y of the rendered background box so callers
// (e.g. drawChordDiagram) can avoid overlapping it.
return Math.round(by + totalHeight);
}
/* ── Scene initialisation ─────────────────────────────────────────── */
function initScene() {
if (!highwayCanvas || !highwayCanvas.parentNode) {
console.error('[3D-Hwy] initScene: canvas has no parent; aborting');
return false;
}
// Reset per-song lane state
fretLastActiveTime.fill(0);
wrap = document.createElement('div');
wrap.id = 'h3d-wrap-' + _instanceId;
wrap.className = 'h3d-wrap';
wrap.dataset.h3dInstance = String(_instanceId);
wrap.style.cssText = 'position:absolute;top:0;left:0;right:0;z-index:2;pointer-events:none;';
// Mark this instance as the primary tour target so the tour engine
// always spotlights a unique element (selector '.h3d-wrap[data-h3d-primary]')
// rather than the first of potentially many splitscreen wraps.
document.querySelectorAll('.h3d-wrap[data-h3d-primary]').forEach(
el => el.removeAttribute('data-h3d-primary'));
wrap.setAttribute('data-h3d-primary', '');
highwayCanvas.parentNode.insertBefore(wrap, highwayCanvas.nextSibling);
// Subscribe to highway:visibility (slopsmith#246) so the
// .h3d-wrap overlay hides in sync with the slopsmith canvas.
// The wrap is a sibling of #highway, so display:none on
// #highway leaves us painting full-screen otherwise.
// Guarded lazy bind: tolerate hosts that don't yet expose
// slopsmith.on/off (older slopsmith versions, headless
// tests).
if (window.slopsmith
&& typeof window.slopsmith.on === 'function'
&& typeof window.slopsmith.off === 'function') {
_visibilityHandler = (e) => {
if (!wrap) return;
// Filter by canvas identity (splitscreen-safe).
// Each createHighway() instance emits its own
// visibility events on the shared slopsmith bus —
// without this gate, one hidden panel would also
// hide every other panel's 3D overlay.
if (!e || !e.detail || e.detail.canvas !== highwayCanvas) return;
const v = e.detail.visible;
wrap.style.display = v === false ? 'none' : '';
};
try {
window.slopsmith.on('highway:visibility', _visibilityHandler);
} catch (e) {
_visibilityHandler = null;
}
// Track canvas-replaced so the visibility handler's
// identity gate continues to match after core swaps the
// <canvas> element for a context-type change.
_canvasReplacedHandler = (e) => {
if (!e || !e.detail) return;
// Only update if the swap involves OUR canvas — in
// splitscreen each panel has its own canvas.
if (e.detail.oldCanvas !== highwayCanvas) return;
highwayCanvas = e.detail.newCanvas;
// Re-sync wrap visibility from the new canvas in
// case its initial displayed-state differs.
if (wrap) {
const v = highwayCanvas && highwayCanvas.offsetParent !== null;
wrap.style.display = v ? '' : 'none';
}
};
try {
window.slopsmith.on('highway:canvas-replaced', _canvasReplacedHandler);
} catch (e) {
_canvasReplacedHandler = null;
}
// Sync once at bind time: the event is transition-only,
// so if the canvas was already hidden when we mounted
// (e.g. plugin loaded while splitscreen was active),
// we'd never receive an emit and would leave the wrap
// visible. Compute from the local highwayCanvas (not
// window.highway.isVisible) so splitscreen panels get
// their own per-instance answer instead of inheriting
// the main highway's state.
if (_visibilityHandler) {
try {
const initialVisible = highwayCanvas
&& highwayCanvas.offsetParent !== null;
wrap.style.display = initialVisible ? '' : 'none';
} catch (e) { /* ignore — initial sync is best-effort */ }
}
}
// powerPreference hints the platform to use the discrete /
// high-performance GPU and a higher power profile for this WebGL
// context. On laptops / iGPU+dGPU machines (Windows, macOS) it
// steers GPU selection to the dGPU; on single-dGPU desktops it
// requests the high-performance power profile. (It does not by
// itself force NVIDIA's utilisation-driven clock ramp on Linux.)
ren = new T.WebGLRenderer({ antialias: true, powerPreference: 'high-performance' });
_probe = new T.Vector3();
ren.setClearColor(0x101820);
wrap.appendChild(ren.domElement);
lyricsCanvas = document.createElement('canvas');
lyricsCanvas.style.cssText = 'position:absolute;top:0;left:0;pointer-events:none;z-index:1;';
lyricsCtx = lyricsCanvas.getContext('2d');
wrap.appendChild(lyricsCanvas);
scene = new T.Scene();
scene.fog = new T.Fog(0x101820, FOG_START * 0.8, FOG_END * 1.2);
cam = new T.PerspectiveCamera(70, 1, 0.01, FOG_END * 3);
ambLight = new T.AmbientLight(0xffffff, 0.85);
scene.add(ambLight);
dirLight = new T.DirectionalLight(0xffffff, 0.8);
dirLight.position.set(40 * K, 120 * K, 80 * K);
scene.add(dirLight);
fretG = new T.Group(); scene.add(fretG);
tuningLblG = new T.Group(); scene.add(tuningLblG);
noteG = new T.Group(); scene.add(noteG);
beatG = new T.Group(); scene.add(beatG);
lblG = new T.Group(); scene.add(lblG);
// Rectangular note geometry
gNote = new T.BoxGeometry(NW, NH, ND);
// Per-string vertical gradient gems — colours sampled from the
// original colour PNGs (top highlight → deeper bottom). Each gradient
// string gets its own BoxGeometry clone carrying a per-vertex colour
// attribute; the gem core swaps to gNoteGrad[s] in drawNote while its
// material (mStr[s]) is white + vertexColors:true so the gradient
// shows pure. Strings 6/7 have no entry and fall back to flat gNote.
gNoteGrad = DEFAULT_GEM_GRADIENTS.map(([topHex, botHex]) => {
const g = new T.BoxGeometry(NW, NH, ND);
const _pos = g.attributes.position;
const _colors = new Float32Array(_pos.count * 3);
const _topCol = new T.Color(topHex);
const _botCol = new T.Color(botHex);
const _tmpCol = new T.Color();
const _halfH = NH / 2;
for (let i = 0; i < _pos.count; i++) {
const t = (_pos.getY(i) + _halfH) / (2 * _halfH); // 0 bottom..1 top
_tmpCol.copy(_botCol).lerp(_topCol, t);
_colors[i * 3] = _tmpCol.r;
_colors[i * 3 + 1] = _tmpCol.g;
_colors[i * 3 + 2] = _tmpCol.b;
}
g.setAttribute('color', new T.BufferAttribute(_colors, 3));
_ownedSharedGeos.push(g);
return g;
});
// Seed gem colors from whatever palette is active at mount (custom
// colors recolor the gem bodies just like the strings/trails).
_recolorGemGradients();
/** Filled ring matching flying-note outline (1.1) minus core (1.0); hollow centre. */
function mkGhostFrameGeometry() {
const ow = NW * 1.1;
const oh = NH * 1.1;
const iw = NW;
const ih = NH;
const depth = ND * 2.8;
const shape = new T.Shape();
shape.moveTo(-ow / 2, -oh / 2);
shape.lineTo(-ow / 2, oh / 2);
shape.lineTo(ow / 2, oh / 2);
shape.lineTo(ow / 2, -oh / 2);
shape.lineTo(-ow / 2, -oh / 2);
const hole = new T.Path();
hole.moveTo(-iw / 2, -ih / 2);
hole.lineTo(iw / 2, -ih / 2);
hole.lineTo(iw / 2, ih / 2);
hole.lineTo(-iw / 2, ih / 2);
hole.lineTo(-iw / 2, -ih / 2);
shape.holes.push(hole);
const g = new T.ExtrudeGeometry(shape, { depth, bevelEnabled: false });
g.translate(0, 0, -depth / 2);
return g;
}
gSus = new T.BoxGeometry(1, 1, 1);
gBeat = new T.BufferGeometry().setFromPoints(
[new T.Vector3(0, 0, 0), new T.Vector3(1, 0, 0)],
);
// Tap chevron (open V pointing downward) — filled outline for extrusion into a solid mesh
const chevronShape = new T.Shape();
// Adjusting points for a "stubby" look
// Width: increased to +/- 0.8 for a broader look
// Height: capped at 0.2 to make it significantly shorter
chevronShape.moveTo(-0.6, 0.3); // Top left point (further out, lower down)
chevronShape.lineTo(0, -0.1); // Interior vertex (shallower V)
chevronShape.lineTo(0.6, 0.3); // Top right point (further out, lower down)
chevronShape.lineTo(0.8, 0.0); // Right outer thickness point
chevronShape.lineTo(0, -0.3); // Bottom vertex / Outer point (less deep)
chevronShape.lineTo(-0.8, 0.0); // Left outer thickness point
chevronShape.closePath();
// Create the 3D mesh geometry with a small depth
gTapChevron = new T.ExtrudeGeometry(chevronShape, {
depth: 0.04 * K,
bevelEnabled: false,
});
// Optional: Center the geometry if the pivot point feels off
gTapChevron.computeBoundingBox();
const centerOffset = -0.5 * (gTapChevron.boundingBox.max.y + gTapChevron.boundingBox.min.y);
gTapChevron.translate(0, centerOffset, 0);
// String materials. Strings 0..5 use a per-vertex gradient (color is
// white so the gradient baked into gNoteGrad[s] shows pure); strings
// 6/7 keep a flat colour (vertexColors:false ignores the attribute).
mStr = activePalette.map((c, i) => new T.MeshBasicMaterial({
color: i < 6 ? 0xffffff : c,
vertexColors: i < 6,
transparent: true, opacity: 1.0,
}));
mGlow = activePalette.map(c => new T.MeshLambertMaterial({
color: 0xffffff, emissive: c, emissiveIntensity: 1.5,
transparent: true, opacity: 1.0, depthWrite: false,
}));
_laneTargetColor = new T.Color(0x4488ff);
mSus = activePalette.map(c => new T.MeshLambertMaterial({
color: c, transparent: true, opacity: 0.35,
}));
mWhiteOutline = new T.MeshLambertMaterial({ color: 0xffffff, emissive: 0xffffff, emissiveIntensity: 0.6, transparent: true, opacity: 1.0, depthWrite: false });
const _outlineColors = [0xFF5552, 0xFFF352, 0x31CAFF, 0xFFAE31, 0x84FF42, 0xE639FF];
const _outlinePalette = activePalette.map((c, i) => _outlineColors[i] ?? c);
mStrHitOutline = _outlinePalette.map(c => new T.MeshLambertMaterial({
color: c, emissive: c, emissiveIntensity: 1.0,
transparent: true, opacity: 1.0, depthWrite: false,
}));
// Stronger coloured rim + body for accented notes (.ac); drawNote swaps these in behind ND hit/miss.
mAccentOutline = activePalette.map(c => new T.MeshLambertMaterial({
color: c, emissive: c, emissiveIntensity: ACCENT_RIM_BASE_EMISSIVE,
transparent: true, opacity: 1.0, depthWrite: false,
}));
// Same colour response as mGlow (vibrancy lerp) but separate emissive drive for extra accent punch.
mAccentCore = activePalette.map(c => new T.MeshLambertMaterial({
color: 0xffffff, emissive: c, emissiveIntensity: 1.5,
transparent: true, opacity: 1.0, depthWrite: false,
}));
const mkAccentHaloMats = (baseOp) => activePalette.map(c => new T.MeshBasicMaterial({
color: new T.Color(c),
transparent: true,
opacity: baseOp,
depthWrite: false,
depthTest: true,
blending: T.AdditiveBlending,
side: T.DoubleSide,
fog: true,
}));
mAccentHaloNear = mkAccentHaloMats(ACCENT_HALO_OP_NEAR);
mAccentHaloMid = mkAccentHaloMats(ACCENT_HALO_OP_MID);
mAccentHaloFar = mkAccentHaloMats(ACCENT_HALO_OP_FAR);
// Frozen per-string shell descriptors — see _accentShellsByString
// declaration. Materials live for the renderer's lifetime, so
// these refs stay valid until teardown() clears them.
_accentShellsByString = mAccentHaloFar.map((_, s) => Object.freeze([
Object.freeze({ mat: mAccentHaloFar[s], ixy: ACCENT_HALO_XY_OUTER, iz: ACCENT_HALO_Z_OUTER, zK: 0.012 }),
Object.freeze({ mat: mAccentHaloMid[s], ixy: ACCENT_HALO_XY_MID, iz: ACCENT_HALO_Z_MID, zK: 0.008 }),
Object.freeze({ mat: mAccentHaloNear[s], ixy: ACCENT_HALO_XY_INNER, iz: ACCENT_HALO_Z_INNER, zK: 0.005 }),
]));
// Chord/arpeggio frame accent bloom — single gradient bar geometry.
// The 4 bloom shells (expand=1.00/1.10/1.25/1.45, op=0.90/0.65/0.38/0.18)
// are baked into vertex colours as their additive sum at each Y level,
// so one mesh per bar replaces 4 per-shell meshes (16→4 draw calls/chord).
// Normalised Y = ±(expand / EXPAND_MAX); EXPAND_MAX = 1.45.
// Values > 1.0 in the Float32Array buffer are intentional: WebGL passes
// them to the shader unchanged, and additive blending clips naturally.
if (!gHaloBar) {
// Y levels (normalised): ±(shell_expand / 1.45)
// ±0.690 = shell 1 edge ±0.759 = shell 2 ±0.862 = shell 3 ±1.0 = shell 4
// Brightness = additive sum of all shells covering that band:
// |y| < 0.690 → all 4: 0.90+0.65+0.38+0.18 = 2.11
// |y| < 0.759 → 3 shells: 0.65+0.38+0.18 = 1.21
// |y| < 0.862 → 2 shells: 0.38+0.18 = 0.56
// |y| ≤ 1.000 → shell 4 only: 0.18
// prettier-ignore
const YS = [-1.000, -0.862, -0.759, -0.690, 0.690, 0.759, 0.862, 1.000];
// prettier-ignore
const BS = [ 0.18, 0.56, 1.21, 2.11, 2.11, 1.21, 0.56, 0.18 ];
const N = YS.length;
const pos = new Float32Array(N * 2 * 3);
const col = new Float32Array(N * 2 * 3);
const idx = new Uint16Array((N - 1) * 6);
for (let i = 0; i < N; i++) {
const y = YS[i], b = BS[i];
const li = (i * 2 + 0) * 3, ri = (i * 2 + 1) * 3;
pos[li]=-1; pos[li+1]=y; pos[li+2]=0;
col[li]=b; col[li+1]=b; col[li+2]=b;
pos[ri]=+1; pos[ri+1]=y; pos[ri+2]=0;
col[ri]=b; col[ri+1]=b; col[ri+2]=b;
}
for (let i = 0; i < N - 1; i++) {
const ii = i * 6, v = i * 2;
idx[ii+0]=v+0; idx[ii+1]=v+1; idx[ii+2]=v+3;
idx[ii+3]=v+0; idx[ii+4]=v+3; idx[ii+5]=v+2;
}
gHaloBar = new T.BufferGeometry();
gHaloBar.setAttribute('position', new T.BufferAttribute(pos, 3));
gHaloBar.setAttribute('color', new T.BufferAttribute(col, 3));
gHaloBar.setIndex(new T.BufferAttribute(idx, 1));
}
pHaloBar = pool(noteG, () => new T.Mesh(
gHaloBar,
new T.MeshBasicMaterial({
vertexColors: true,
transparent: true, opacity: 1.0, depthWrite: false,
blending: T.AdditiveBlending, side: T.DoubleSide, fog: false,
}),
));
// Notedetect feedback outline (issue #9): hot magenta-red (0xff0066, hue
// ~345°) — distinct from the string red 0xff2828 at hue ~0°. Note rendering
// swaps its outline.material between mWhiteOutline / per-string
// mHitBright[s] / mMissOutline based on recent notedetect events.
mMissOutline = new T.MeshLambertMaterial({ color: 0xff0066, emissive: 0xff0066, emissiveIntensity: 1.2, transparent: true, opacity: 1.0, depthWrite: false });
// Transparent placeholder for front (+Z, group 4) and back (-Z, group 5)
// of the lateral face-fill material array. Also the default material for
// the pNoteEdge pool: pool consumers reassign .material before render, so
// the placeholder is never displayed — using an explicitly-invisible
// material makes that intent obvious.
// BoxGeometry group order: 0=+X, 1=-X, 2=+Y, 3=-Y, 4=+Z(front), 5=-Z(back)
mEdgeTransparent = new T.MeshBasicMaterial({ transparent: true, opacity: 0, depthWrite: false });
// mMissEdgeArrays: use mMissOutline (same Lambert+emissive material as the gem
// border) so the lateral face fill matches the outline colour exactly.
mMissEdgeArrays = [mMissOutline, mMissOutline, mMissOutline, mMissOutline, mEdgeTransparent, mEdgeTransparent];
// Hit: fixed neon spring-green on every string — 0x22ff88 is cyan-shifted
// enough to be readable even on the green string (0x30d040). The outline
// + lateral faces flash green regardless of which string was hit.
mHitBright = activePalette.map(() => new T.MeshLambertMaterial({
color: 0x22ff88, emissive: 0x22ff88, emissiveIntensity: 4.0 * glowMul,
transparent: true, opacity: 1.0, depthWrite: false,
}));
mHitBrightArrays = mHitBright.map(m => [m, m, m, m, mEdgeTransparent, mEdgeTransparent]);
// Outline materials render at a lower renderOrder than the body.
// The body is rendered on top with opacity:1 on hit/miss, which
// fully covers the outline center — only the fringe that extends
// past the body edges (0.2*K on each side) is visible.
mSusOutline = new T.MeshLambertMaterial({ color: 0xffffff, emissive: 0xffffff, emissiveIntensity: 0.3, transparent: true, opacity: 0.75, depthWrite: false });
mHitSusOutline = new T.MeshLambertMaterial({ color: 0x22ff88, emissive: 0x22ff88, emissiveIntensity: 0.8, transparent: true, opacity: 0.45, depthWrite: false });
mBeatM = new T.LineBasicMaterial({ color: 0xffffff, transparent: true, opacity: 0.25 });
mBeatQ = new T.LineBasicMaterial({ color: 0xffffff, transparent: true, opacity: 0.07 });
// ── Board ghost: filled rim (ExtrudeGeometry w/ hole) in string colour ──
// Matches outline 1.1× vs core 1.0× like drawNote; centre stays empty.
// 3 slots per string (up to 3 simultaneous ghost previews, chart-
// format style): slot 0 is the "next on string" / chord / arp ghost
// (unchanged selection logic), slots 1/2 are independent
// "upcoming" lead-note previews. All slots share one geometry —
// it's identical (NW/NH/ND-based) regardless of string or slot,
// so one ExtrudeGeometry serves all nStr*3 meshes.
const _ghostFrameGeo = mkGhostFrameGeometry();
projMeshArr = activePalette.map((_, s) => [0, 1, 2].map(() => {
const mat = new T.MeshStandardMaterial({
color: activePalette[s],
emissive: activePalette[s],
emissiveIntensity: 0.002,
transparent: true,
opacity: 0.65,
roughness: 1,
depthWrite: false,
depthTest: false,
});
const m = new T.Mesh(_ghostFrameGeo, mat);
m.visible = false;
// Board projection ghost frame. depthTest:false above, so
// renderOrder alone decides stacking — keep it above the
// sus trails (12/13) but below note gems (20/21) so it
// stays visible on the fretboard without covering notes.
m.renderOrder = 14;
noteG.add(m);
return m;
}));
// ── Pools ──────────────────────────────────────────────────────
pNote = pool(noteG, () => new T.Mesh(gNote, mStr[0]));
// Pool default is the always-invisible mEdgeTransparent — every
// consumer reassigns .material before render (to a verdict edge
// material array), so the placeholder is never displayed.
pNoteEdge = pool(noteG, () => new T.Mesh(gNote, mEdgeTransparent));
pAccentHalo = pool(noteG, () => new T.Mesh(gNote, mAccentHaloFar[0]));
pSus = pool(noteG, () => new T.Mesh(gSus, mSus[0]));
pSusOutline = pool(noteG, () => new T.Mesh(gSus, mSusOutline));
const mkSlideRibbonGeo = () => {
const nVert = 4 * (SLIDE_RIBBON_SAMPLES + 1);
const g = new T.BufferGeometry();
g.setAttribute('position', new T.Float32BufferAttribute(new Float32Array(nVert * 3), 3));
// SLIDE_RIBBON_INDICES_ARR is the plain-Array form (see module-init
// comment) shared across pool meshes; setIndex() rewraps it into a
// fresh Uint16BufferAttribute per geometry, so the share is safe.
g.setIndex(SLIDE_RIBBON_INDICES_ARR);
// Static cross-section normals: each ring is an axis-aligned quad,
// so vertex normals point radially in the XY plane regardless of
// the slide's Z-direction curvature. Pre-fill once and skip the
// per-frame computeVertexNormals() pass that previously ran on
// every sustained-slide update (Copilot perf finding on PR #215).
const SQRT_HALF = Math.SQRT1_2;
const normals = new Float32Array(nVert * 3);
for (let k = 0; k <= SLIDE_RIBBON_SAMPLES; k++) {
const o = k * 12;
// v0 (-X,-Y), v1 (+X,-Y), v2 (+X,+Y), v3 (-X,+Y)
normals[o] = -SQRT_HALF; normals[o + 1] = -SQRT_HALF; normals[o + 2] = 0;
normals[o + 3] = SQRT_HALF; normals[o + 4] = -SQRT_HALF; normals[o + 5] = 0;
normals[o + 6] = SQRT_HALF; normals[o + 7] = SQRT_HALF; normals[o + 8] = 0;
normals[o + 9] = -SQRT_HALF; normals[o + 10] = SQRT_HALF; normals[o + 11] = 0;
}
g.setAttribute('normal', new T.Float32BufferAttribute(normals, 3));
return g;
};
// Ribbon meshes mutate vertex positions every frame in
// slideRibbonUpdatePositions but the mesh itself stays at (0,0,0)
// and the geometry's bounding sphere is never recomputed. With
// frustum culling on, Three.js tests the (0,0,0)-centred bounds
// and culls the ribbon as soon as the camera pans away from world
// origin, so slides flicker in/out. Disable culling on these
// meshes — the ribbon footprint is small and they're already
// gated by t0/t1 reachability before render.
pSusRibbon = pool(noteG, () => {
const m = new T.Mesh(mkSlideRibbonGeo(), mSus[0]);
m.frustumCulled = false;
return m;
});
pSusRibbonOl = pool(noteG, () => {
const m = new T.Mesh(mkSlideRibbonGeo(), mSusOutline);
m.frustumCulled = false;
m.renderOrder = -3;
return m;
});
// One shared material per technique-mesh type. The pool factory
// hands out fresh meshes that all reference the same material,
// so a dense HO/PO passage doesn't churn N MeshLambertMaterial
// allocations and N GPU material switches.
// Transparent + no depth write/test so the tap chevron draws in
// the transparent pass where drawNote assigns renderOrder 1000.
mTapChevron = new T.MeshLambertMaterial({
color: 0xd4d4d4,
emissive: 0xd4d4d4,
emissiveIntensity: 0.9,
transparent: true,
opacity: 0.85,
side: T.DoubleSide,
depthWrite: false,
depthTest: false,
});
pTapChevron = pool(noteG, () => new T.Mesh(gTapChevron, mTapChevron));
pLbl = pool(lblG, () => new T.Sprite(txtMat('0', '#fff', false, 'technique')));
pBeat = pool(beatG, () => new T.Line(gBeat, mBeatQ));
pSec = pool(lblG, () => new T.Sprite(txtMat('', '#0dd', true, 'section')));
// Chord sustain length indicator — thin horizontal plane rails.
// Unit plane (1×1 in XZ) laid flat; scaled to (railWidth, 1, railLen).
// A horizontal plane seen from the camera looking down-forward is
// face-on and has real apparent thickness — unlike T.Line (always 1px).
// depthTest:false so they never occlude gems; renderOrder 11 places
// them above lane dividers (2) and chord fill (10), at the same level
// as chord frame edges (11), and BELOW sustain trails (12/13), note
// gems (dynamic ≥50), and arp brackets (18). The bloom halo (10) sits
// behind the core rail (11). Keeping susrail behind note sus trails
// prevents the rail border from covering individual note tails
// (sustain/vibrato/tremolo/bend). (Was 14/16 which rendered on top of
// 12/13 sus trails, causing the outer border to overlap tails.)
gSusRail = new T.PlaneGeometry(1, 1);
gSusRail.rotateX(-Math.PI / 2); // lay flat in XZ plane
mSusRailBase = new T.MeshBasicMaterial({
color: CHORD_BOX_TEAL_HEX,
transparent: true, opacity: 0.85,
depthTest: false, depthWrite: false,
fog: false, side: T.DoubleSide,
});
pSusRail = pool(noteG, () => {
const m = new T.Mesh(gSusRail, mSusRailBase.clone());
m.renderOrder = 5; // below strings (7) so strings render on top
return m;
});
// Bloom glow for chord sustain rails — wider plane with a gaussian
// falloff texture (bright centre → transparent edges in X direction)
// and additive blending, so it brightens whatever is behind it.
// renderOrder 4 places it behind the core rail (5).
_bloomGaussTex = _makeGaussTex(T);
gSusRailBloom = new T.PlaneGeometry(1, 1);
gSusRailBloom.rotateX(-Math.PI / 2);
mSusRailBloomBase = new T.MeshBasicMaterial({
color: CHORD_BOX_TEAL_HEX,
map: _bloomGaussTex,
transparent: true, opacity: 0.55,
blending: T.AdditiveBlending,
depthTest: false, depthWrite: false,
fog: false, side: T.DoubleSide,
});
pSusRailBloom = pool(noteG, () => {
const m = new T.Mesh(gSusRailBloom, mSusRailBloomBase.clone());
m.renderOrder = 4; // below strings (7) so strings render on top
return m;
});
// Rotatable plane pool for technique markers (pm, mt, hm, hp, H/P, bend).
// Unlike T.Sprite, a PlaneGeometry mesh accepts rotation.z = approachRot
// so markers stay coplanar with the gem as it tilts from vertical to flat.
gTechPlane = new T.PlaneGeometry(1, 1);
pTechPlane = pool(noteG, () => {
const m = new T.Mesh(gTechPlane, new T.MeshBasicMaterial({
transparent: true, depthTest: false, depthWrite: false, side: T.DoubleSide,
}));
m.renderOrder = 1000;
return m;
});
// ── InstancedMesh temporaries ──────────────────────────────────────
_imM4 = new T.Matrix4();
_imPos = new T.Vector3();
_imSca = new T.Vector3();
_imQ = new T.Quaternion();
_imAZ = new T.Vector3(0, 0, 1);
_imColor = new T.Color();
// ── Shared ShaderMaterial templates ───────────────────────────────
// Vertex shader used by PM-X and FH-X on individual note gems.
// Three.js injects `USE_INSTANCING` + `instanceMatrix` attribute
// into the prefix when an InstancedMesh uses a ShaderMaterial.
const _imTechVert = [
'attribute float instanceAlpha;',
'varying float vAlpha;',
'varying vec2 vUv;',
'void main() {',
' vUv = uv;',
' vAlpha = instanceAlpha;',
' vec4 pos = vec4(position, 1.0);',
' #ifdef USE_INSTANCING',
' pos = instanceMatrix * pos;',
' #endif',
' gl_Position = projectionMatrix * modelViewMatrix * pos;',
'}',
].join('\n');
const _imTechFrag = [
'uniform sampler2D map;',
'varying float vAlpha;',
'varying vec2 vUv;',
'void main() {',
' vec4 t = texture2D(map, vUv);',
' if (t.a * vAlpha < 0.01) discard;',
' gl_FragColor = vec4(t.rgb, t.a * vAlpha);',
'}',
].join('\n');
// ── PM / FH tech marker InstancedMeshes ───────────────────────────
// Each IM gets a geometry clone so instanceAlpha is a separate buffer.
const _mkTechIM = (spriteMat, alphaArr) => {
const geo = gTechPlane.clone();
const alphaAttr = new T.InstancedBufferAttribute(alphaArr, 1);
alphaAttr.setUsage(T.DynamicDrawUsage);
geo.setAttribute('instanceAlpha', alphaAttr);
const mat = new T.ShaderMaterial({
uniforms: { map: { value: spriteMat.map } },
vertexShader: _imTechVert,
fragmentShader: _imTechFrag,
transparent: true, depthTest: false, depthWrite: false, side: T.DoubleSide,
});
const im = new T.InstancedMesh(geo, mat, IM_TECH_CAP);
im.instanceMatrix.setUsage(T.DynamicDrawUsage);
im.frustumCulled = false;
im.count = 0;
noteG.add(im);
return { im, geo, mat };
};
{ const r = _mkTechIM(palmMuteXSpriteMat(), _imPMTechAlphaArr);
imPMTech = r.im; _imGPMTech = r.geo; _imPMTechMat = r.mat; imPMTech.renderOrder = 702; }
{ const r = _mkTechIM(fretHandMuteXSpriteMat(), _imFHTechAlphaArr);
imFHTech = r.im; _imGFHTech = r.geo; _imFHTechMat = r.mat; imFHTech.renderOrder = 700; }
// Dynamic fret number labels (heat-coloured, updated each frame)
pFretLbl = pool(lblG, () => new T.Sprite(txtMat('0', '#888', false, 'fretRow')));
// Highlight lane plane over active fret range. With the anchor-driven
// segmented lanes we render up to fret-count × HWY_LANE_TIME_SLICES (96)
// pLane meshes per frame, so:
// - geometry is a shared PlaneGeometry(1,1) (was per-mesh, never differed)
// - 2 shared MeshBasicMaterials (odd / even stripe colour) replace the
// per-mesh material clones; the per-frame opacity still travels via
// the materials but is set once outside the inner loop, not per-mesh.
gLanePlane = new T.PlaneGeometry(1, 1);
mLaneOdd = new T.MeshBasicMaterial({
color: HWY_LANE_STRIPE_ODD_HEX, transparent: true, opacity: 0, depthWrite: false,
});
mLaneEven = new T.MeshBasicMaterial({
color: HWY_LANE_STRIPE_EVEN_HEX, transparent: true, opacity: 0, depthWrite: false,
});
// Tracked for explicit disposal in teardown — these materials may
// not be reachable via scene.traverse() if no lane was ever rendered.
_ownedSharedMats.push(mLaneOdd, mLaneEven);
_ownedSharedGeos.push(gLanePlane);
pLane = pool(noteG, () => new T.Mesh(gLanePlane, mLaneOdd));
gGhostFretPlane = new T.PlaneGeometry(1, 1);
_ownedSharedGeos.push(gGhostFretPlane);
const mGhostFretLblPh = new T.MeshBasicMaterial({
color: 0xffffff, transparent: true, depthTest: false, depthWrite: false,
});
_ownedSharedMats.push(mGhostFretLblPh);
pGhostFretLbl = pool(noteG, () => {
const m = new T.Mesh(gGhostFretPlane, mGhostFretLblPh);
// Must be above the proj frame (renderOrder=14) and opaque
// geometry — same contract as technique labels (renderOrder=1000):
// depthTest:false alone is insufficient, renderOrder=1000 needed.
m.renderOrder = 1000;
m.frustumCulled = false;
return m;
});
// Vertical fret dividers within active lane
const gLaneDivider = new T.BoxGeometry(0.15 * K, 0.15 * K, 1);
mLaneDivider = new T.MeshBasicMaterial({
color: 0x46DDE6, transparent: true, opacity: 1.00, fog: false, depthWrite: false,
});
mLaneDividerArp = new T.MeshBasicMaterial({
color: ARPEGGIO_RIM_BLUE_HEX,
transparent: true, opacity: 0.08, fog: false, depthWrite: false,
});
mLaneDividerExt = new T.MeshBasicMaterial({
color: 0x364D5F, transparent: true, opacity: 0.4, fog: false, depthWrite: false,
});
_ownedSharedMats.push(mLaneDivider, mLaneDividerArp, mLaneDividerExt);
pLaneDivider = pool(noteG, () => new T.Mesh(gLaneDivider, mLaneDivider));
// Chord frame palette (frame alpha 128, fill gradient alpha 32; MeshBasic).
const chR = CHORD_BOX_TEAL_HEX >> 16 & 255;
const chG = CHORD_BOX_TEAL_HEX >> 8 & 255;
const chB = CHORD_BOX_TEAL_HEX & 255;
const dkR = CHORD_BOX_TEAL_DARK_HEX >> 16 & 255;
const dkG = CHORD_BOX_TEAL_DARK_HEX >> 8 & 255;
const dkB = CHORD_BOX_TEAL_DARK_HEX & 255;
const aFill = Math.round(CHORD_BOX_FILL_GRAD_ALPHA * 255);
chordFrameGradTex = new T.DataTexture(
new Uint8Array([ chR, chG, chB, aFill, dkR, dkG, dkB, aFill, chR, chG, chB, aFill ]),
3, 1, T.RGBAFormat);
chordFrameGradTex.magFilter = T.LinearFilter;
chordFrameGradTex.minFilter = T.LinearFilter;
chordFrameGradTex.wrapS = T.ClampToEdgeWrapping;
chordFrameGradTex.wrapT = T.ClampToEdgeWrapping;
// DataTexture defaults to linear color space; flag this gradient
// as sRGB so the chord-box hex values match other sRGB color textures.
chordFrameGradTex.colorSpace = T.SRGBColorSpace;
chordFrameGradTex.needsUpdate = true;
const arR = ARPEGGIO_BOX_BLUE_HEX >> 16 & 255;
const arG = ARPEGGIO_BOX_BLUE_HEX >> 8 & 255;
const arB = ARPEGGIO_BOX_BLUE_HEX & 255;
const arDR = ARPEGGIO_BOX_BLUE_DARK_HEX >> 16 & 255;
const arDG = ARPEGGIO_BOX_BLUE_DARK_HEX >> 8 & 255;
const arDB = ARPEGGIO_BOX_BLUE_DARK_HEX & 255;
chordFrameGradTexArp = new T.DataTexture(
new Uint8Array([ arR, arG, arB, aFill, arDR, arDG, arDB, aFill, arR, arG, arB, aFill ]),
3, 1, T.RGBAFormat);
chordFrameGradTexArp.magFilter = T.LinearFilter;
chordFrameGradTexArp.minFilter = T.LinearFilter;
chordFrameGradTexArp.wrapS = T.ClampToEdgeWrapping;
chordFrameGradTexArp.wrapT = T.ClampToEdgeWrapping;
chordFrameGradTexArp.colorSpace = T.SRGBColorSpace;
chordFrameGradTexArp.needsUpdate = true;
pChordFrameFill = pool(noteG, () => new T.Mesh(
new T.PlaneGeometry(1, 1),
new T.MeshBasicMaterial({
map: chordFrameGradTex,
transparent: true,
opacity: 1,
depthWrite: false,
depthTest: false,
fog: false,
side: T.DoubleSide,
}),
));
pChordBox = pool(noteG, () => new T.Mesh(
new T.BoxGeometry(1, 1, 1),
new T.MeshBasicMaterial({
color: CHORD_BOX_TEAL_HEX,
transparent: true,
opacity: CHORD_BOX_EDGE_ALPHA,
depthWrite: false,
depthTest: false,
fog: false,
side: T.DoubleSide,
}),
));
// PM strum X fill — 4 corner regions + centre; the 4 arms (L,R,T,B) are left empty.
// 16 vertices, 14 triangles.
// 0=A(-1,1) 1=TLC(-0.48,1) 2=T(-0.012,0.257) 3=TRC(0.5,1)
// 4=BR(1,1) 5=REB(1,0.5) 6=R(0.476,-0.011) 7=RET(1,-0.5)
// 8=C(1,-1) 9=BRC(0.48,-1) 10=B(-0.003,-0.276) 11=BLC(-0.48,-1)
// 12=D(-1,-1) 13=LET(-1,-0.5) 14=L(-0.494,-0.011) 15=LEB(-1,0.5)
{
// prettier-ignore
const pos = new Float32Array([
-1, 1, 0, // 0 A
-0.480, 1, 0, // 1 TLC
-0.012, 0.257, 0, // 2 T
0.500, 1, 0, // 3 TRC
1, 1, 0, // 4 BR
1, 0.5, 0, // 5 REB
0.476, -0.011, 0, // 6 R
1, -0.5, 0, // 7 RET
1, -1, 0, // 8 C
0.480, -1, 0, // 9 BRC
-0.003, -0.276, 0, // 10 B
-0.480, -1, 0, // 11 BLC
-1, -1, 0, // 12 D
-1, -0.5, 0, // 13 LET
-0.494, -0.011, 0, // 14 L
-1, 0.5, 0, // 15 LEB
]);
// prettier-ignore
const idx = new Uint16Array([
// top-left corner: A,TLC,T,L,LEB
0, 1, 2,
0, 2, 14,
0, 14, 15,
// top-right corner: TRC,BR,REB,R,T
3, 4, 5,
3, 5, 6,
3, 6, 2,
// centre: T,R,B,L
2, 6, 10,
2, 10, 14,
// bottom-right corner: RET,C,BRC,B,R
7, 8, 9,
7, 9, 10,
7, 10, 6,
// bottom-left corner: LET,L,B,BLC,D
13, 14, 10,
13, 10, 11,
13, 11, 12,
]);
gPMXFill = new T.BufferGeometry();
gPMXFill.setAttribute('position', new T.BufferAttribute(pos, 3));
gPMXFill.setIndex(new T.BufferAttribute(idx, 1));
}
// PM fill — InstancedMesh (black, varying alpha per chord).
{
const _imFillVert = [
'attribute float instanceAlpha;',
'varying float vAlpha;',
'void main() {',
' vAlpha = instanceAlpha;',
' vec4 pos = vec4(position, 1.0);',
' #ifdef USE_INSTANCING',
' pos = instanceMatrix * pos;',
' #endif',
' gl_Position = projectionMatrix * modelViewMatrix * pos;',
'}',
].join('\n');
const _imFillFrag = [
'varying float vAlpha;',
'void main() {',
' if (vAlpha <= 0.0) discard;',
' gl_FragColor = vec4(0.0, 0.0, 0.0, vAlpha);',
'}',
].join('\n');
const alphaAttr = new T.InstancedBufferAttribute(_imPMXFillAlphaArr, 1);
alphaAttr.setUsage(T.DynamicDrawUsage);
gPMXFill.setAttribute('instanceAlpha', alphaAttr);
_imPMXFillMat = new T.ShaderMaterial({
vertexShader: _imFillVert, fragmentShader: _imFillFrag,
transparent: true, depthTest: false, depthWrite: false,
fog: false, side: T.DoubleSide,
});
imPMXFill = new T.InstancedMesh(gPMXFill, _imPMXFillMat, IM_STRUM_CAP);
imPMXFill.instanceMatrix.setUsage(T.DynamicDrawUsage);
imPMXFill.frustumCulled = false;
imPMXFill.renderOrder = 10.5;
imPMXFill.count = 0;
noteG.add(imPMXFill);
}
// FH (frethand mute) strum X fill — 5 regions: 4 corner quadrants + centre diamond.
// 12 vertices, 10 triangles. L/R wings stop at fx=±0.50 (no solid lateral blocks).
// 0=LET(-0.50,+1) 1=TLC(-0.15,+1) 2=T(0,+0.42) 3=TRC(+0.15,+1)
// 4=RET(+0.50,+1) 5=REB(+0.50,-1) 6=R(+0.28,0) 7=B(0,-0.42)
// 8=BRC(+0.15,-1) 9=BLC(-0.15,-1) 10=LEB(-0.50,-1) 11=L(-0.28,0)
{
// prettier-ignore
const pos = new Float32Array([
-0.50, 1, 0, // 0 LET
-0.15, 1, 0, // 1 TLC
0, 0.42, 0, // 2 T
0.15, 1, 0, // 3 TRC
0.50, 1, 0, // 4 RET
0.50, -1, 0, // 5 REB
0.28, 0, 0, // 6 R
0, -0.42, 0, // 7 B
0.15, -1, 0, // 8 BRC
-0.15, -1, 0, // 9 BLC
-0.50, -1, 0, // 10 LEB
-0.28, 0, 0, // 11 L
]);
// prettier-ignore
const idx = new Uint16Array([
// top-left corner: LET,TLC,T,L
0, 1, 2,
0, 2, 11,
// top-right corner: TRC,RET,R,T
3, 4, 6,
3, 6, 2,
// bottom-right corner: REB,R,B,BRC
5, 6, 7,
5, 7, 8,
// bottom-left corner: LEB,L,B,BLC
10, 11, 7,
10, 7, 9,
// centre diamond: L,T,R,B
11, 2, 6,
11, 6, 7,
]);
gFHXFill = new T.BufferGeometry();
gFHXFill.setAttribute('position', new T.BufferAttribute(pos, 3));
gFHXFill.setIndex(new T.BufferAttribute(idx, 1));
}
// FH fill — InstancedMesh (black, varying alpha per chord).
{
const _imFillVert = [
'attribute float instanceAlpha;',
'varying float vAlpha;',
'void main() {',
' vAlpha = instanceAlpha;',
' vec4 pos = vec4(position, 1.0);',
' #ifdef USE_INSTANCING',
' pos = instanceMatrix * pos;',
' #endif',
' gl_Position = projectionMatrix * modelViewMatrix * pos;',
'}',
].join('\n');
const _imFillFrag = [
'varying float vAlpha;',
'void main() {',
' if (vAlpha <= 0.0) discard;',
' gl_FragColor = vec4(0.0, 0.0, 0.0, vAlpha);',
'}',
].join('\n');
const alphaAttr = new T.InstancedBufferAttribute(_imFHXFillAlphaArr, 1);
alphaAttr.setUsage(T.DynamicDrawUsage);
gFHXFill.setAttribute('instanceAlpha', alphaAttr);
_imFHXFillMat = new T.ShaderMaterial({
vertexShader: _imFillVert, fragmentShader: _imFillFrag,
transparent: true, depthTest: false, depthWrite: false,
fog: false, side: T.DoubleSide,
});
imFHXFill = new T.InstancedMesh(gFHXFill, _imFHXFillMat, IM_STRUM_CAP);
imFHXFill.instanceMatrix.setUsage(T.DynamicDrawUsage);
imFHXFill.frustumCulled = false;
imFHXFill.renderOrder = 10.5;
imFHXFill.count = 0;
noteG.add(imFHXFill);
}
// PM X lines — 8 segments baked as thin quads in ±1 normalised space.
// Scale the pool mesh by (innerW*0.5, -innerH*0.5, ...) per chord;
// the Y-negated scale matches the XLINES convention (fya>0 = below centre).
if (!gPMXLines) {
const HT = 0.016; // normalised half-thickness ≈ lw/hH for a typical chord
// prettier-ignore
const XLINES = [
[-1.000, -0.500, -0.494, -0.011],
[-1.000, 0.500, -0.494, -0.011],
[ 1.000, -0.500, 0.476, -0.011],
[ 1.000, 0.500, 0.476, -0.011],
[-0.480, 1.000, -0.012, 0.257],
[ 0.500, 1.000, -0.012, 0.257],
[ 0.480, -1.000, 0.000, -0.276],
[-0.480, -1.000, -0.006, -0.276],
];
const pos = new Float32Array(XLINES.length * 4 * 3);
const idx = new Uint16Array(XLINES.length * 6);
for (let i = 0; i < XLINES.length; i++) {
const [xa, ya, xb, yb] = XLINES[i];
const dx = xb - xa, dy = yb - ya;
const il = 1 / Math.sqrt(dx * dx + dy * dy);
const nx = -dy * il, ny = dx * il;
const vi = i * 12, ii = i * 6, vb = i * 4;
pos[vi+0]=xa+nx*HT; pos[vi+1]=ya+ny*HT; pos[vi+2]=0;
pos[vi+3]=xb+nx*HT; pos[vi+4]=yb+ny*HT; pos[vi+5]=0;
pos[vi+6]=xb-nx*HT; pos[vi+7]=yb-ny*HT; pos[vi+8]=0;
pos[vi+9]=xa-nx*HT; pos[vi+10]=ya-ny*HT; pos[vi+11]=0;
idx[ii+0]=vb; idx[ii+1]=vb+1; idx[ii+2]=vb+2;
idx[ii+3]=vb; idx[ii+4]=vb+2; idx[ii+5]=vb+3;
}
gPMXLines = new T.BufferGeometry();
gPMXLines.setAttribute('position', new T.BufferAttribute(pos, 3));
gPMXLines.setIndex(new T.BufferAttribute(idx, 1));
}
// PM lines — InstancedMesh (varying color + alpha per chord).
// instanceColor (THREE built-in) carries baseRimHex per instance;
// instanceAlpha carries the per-chord opacity.
{
const _imLinesVert = [
'attribute float instanceAlpha;',
'varying float vAlpha;',
'varying vec3 vColor;',
'void main() {',
' vAlpha = instanceAlpha;',
' #ifdef USE_INSTANCING_COLOR',
' vColor = instanceColor;',
' #else',
' vColor = vec3(1.0);',
' #endif',
' vec4 pos = vec4(position, 1.0);',
' #ifdef USE_INSTANCING',
' pos = instanceMatrix * pos;',
' #endif',
' gl_Position = projectionMatrix * modelViewMatrix * pos;',
'}',
].join('\n');
const _imLinesFrag = [
'varying float vAlpha;',
'varying vec3 vColor;',
'void main() {',
' if (vAlpha <= 0.0) discard;',
' gl_FragColor = vec4(vColor, vAlpha);',
'}',
].join('\n');
const alphaAttr = new T.InstancedBufferAttribute(_imPMXLinesAlphaArr, 1);
alphaAttr.setUsage(T.DynamicDrawUsage);
gPMXLines.setAttribute('instanceAlpha', alphaAttr);
_imPMXLinesMat = new T.ShaderMaterial({
vertexShader: _imLinesVert, fragmentShader: _imLinesFrag,
transparent: true, depthTest: false, depthWrite: false,
fog: false, side: T.DoubleSide,
});
imPMXLines = new T.InstancedMesh(gPMXLines, _imPMXLinesMat, IM_STRUM_CAP);
imPMXLines.instanceMatrix.setUsage(T.DynamicDrawUsage);
imPMXLines.frustumCulled = false;
imPMXLines.renderOrder = 11;
// Eagerly initialise instanceColor so USE_INSTANCING_COLOR is
// defined when the shader is compiled on the first draw.
_imColor.set(1, 1, 1);
imPMXLines.setColorAt(0, _imColor);
imPMXLines.instanceColor.setUsage(T.DynamicDrawUsage);
imPMXLines.count = 0;
noteG.add(imPMXLines);
}
// FH X lines — same scheme, 8 segments from the FH_XLINES pattern
if (!gFHXLines) {
const HT = 0.022; // slightly wider — FH wings are shorter, need more visual weight
// prettier-ignore
const FH_XLINES = [
[-0.50, 1.00, -0.28, 0.00],
[-0.50, -1.00, -0.28, 0.00],
[ 0.50, 1.00, 0.28, 0.00],
[ 0.50, -1.00, 0.28, 0.00],
[-0.15, -1.00, 0.00, -0.42],
[ 0.15, -1.00, 0.00, -0.42],
[ 0.15, 1.00, 0.00, 0.42],
[-0.15, 1.00, 0.00, 0.42],
];
const pos = new Float32Array(FH_XLINES.length * 4 * 3);
const idx = new Uint16Array(FH_XLINES.length * 6);
for (let i = 0; i < FH_XLINES.length; i++) {
const [xa, ya, xb, yb] = FH_XLINES[i];
const dx = xb - xa, dy = yb - ya;
const il = 1 / Math.sqrt(dx * dx + dy * dy);
const nx = -dy * il, ny = dx * il;
const vi = i * 12, ii = i * 6, vb = i * 4;
pos[vi+0]=xa+nx*HT; pos[vi+1]=ya+ny*HT; pos[vi+2]=0;
pos[vi+3]=xb+nx*HT; pos[vi+4]=yb+ny*HT; pos[vi+5]=0;
pos[vi+6]=xb-nx*HT; pos[vi+7]=yb-ny*HT; pos[vi+8]=0;
pos[vi+9]=xa-nx*HT; pos[vi+10]=ya-ny*HT; pos[vi+11]=0;
idx[ii+0]=vb; idx[ii+1]=vb+1; idx[ii+2]=vb+2;
idx[ii+3]=vb; idx[ii+4]=vb+2; idx[ii+5]=vb+3;
}
gFHXLines = new T.BufferGeometry();
gFHXLines.setAttribute('position', new T.BufferAttribute(pos, 3));
gFHXLines.setIndex(new T.BufferAttribute(idx, 1));
}
// FH lines — InstancedMesh (varying color + alpha per chord).
{
const _imLinesVert = [
'attribute float instanceAlpha;',
'varying float vAlpha;',
'varying vec3 vColor;',
'void main() {',
' vAlpha = instanceAlpha;',
' #ifdef USE_INSTANCING_COLOR',
' vColor = instanceColor;',
' #else',
' vColor = vec3(1.0);',
' #endif',
' vec4 pos = vec4(position, 1.0);',
' #ifdef USE_INSTANCING',
' pos = instanceMatrix * pos;',
' #endif',
' gl_Position = projectionMatrix * modelViewMatrix * pos;',
'}',
].join('\n');
const _imLinesFrag = [
'varying float vAlpha;',
'varying vec3 vColor;',
'void main() {',
' if (vAlpha <= 0.0) discard;',
' gl_FragColor = vec4(vColor, vAlpha);',
'}',
].join('\n');
const alphaAttr = new T.InstancedBufferAttribute(_imFHXLinesAlphaArr, 1);
alphaAttr.setUsage(T.DynamicDrawUsage);
gFHXLines.setAttribute('instanceAlpha', alphaAttr);
_imFHXLinesMat = new T.ShaderMaterial({
vertexShader: _imLinesVert, fragmentShader: _imLinesFrag,
transparent: true, depthTest: false, depthWrite: false,
fog: false, side: T.DoubleSide,
});
imFHXLines = new T.InstancedMesh(gFHXLines, _imFHXLinesMat, IM_STRUM_CAP);
imFHXLines.instanceMatrix.setUsage(T.DynamicDrawUsage);
imFHXLines.frustumCulled = false;
imFHXLines.renderOrder = 11;
_imColor.set(1, 1, 1);
imFHXLines.setColorAt(0, _imColor);
imFHXLines.instanceColor.setUsage(T.DynamicDrawUsage);
imFHXLines.count = 0;
noteG.add(imFHXLines);
}
// Pool-based strum-indicator replacements. The IM approach above uses
// a fixed renderOrder per mesh type, which lets far-chord X marks overdraw
// gems/frames of nearer chords. Pools give per-chord Z-proportional renderOrder.
// Geometries are shared with the (now empty) IMs — MeshBasicMaterial
// ignores the instanceAlpha / instanceColor attributes on the geometry.
pPMXFill = pool(noteG, () => new T.Mesh(
gPMXFill,
new T.MeshBasicMaterial({
color: 0x000000, transparent: true, opacity: 1,
depthWrite: false, depthTest: false, fog: false, side: T.DoubleSide,
}),
));
pFHXFill = pool(noteG, () => new T.Mesh(
gFHXFill,
new T.MeshBasicMaterial({
color: 0x000000, transparent: true, opacity: 1,
depthWrite: false, depthTest: false, fog: false, side: T.DoubleSide,
}),
));
pMuteXLines = pool(noteG, () => new T.Mesh(
gPMXLines,
new T.MeshBasicMaterial({
color: 0xffffff, transparent: true, opacity: 1,
depthWrite: false, depthTest: false, fog: false, side: T.DoubleSide,
}),
));
pFHXLines = pool(noteG, () => new T.Mesh(
gFHXLines,
new T.MeshBasicMaterial({
color: 0xffffff, transparent: true, opacity: 1,
depthWrite: false, depthTest: false, fog: false, side: T.DoubleSide,
}),
));
pChordLbl = pool(lblG, () => new T.Sprite(txtMat('', '#e8d080', true, 'chord').clone()));
// Single shared barre material — all pool meshes reference it,
// so _applyGlow() can mutate emissiveIntensity once and every
// recycled / future-allocated barre mesh picks up the change.
mBarre = new T.MeshLambertMaterial({ color: 0xffffff, emissive: 0xffffff, emissiveIntensity: 0.9 * glowMul, transparent: true, depthWrite: false });
pBarreLine = pool(noteG, () => new T.Mesh(new T.BoxGeometry(1, 1, 1), mBarre));
// Shared 1×1×1 box geometry — brackets can require many pooled
// meshes per frame, so per-mesh BoxGeometry allocation would
// duplicate buffers and create unnecessary GPU disposal work.
// Disposed once in teardown (alongside the other shared geos).
if (!gArpBracket) gArpBracket = new T.BoxGeometry(1, 1, 1);
pArpBracket = pool(noteG, () => new T.Mesh(
gArpBracket,
new T.MeshBasicMaterial({
color: 0xffffff,
transparent: true,
opacity: 1.0,
depthWrite: false,
depthTest: false,
fog: false,
}),
));
// Per-note fret number below note with connector line
pNoteFretLabel = pool(lblG, () => {
const _nfl = new T.Sprite(txtMat('0', FRET_LABEL_GOLD_HEX, false, 'noteFret').clone());
_nfl.material.fog = false;
_nfl.material.depthTest = false;
return _nfl;
});
pConnectorLine = pool(noteG, () => new T.Line(
new T.BufferGeometry().setFromPoints([new T.Vector3(0, 0, 0), new T.Vector3(0, 1, 0)]),
new T.LineBasicMaterial({ color: 0xaaaaaa, transparent: true, opacity: 0.5, depthTest: false }),
));
pDropLine = pool(noteG, () => new T.Line(
new T.BufferGeometry().setFromPoints([new T.Vector3(0, 0, 0), new T.Vector3(0, 1, 0)]),
new T.LineBasicMaterial({ color: 0xffffff, transparent: true, opacity: 0.35 }),
));
// Fret-column reference markers (visual cue for X-position to fret-number).
// Each sprite gets its own clone so the per-frame material.map swap
// (dark vs light grey) doesn't poison neighbours sharing the same
// cached texture map.
// fog:false prevents the scene fog from gradually dimming the sprite
// as it enters the far end of the highway — opacity is managed
// manually with a short fade-in so the number appears at its
// final size the moment it becomes visible rather than seeming to
// emerge from a tiny dim spec at the horizon.
pFretColMarker = pool(lblG, () => {
const _sp = new T.Sprite(txtMat('0', '#666666', false, 'noteFret').clone());
// fog=false: prevents scene fog from dimming the sprite as it enters the
// far end of the highway. Opacity is managed by the manual fade-in ramp
// so the number appears smoothly instead of emerging as a dim spec.
_sp.material.fog = false;
return _sp;
});
// ── Pre-warm pools (slopsmith#226) ─────────────────────────────
// Dense 7/8-string charts can outrun the lazy-grow path in the
// first 1-2s of playback, stalling those frames with `new T.Mesh`
// allocations *and* growing noteG forever (the pool only hides on
// reset). Pay the cost up front instead.
//
// Trade-off: pre-warming attaches the same meshes to noteG even
// on 4/6-string charts that may never use them all. The cost is
// paid at boardInit (during the load spinner — wall-clock time
// users were already waiting on), so the steady-state win on
// playback FPS is worth the init-time scene-graph footprint.
// Caps sized for a typical visible-window worst case (NOT the
// theoretical max across MAX_RENDER_STRINGS); lazy growth past
// the warm cap still works for genuinely dense outliers.
const _WARM_NOTE = 48;
const _WARM_CHORD = 12;
const _WARM_LANE = 32;
const _WARM_BEAT = 24;
pNote.warm(_WARM_NOTE);
pNoteEdge.warm(_WARM_NOTE);
pAccentHalo.warm(_WARM_NOTE);
pSus.warm(_WARM_NOTE);
pSusOutline.warm(_WARM_NOTE);
pSusRibbon.warm(_WARM_NOTE / 2);
pSusRibbonOl.warm(_WARM_NOTE / 2);
pTapChevron.warm(_WARM_CHORD);
pLbl.warm(_WARM_NOTE);
pSusRail.warm(_WARM_CHORD);
pSusRailBloom.warm(_WARM_CHORD);
pTechPlane.warm(_WARM_CHORD);
pNoteFretLabel.warm(_WARM_NOTE);
pChordFrameFill.warm(_WARM_CHORD);
pChordBox.warm(_WARM_CHORD);
pChordLbl.warm(_WARM_CHORD);
pBarreLine.warm(_WARM_CHORD);
pArpBracket.warm(_WARM_CHORD);
pHaloBar.warm(_WARM_CHORD);
pFretLbl.warm(_WARM_LANE);
pLane.warm(_WARM_LANE * 2); // anchor-driven lanes × time slices
pLaneDivider.warm(_WARM_LANE);
pGhostFretLbl.warm(_WARM_LANE);
pFretColMarker.warm(_WARM_LANE);
pConnectorLine.warm(_WARM_NOTE / 2);
pDropLine.warm(_WARM_NOTE / 2);
pBeat.warm(_WARM_BEAT);
pSec.warm(8);
_bgLoadSettings();
buildBoard();
// Background animations (#13). Read settings keyed by this
// panel and mount the active style's meshes. Subscribe to
// in-app settings changes (settings.html via window.h3dBgSet*)
// so they propagate without a reload. Manual localStorage
// edits don't fire the pub-sub and require a reload.
// Push the freshly-loaded vibrancy/glow values into the
// materials. _bgLoadSettings only triggers a palette re-apply
// when the palette ID actually changed, so a fresh-init user
// on the default palette would otherwise keep the hardcoded
// construction-time material values until they touched a
// slider.
_applyVibrancy();
_applyGlow();
// inlayLabelsVisible was applied before buildBoard() via _bgLoadSettings.
bgGroup = new T.Group();
// Note: renderOrder on a Group is a no-op (Three.js Groups
// are transforms, not rendered objects, so renderOrder only
// affects the actual meshes inside). _bgMountStyle stamps
// renderOrder = -1 on every child after build, which IS what
// forces background to render before gameplay geometry.
// Combined with the deeper-than-note-range placements below,
// background never paints over notes.
scene.add(bgGroup);
_bgMountStyle();
_bgListener = (changedKey) => {
if (changedKey === 'inlayLabelsVisible') {
_bgLoadSettings();
// Flip visibility on the already-built sprites; no
// need to rebuild the board (cheaper, preserves the
// shared materials and avoids palette re-apply churn).
for (const lbl of _inlayLabels) lbl.visible = inlayLabelsVisible;
return;
}
if (changedKey === 'nutHeadstockVisible') {
_bgLoadSettings();
if (nutHeadstockGroup) nutHeadstockGroup.visible = nutHeadstockVisible;
return;
}
if (changedKey === 'tuningLabelsVisible') {
_bgLoadSettings();
_lastOpenStringLblSig = '';
if (_tuningLabelSprites.length) _disposeOpenStringPitchSprites();
return;
}
if (changedKey === 'nutColor' || changedKey === 'headstockColor') {
_bgLoadSettings();
if (fretG) buildBoard();
for (const lbl of _inlayLabels) lbl.visible = inlayLabelsVisible;
return;
}
if (changedKey === 'reactive' || changedKey === 'showFretOnNote' ||
changedKey === 'fretNumberGhostScope' ||
changedKey === 'cameraSmoothing' || changedKey === 'zoomSmoothing' ||
changedKey === 'tiltSmoothing' || changedKey === 'cameraLockLow' ||
changedKey === 'cameraLockZoom' || changedKey === 'cameraMode' ||
changedKey === 'textSize' ||
changedKey === 'chordDiagramSize' || changedKey === 'chordDiagramPosition' ||
changedKey === 'fretColumnMarkerCadence' ||
changedKey === 'sectionLabelsOnHighway' ||
changedKey === 'sectionHudVisible' ||
changedKey === 'sectionHudPosition' ||
changedKey === 'sectionHudSize' ||
changedKey === 'toneHudVisible' ||
changedKey === 'toneHudPosition' ||
changedKey === 'toneHudSize' ||
changedKey === 'projectionVisible' ||
changedKey === 'slideArrowApproachVisible' ||
changedKey === 'slideArrowNeckVisible' ||
changedKey === 'slideArrowChainPreviewVisible') {
// Flag flips don't need a mesh rebuild — just refresh
// the per-instance state for the next frame to consult.
// Same shape for showFretOnNote (#12), cameraSmoothing
// (#34), the zoom/tilt smoothing follow-ups, and
// cameraLockLow — all read per-frame in update() /
// camUpdate().
_bgLoadSettings();
return;
}
if (changedKey === 'vibrancy') {
_bgLoadSettings();
_applyVibrancy();
return;
}
if (changedKey === 'glow') {
_bgLoadSettings();
_applyGlow();
return;
}
if (changedKey === 'palette') {
// Palette change has three effects:
// 1. _bgLoadSettings -> _applyPaletteToMaterials
// retints the per-instance shared materials
// (notes, glows, sustain trails, projection).
// 2. buildBoard rebuilds the fretboard meshes
// (LineBasicMaterial lane lines + per-string
// BoxGeometry materials). These are created at
// build time with palette-baked colors and
// aren't reachable from _applyPaletteToMaterials.
// 3. lights bg style bakes palette colors into
// sprite quads at build time, so it needs a
// full mesh rebuild — fire _bgRebuild when
// that style is active.
_bgLoadSettings();
if (fretG) buildBoard();
if (bgStyleId === 'lights') _bgRebuild();
return;
}
if (changedKey === 'customImageDataUrl') {
// Asset bytes changed. Rebuild only when the image
// style is active — otherwise the new bytes will
// pick up next time the user picks `image`.
_bgLoadSettings();
if (bgStyleId === 'image') _bgRebuild();
return;
}
if (changedKey === 'customImageName') {
// Display-only metadata; no mesh rebuild.
_bgLoadSettings();
return;
}
if (changedKey === 'customVideoName') {
// Filename change → new <video> source. Rebuild
// only when the video style is currently active;
// otherwise the new bytes pick up next time the
// user picks `video`.
_bgLoadSettings();
if (bgStyleId === 'video') _bgRebuild();
return;
}
if (changedKey === 'intensity') {
_bgLoadSettings();
// Image style reads s.intensity per frame inside
// update() to scale the drift speed, so a live
// mutation is enough — no need to tear down and
// re-decode the texture for every slider change.
// The procedural styles bake intensity into mesh
// count, opacity, and size at build time, so they
// still need a full rebuild.
if (bgStyleId === 'image' && bgState) {
bgState.intensity = bgIntensity;
return;
}
_bgRebuild();
return;
}
if (changedKey === 'venueScene') {
_bgRebuild();
return;
}
if (changedKey === 'venueInstrumentPov') {
if (_bgEffectiveStyleId() === 'venue' && bgState) {
_venueSwapPlateIfNeeded(bgState);
}
return;
}
if (!changedKey || changedKey === 'style') {
_bgRebuild();
}
};
_bgSubscribe(_bgListener);
// Notedetect feedback (#9). Listen for hit/miss events on
// window. Notedetect dispatches both globally and on its
// instanceRoot; the global fire is fine for our case since
// each 3dhighway panel just stores any event into its own
// queue and renders only the matching note. Listeners are
// per-panel so destroy() can cleanly remove them; cost is
// a per-event branch + push, negligible vs per-frame work.
// Validate every payload field we'll later compare against
// chart-data fields (s, f, t). drawNote compares with
// Math.abs(m.noteTime - n.t) and trusts the values are
// finite, so reject any payload missing one of those
// fields here rather than letting bogus data into the
// arrays. Prune expired marks on every push so the arrays
// settle back to empty when notedetect stops emitting —
// drawNote's fast-path short-circuit
// (`if (_ndHitMarks.length || _ndMissMarks.length)`) only
// works if expired entries don't linger.
const _ndNormalizeMark = (d) => {
if (!d) return null;
const note = d.note || d.chartNote;
if (!note) return null;
if (!Number.isFinite(note.s) || !Number.isFinite(note.f) || !Number.isFinite(d.noteTime)) return null;
const labels = [];
if (d.timingState && d.timingState !== 'OK' && Number.isFinite(d.timingError)) {
labels.push({
text: `${d.timingState === 'EARLY' ? '↑' : '↓'} ${d.timingError > 0 ? '+' : ''}${d.timingError}ms`,
color: '#ffb347',
});
}
if (d.pitchState && d.pitchState !== 'OK' && Number.isFinite(d.pitchError)) {
labels.push({
text: `${d.pitchState === 'SHARP' ? '♯' : '♭'} ${d.pitchError > 0 ? '+' : ''}${d.pitchError}¢`,
color: '#66c7ff',
});
}
return { s: note.s, f: note.f, noteTime: d.noteTime, labels };
};
const _ndPushMark = (arr, d) => {
const mark = _ndNormalizeMark(d);
if (!mark) return arr;
const now = performance.now();
// Prune expired entries unconditionally. The dedupe path
// below can extend expiresAt of any entry (including arr[0]),
// so an arr[0] gate is not reliable — it would prevent
// pruning entries that expired behind a refreshed front
// entry, allowing the array to grow unbounded. These arrays
// are tiny (a handful of marks at most), so an unconditional
// filter() is negligible and always correct.
if (arr.length !== 0) {
const live = arr.filter(m => m.expiresAt > now);
arr.length = 0;
if (live.length) arr.push(...live);
}
const existing = arr.find(m =>
m.s === mark.s && m.f === mark.f && Math.abs(m.noteTime - mark.noteTime) < _ND_TIME_EPS
);
if (existing) {
existing.labels = mark.labels.length ? mark.labels : existing.labels;
existing.expiresAt = Math.max(existing.expiresAt, now + _ND_TTL_MS);
return arr;
}
arr.push({ ...mark, expiresAt: now + _ND_TTL_MS });
return arr;
};
_ndOnHit = (e) => { _ndHitMarks = _ndPushMark(_ndHitMarks, e.detail); };
_ndOnMiss = (e) => { _ndMissMarks = _ndPushMark(_ndMissMarks, e.detail); };
window.addEventListener('notedetect:hit', _ndOnHit);
window.addEventListener('notedetect:miss', _ndOnMiss);
if (window.slopsmith &&
typeof window.slopsmith.on === 'function' &&
typeof window.slopsmith.off === 'function') {
_ndOnBusHit = (e) => { _ndHitMarks = _ndPushMark(_ndHitMarks, e.detail); };
_ndOnBusMiss = (e) => { _ndMissMarks = _ndPushMark(_ndMissMarks, e.detail); };
window.slopsmith.on('note:hit', _ndOnBusHit);
window.slopsmith.on('note:miss', _ndOnBusMiss);
}
// Score FX (notedetect ≥1.13). notedetect dispatches each fx
// detail object twice in the same task: first explicitly on
// window (unscoped), then as a bubbling CustomEvent from its
// per-panel instanceRoot (scoped). Element-targeted copies are
// authoritative — accept only the ones whose root lives in this
// panel's container. The window copy is DEFERRED a task: by the
// time it runs, the element copy (same detail reference) has
// either arrived — making the window copy a duplicate to drop —
// or it never will (detector root not attached to the DOM), in
// which case the window copy is the compat fallback. This keeps
// splitscreen panels from rendering each other's FX even for
// the first event of a session.
_fxResolvePalette();
_fxOnFx = (e) => {
const d = e && e.detail;
if (!d) return;
const t = e.target;
if (t && t.parentElement) {
_fxElemSeen.add(d);
if (!highwayCanvas || !t.parentElement.contains(highwayCanvas)) return;
_fxHandle(d);
return;
}
const gen = _fxGen;
setTimeout(() => {
if (gen !== _fxGen) return; // torn down (or re-inited) meanwhile
if (_fxElemSeen.has(d)) return;
_fxHandle(d);
}, 0);
};
window.addEventListener('notedetect:fx', _fxOnFx);
if (window.slopsmith && typeof window.slopsmith.on === 'function'
&& typeof window.slopsmith.off === 'function') {
_fxOnSkin = () => _fxResolvePalette();
window.slopsmith.on('notedetect:skin', _fxOnSkin);
}
return true;
}
function _bgLoadSettings() {
const panelKey = _bgPanelKey(highwayCanvas);
bgStyleId = _bgReadSetting(panelKey, 'style');
bgIntensity = _bgReadSetting(panelKey, 'intensity');
bgReactive = _bgReadSetting(panelKey, 'reactive');
// Per-render opt-out (captured from the mount bundle in init): force
// the reactive background off for THIS instance, overriding the shared
// setting without writing it back. Re-applied here so it sticks across
// setting reloads.
if (_bgReactiveOptOut) bgReactive = false;
const newPaletteId = _bgReadSetting(panelKey, 'palette');
let newPalette;
if (newPaletteId === 'custom') {
// Resolve user colors into the stable _customPalette array,
// mutated in place so the reference identity is preserved.
let stored = null;
const raw = _bgReadSetting(panelKey, 'customColors');
if (typeof raw === 'string') { try { stored = JSON.parse(raw); } catch (_) { /* corrupt */ } }
for (let i = 0; i < _customPalette.length; i++) {
const v = Array.isArray(stored) ? _h3dHexToInt(stored[i]) : null;
_customPalette[i] = (v != null) ? v : PALETTES.default[i];
}
newPalette = _customPalette;
} else {
newPalette = PALETTES[newPaletteId] || PALETTES.default;
}
// Signature guards the in-place custom case: when the user edits a
// color the reference stays === activePalette, so compare contents
// too to force a retint. _bgPaletteSig caches the applied colors.
const newSig = newPalette.join(',');
if (newPalette !== activePalette || newSig !== _bgPaletteSig) {
activePalette = newPalette;
_bgPaletteSig = newSig;
_applyPaletteToMaterials();
}
showFretOnNote = _bgReadSetting(panelKey, 'showFretOnNote');
fretNumberGhostScope = _bgReadSetting(panelKey, 'fretNumberGhostScope');
cameraSmoothing = _bgReadSetting(panelKey, 'cameraSmoothing');
// Mirror-at-first-read: zoom + tilt sliders inherit cameraSmoothing
// when the user has never explicitly written them. Once the user
// moves either slider, the corresponding _bgHasStored() flips
// true and the read becomes independent.
zoomSmoothing = _bgHasStored(panelKey, 'zoomSmoothing')
? _bgReadSetting(panelKey, 'zoomSmoothing')
: cameraSmoothing;
tiltSmoothing = _bgHasStored(panelKey, 'tiltSmoothing')
? _bgReadSetting(panelKey, 'tiltSmoothing')
: cameraSmoothing;
cameraLockLow = _bgReadSetting(panelKey, 'cameraLockLow');
cameraLockZoom = _bgReadSetting(panelKey, 'cameraLockZoom');
cameraMode = _bgReadSetting(panelKey, 'cameraMode');
textSize = _bgReadSetting(panelKey, 'textSize');
vibrancy = _bgReadSetting(panelKey, 'vibrancy');
glowMul = _bgReadSetting(panelKey, 'glow');
fpsVisible = _bgReadSetting(panelKey, 'fpsVisible');
fretDividersVisible = _bgReadSetting(panelKey, 'fretDividersVisible');
chordDiagramVisible = _bgReadSetting(panelKey, 'chordDiagramVisible');
chordDiagramSize = _bgReadSetting(panelKey, 'chordDiagramSize');
chordDiagramPosition = _bgReadSetting(panelKey, 'chordDiagramPosition');
fretColumnMarkerCadence = _bgReadSetting(panelKey, 'fretColumnMarkerCadence');
inlayLabelsVisible = _bgReadSetting(panelKey, 'inlayLabelsVisible');
sectionLabelsOnHighway = _bgReadSetting(panelKey, 'sectionLabelsOnHighway');
sectionHudVisible = _bgReadSetting(panelKey, 'sectionHudVisible');
sectionHudPosition = _bgReadSetting(panelKey, 'sectionHudPosition');
sectionHudSize = _bgReadSetting(panelKey, 'sectionHudSize');
toneHudVisible = _bgReadSetting(panelKey, 'toneHudVisible');
toneHudPosition = _bgReadSetting(panelKey, 'toneHudPosition');
toneHudSize = _bgReadSetting(panelKey, 'toneHudSize');
nutHeadstockVisible = _bgReadSetting(panelKey, 'nutHeadstockVisible');
tuningLabelsVisible = _bgReadSetting(panelKey, 'tuningLabelsVisible');
nutColor = _bgReadSetting(panelKey, 'nutColor');
headstockColor = _bgReadSetting(panelKey, 'headstockColor');
projectionVisible = _bgReadSetting(panelKey, 'projectionVisible');
slideArrowApproachVisible = _bgReadSetting(panelKey, 'slideArrowApproachVisible');
slideArrowNeckVisible = _bgReadSetting(panelKey, 'slideArrowNeckVisible');
slideArrowChainPreviewVisible = _bgReadSetting(panelKey, 'slideArrowChainPreviewVisible');
_vibrancyIdleOp = 0.4 + 0.6 * vibrancy;
_vibrancyProjOp = 0.15 + 0.35 * vibrancy;
// Custom image asset is a single GLOBAL slot — bytes are
// shared across panels (per-panel choice is which style
// each panel renders, not which asset). Reading via
// _bgReadSetting would let a stray h3d_bg_panel<idx>_*
// override silently re-introduce the per-panel asset
// duplication this design deliberately avoids (and
// h3dBgClearCustomImage wouldn't reach those overrides).
// Read globals directly instead.
//
// Precedence: in-memory fallback BEFORE localStorage. The
// setter always populates _bgMemFallback (even when the
// localStorage write fails on quota), so the fallback
// holds the most-recent staged value. Reading localStorage
// first would mean a failed write leaves the renderer
// pointed at the previous asset while settings.html shows
// a "session-only" warning claiming the new bytes are in
// effect — UI and renderer would silently disagree.
const memDataUrl = _bgMemFallback.customImageDataUrl;
const memName = _bgMemFallback.customImageName;
try {
const gDataUrl = (memDataUrl !== undefined) ? memDataUrl : localStorage.getItem('h3d_bg_customImageDataUrl');
const gName = (memName !== undefined) ? memName : localStorage.getItem('h3d_bg_customImageName');
bgCustomImageDataUrl = (gDataUrl != null) ? gDataUrl : BG_DEFAULTS.customImageDataUrl;
bgCustomImageName = (gName != null) ? gName : BG_DEFAULTS.customImageName;
} catch (_) {
bgCustomImageDataUrl = (memDataUrl !== undefined) ? memDataUrl : BG_DEFAULTS.customImageDataUrl;
bgCustomImageName = (memName !== undefined) ? memName : BG_DEFAULTS.customImageName;
}
// Custom video filename: also a single global slot, same
// mem-first precedence as the image keys (a quota-failed
// setItem leaves _bgMemFallback ahead of localStorage).
const memVideoName = _bgMemFallback.customVideoName;
try {
const gVideoName = (memVideoName !== undefined) ? memVideoName : localStorage.getItem('h3d_bg_customVideoName');
bgCustomVideoName = (gVideoName != null) ? gVideoName : BG_DEFAULTS.customVideoName;
} catch (_) {
bgCustomVideoName = (memVideoName !== undefined) ? memVideoName : BG_DEFAULTS.customVideoName;
}
}
// Live-swap palette by mutating existing materials in place.
// Three.js colors propagate to all sharing meshes on the next
// render — no rebuild, no GC. The mGlow material was authored
// with .color = white and the per-string color in .emissive
// only; we preserve that here so the glow look stays consistent
// before/after a palette swap rather than tinting the diffuse
// white. Lane lines and drop lines that read
// activePalette[s] per frame pick up automatically. Per-string
// fretboard materials built inside buildBoard() are independent
// and aren't reachable from here — buildBoard re-runs from the
// palette listener to regenerate them with the new colors.
//
// projMeshArr holds filled rim meshes (ExtrudeGeometry frame); centre
// is open. Palette + vibrancy mutate each mesh's material like mStr.
function _applyPaletteToMaterials() {
for (let s = 0; s < activePalette.length; s++) {
const c = activePalette[s];
if (mStr[s]) {
// Gradient strings (0..5) keep a white base so the per-vertex
// colours in gNoteGrad[s] show pure; only flat strings (6/7)
// take the palette colour. mStr is MeshBasicMaterial (no
// emissive) — guard the legacy emissive retint.
if (s >= 6) mStr[s].color.setHex(c);
if (mStr[s].emissive) mStr[s].emissive.setHex(c);
}
if (mGlow[s]) mGlow[s].emissive.setHex(c);
if (mSus[s]) mSus[s].color.setHex(c);
if (mStrHitOutline[s]) {
mStrHitOutline[s].color.setHex(c);
mStrHitOutline[s].emissive.setHex(c);
}
if (mAccentOutline[s]) {
mAccentOutline[s].color.setHex(c);
mAccentOutline[s].emissive.setHex(c);
}
if (mAccentCore[s]) mAccentCore[s].emissive.setHex(c);
// Verdict materials use fixed colours (0x22ff88 hit, 0xff0066 miss)
// that are independent of the string palette — no retint needed.
for (const haloArr of [mAccentHaloNear, mAccentHaloMid, mAccentHaloFar]) {
if (haloArr[s]) haloArr[s].color.setHex(c);
}
if (projMeshArr && projMeshArr[s]) {
for (const pm of projMeshArr[s]) {
if (pm.material) {
pm.material.color.setHex(c);
pm.material.emissive.setHex(c);
}
}
}
}
// Per-string gem bodies (strings 0..5) are a baked per-vertex
// gradient (gNoteGrad), not a flat material — recolor them too so a
// custom palette reaches the note/sustain/vibrato gem bodies.
_recolorGemGradients();
// Re-apply vibrancy: mGlow's color is a lerp between white and
// the palette colour, so a palette swap must rebuild that
// lerp from the new endpoints. Skipped pre-init when mGlow
// isn't allocated yet — _applyVibrancy() guards on that.
_applyVibrancy();
}
// Recompute the per-vertex gem-gradient colors from the active palette.
// Built-in palettes (and unchanged slots of a custom palette) keep the
// hand-tuned DEFAULT_GEM_GRADIENTS stops so the stock look is preserved;
// a custom slot derives a top-highlight / bottom-shade from its base
// color. Mutates the existing 'color' attribute in place (no geometry
// churn, pooled note meshes pick it up next frame).
function _recolorGemGradients() {
if (!T || !gNoteGrad || !gNoteGrad.length) return;
const isCustom = (activePalette === _customPalette);
const topCol = new T.Color(), botCol = new T.Color(), tmp = new T.Color();
const halfH = NH / 2;
for (let s = 0; s < gNoteGrad.length; s++) {
const g = gNoteGrad[s];
if (!g || !g.attributes || !g.attributes.color) continue;
const base = activePalette[s];
let topHex, botHex;
if (isCustom && base !== PALETTES.default[s]) {
// Match the SUBTLE stock gem shading (bottom ≈ 0.78 of a
// near-base top), so a custom gem reads as a flat-ish gem
// in the chosen color rather than a strong gradient.
topHex = _lightenInt(base, 0.05);
botHex = _darkenInt(base, 0.78);
} else {
const stops = DEFAULT_GEM_GRADIENTS[s];
if (!stops) continue; // strings 6/7 have no gradient geometry
topHex = stops[0];
botHex = stops[1];
}
topCol.setHex(topHex);
botCol.setHex(botHex);
const pos = g.attributes.position;
const colAttr = g.attributes.color;
for (let i = 0; i < pos.count; i++) {
const t = (pos.getY(i) + halfH) / (2 * halfH); // 0 bottom..1 top
tmp.copy(botCol).lerp(topCol, t);
colAttr.setXYZ(i, tmp.r, tmp.g, tmp.b);
}
colAttr.needsUpdate = true;
}
}
// Vibrancy + glow live-update helpers. Both walk the same
// material set _applyPaletteToMaterials walks (plus the static
// outline / technique materials) and mutate uniform-backed
// properties — colour, opacity, emissiveIntensity. No
// material.needsUpdate flag is needed for these; Three.js
// re-reads them on the next render call. mGlow.emissiveIntensity
// and BASE_GLOW/MAX_GLOW/IDLE_OP are NOT written here — those
// are stomped per-frame inside updateStringHighlights() and the
// anticipation loop in update(), so they read glowMul /
// _vibrancyIdleOp / vibrancy directly each frame instead.
function _applyVibrancy() {
const t = vibrancy;
const idleOp = 0.4 + 0.6 * t; // mStr / IDLE_OP source
// projIdleOp drives the projMeshArr ghost-frame opacity and is
// read by drawNote() as `_vibrancyProjOp`, which layers a
// per-frame factor on top.
const projIdleOp = 0.15 + 0.35 * t;
const susOp = 0.35 + 0.45 * t; // mSus
const lineGlowOp = 0.15 + 0.35 * t; // thin Line glow layer behind each string
for (let s = 0; s < activePalette.length; s++) {
if (mStr[s]) mStr[s].opacity = idleOp;
if (mSus[s]) mSus[s].opacity = susOp;
if (mGlow[s]) {
// Hit-note body lerps from white (current pastel
// look — colour comes through the emissive only)
// toward the palette colour as vibrancy → 1, so at
// vibrancy=1 the white-wash on hit notes goes away.
if (!_paletteColorTmp && T) _paletteColorTmp = new T.Color();
if (_paletteColorTmp) {
mGlow[s].color.setHex(0xffffff).lerp(_paletteColorTmp.setHex(activePalette[s]), t);
}
}
if (mAccentCore[s]) {
if (!_paletteColorTmp && T) _paletteColorTmp = new T.Color();
if (_paletteColorTmp) {
mAccentCore[s].color.setHex(0xffffff).lerp(_paletteColorTmp.setHex(activePalette[s]), t);
}
}
if (projMeshArr && projMeshArr[s]) {
for (const pm of projMeshArr[s]) {
if (pm.material) pm.material.opacity = projIdleOp;
}
}
}
// stringLines[s].material.opacity is overwritten by
// updateStringHighlights() every frame, so the closed-form
// value would be stomped. updateStringHighlights() reads
// _vibrancyIdleOp directly instead — keep that in sync.
for (let s = 0; s < stringLineGlows.length; s++) {
const line = stringLineGlows[s];
if (line && line.material) line.material.opacity = lineGlowOp;
}
_vibrancyIdleOp = idleOp;
_vibrancyProjOp = projIdleOp;
}
function _applyGlow() {
const g = glowMul;
for (let s = 0; s < activePalette.length; s++) {
if (mStr[s]) mStr[s].emissiveIntensity = 0.002 * g;
// mGlow[s].emissiveIntensity is per-frame in update();
// see Phase 4 comment block.
if (projMeshArr && projMeshArr[s]) {
for (const pm of projMeshArr[s]) {
if (pm.material) pm.material.emissiveIntensity = 0.002 * g;
}
}
if (mStrHitOutline[s]) mStrHitOutline[s].emissiveIntensity = 1.0 * g;
if (mAccentOutline[s]) mAccentOutline[s].emissiveIntensity = ACCENT_RIM_BASE_EMISSIVE * g;
// mAccentCore[].emissiveIntensity is per-frame in update()
// alongside mGlow (accent fill boost).
}
if (mWhiteOutline) mWhiteOutline.emissiveIntensity = 0.6 * g;
if (mMissOutline) mMissOutline.emissiveIntensity = 1.2 * g;
for (let s = 0; s < mHitBright.length; s++) {
if (mHitBright[s]) mHitBright[s].emissiveIntensity = 4.0 * g;
}
if (mSusOutline) mSusOutline.emissiveIntensity = 0.3 * g;
if (mHitSusOutline) mHitSusOutline.emissiveIntensity = 0.7 * g;
if (mTapChevron) mTapChevron.emissiveIntensity = 0.9 * g;
if (mBarre) mBarre.emissiveIntensity = 0.9 * g;
for (let si = 0; si < activePalette.length; si++) {
if (mAccentHaloNear[si]) mAccentHaloNear[si].opacity = ACCENT_HALO_OP_NEAR * g;
if (mAccentHaloMid[si]) mAccentHaloMid[si].opacity = ACCENT_HALO_OP_MID * g;
if (mAccentHaloFar[si]) mAccentHaloFar[si].opacity = ACCENT_HALO_OP_FAR * g;
}
}
function _bgEffectiveStyleId() {
return _venueSceneOverride ? 'venue' : bgStyleId;
}
function _bgMountStyle() {
const effectiveId = _bgEffectiveStyleId();
const style = BG_STYLES[effectiveId] || BG_STYLES.off;
// Build into a fresh stage group so a partial throw can't
// orphan meshes inside bgGroup. On success the stage joins
// bgGroup atomically; on failure the stage and everything
// in it are disposed and bgState stays null.
const stage = new T.Group();
let result = null;
try {
result = style.build(stage, {
intensity: bgIntensity,
palette: activePalette,
customImageDataUrl: bgCustomImageDataUrl,
customVideoName: bgCustomVideoName,
cam: cam,
}) || null;
} catch (e) {
console.error('[3D-Hwy] bg style build failed', effectiveId, e);
_bgDisposeGroupTree(stage);
bgState = null;
bgStage = null;
bgMountedStyleId = null;
return;
}
// renderOrder on a Group doesn't propagate to its children
// (Three.js sorts by per-object renderOrder, and a Group is a
// transform, not a rendered object). Stamp every mesh in the
// stage so transparent bg objects always sort behind notes
// regardless of their z relative to gameplay geometry.
stage.traverse((c) => { c.renderOrder = -1; });
bgGroup.add(stage);
bgStage = stage;
bgState = result;
bgMountedStyleId = effectiveId;
}
function _bgUnmountStyle() {
const mountedId = bgMountedStyleId || _bgEffectiveStyleId();
const style = BG_STYLES[mountedId] || BG_STYLES.off;
try { style.teardown(bgState); } catch (e) { console.error('[3D-Hwy] bg teardown', e); }
bgState = null;
// Belt + suspenders: even if a style's teardown forgets to
// dispose something, the stage tree dispose mops up.
if (bgStage) {
bgStage.parent?.remove(bgStage);
_bgDisposeGroupTree(bgStage);
bgStage = null;
}
bgMountedStyleId = null;
}
// Recursively dispose geometries / materials attached to an
// Object3D tree, then detach. Used as a safety net during
// _bgMountStyle failures and on _bgUnmountStyle.
//
// Deliberately does NOT dispose material.map textures — texture
// lifetime belongs to whoever allocated the texture. The
// silhouettes style allocates a per-layer CanvasTexture wrapping
// the shared _silCanvas bitmap, and disposes those textures in
// its own teardown. Disposing them here would double-dispose,
// and any future plugin texture sharing across panels (e.g. an
// upcoming custom-background feature) would break the same way.
// Style teardown owns texture release.
function _bgDisposeGroupTree(obj) {
if (!obj) return;
obj.traverse((child) => {
child.geometry?.dispose?.();
const mat = child.material;
if (mat) {
const mats = Array.isArray(mat) ? mat : [mat];
for (const m of mats) m?.dispose?.();
}
});
obj.parent?.remove(obj);
}
function _bgRebuild() {
if (!bgGroup) return;
// Order matters: teardown must run against the (style id,
// state) pair that built the meshes, so unmount BEFORE
// reloading settings. Reload, then mount with the new id.
_bgUnmountStyle();
_bgLoadSettings();
_bgMountStyle();
_bgApplyVenueSceneFog(_venueSceneOverride);
// Reset dt accounting so the first frame after a switch
// doesn't see a huge "since last update" window — that
// would clamp to 0.1 and visibly snap motion / rotation.
_bgLastT = 0;
}
// Venue-only fog/clear/ambient tuning — darker near field, less
// washed-out gray haze over the playable highway. Restored when
// venue deactivates.
function _bgApplyVenueSceneFog(active) {
if (!scene || !scene.fog) return;
if (active) {
scene.fog.color.setHex(0x080c12);
scene.fog.near = FOG_START * 0.98;
scene.fog.far = FOG_END * 0.98;
if (ren) ren.setClearColor(0x080c12);
if (ambLight) ambLight.intensity = 0.68;
} else {
scene.fog.color.setHex(0x101820);
scene.fog.near = FOG_START * 0.8;
scene.fog.far = FOG_END * 1.2;
if (ren) ren.setClearColor(0x101820);
if (ambLight) ambLight.intensity = 0.85;
}
}
/* ── Fretboard (static geometry) ────────────────────────────────── */
function _h3dHexOrDefault(hexStr, defHex) {
const d = defHex || BG_DEFAULTS.nutColor;
const s = (typeof hexStr === 'string' && /^#[0-9a-fA-F]{6}$/.test(hexStr.trim()))
? hexStr.trim().toLowerCase()
: d;
return parseInt(s.slice(1), 16);
}
function buildBoard() {
// Dispose before clearing (traverse: nut/headstock may live in a Group).
while (fretG.children.length) {
const child = fretG.children[0];
child.traverse((o) => {
if (o instanceof T.Sprite) return;
// fretTubeGeo is shared across all fret meshes — disposing it
// per-mesh here would fire one redundant dispose event per
// fret. Skip it; it's disposed exactly once below.
if (o.geometry !== fretTubeGeo) o.geometry?.dispose?.();
const mat = o.material;
if (mat) {
const mats = Array.isArray(mat) ? mat : [mat];
for (const m of mats) m?.dispose?.();
}
});
fretG.remove(child);
}
stringLines = [];
stringLineGlows = [];
// Fret wire materials were already disposed by the child.traverse()
// above (each is attached 1:1 to a fret mesh) — just clear the
// tracking array. The shared fretTubeGeo was skipped by that
// traverse, so dispose it exactly once here.
fretWireMats = [];
fretTubeGeo?.dispose?.();
fretTubeGeo = null;
const board = boardSpanX();
const bw = board.width + 4 * K;
// Fretboard plane — spans exactly from hit line (Z=0) to the note
// spawn horizon (-AHEAD * TS), so the far edge aligns with AHEAD.
const blAhead = TS * AHEAD;
const pg = new T.PlaneGeometry(bw, blAhead);
const pm = new T.MeshLambertMaterial({ color: 0x08080e, transparent: true, opacity: 0.6 });
const p = new T.Mesh(pg, pm);
p.rotation.x = -Math.PI / 2;
p.position.set(board.center, S_BASE - NH / 2 - 2 * K, -blAhead / 2);
fretG.add(p);
// Thin Line strings (glow layer). Retained in stringLineGlows[]
// so vibrancy slider changes can mutate opacity in place
// without rebuilding the board geometry.
// Nut lateral layout (matches headstock block below): playing strings start at the
// fretboard-facing edge so they never project through nut/headstock.
const mir = _leftyCached ? -1 : 1;
const nutLenX = 1.55 * K;
const nutXC = -0.78 * K * mir;
const xHeadLeft = -6.85 * K * mir;
const nutRearX = nutXC - nutLenX * 0.5;
const nutFrontX = nutXC + nutLenX * 0.5;
const nutJoinX = nutFrontX + 0.03 * K;
const bridgeTipX = xFret(NFRETS) + 2 * K * mir;
boardStringStartX = Math.min(nutJoinX, bridgeTipX);
boardTuningLabelX = (nutRearX + xHeadLeft) * 0.5 - 0.15 * K * mir;
const stringEndX = Math.max(nutJoinX, bridgeTipX);
const strSpan = Math.max(stringEndX - boardStringStartX, 1.5 * K);
const lineGlowOp = 0.15 + 0.35 * vibrancy;
for (let s = 0; s < nStr; s++) {
const pts = [new T.Vector3(boardStringStartX, sY(s), 0), new T.Vector3(stringEndX, sY(s), 0)];
const g = new T.BufferGeometry().setFromPoints(pts);
const line = new T.Line(g, new T.LineBasicMaterial({ color: activePalette[s], transparent: true, opacity: lineGlowOp }));
line.renderOrder = 7; // above sus rails (4/5), below chord fill (10)
fretG.add(line);
stringLineGlows.push(line);
}
// BoxGeometry strings — emissive glow driven by updateStringHighlights()
for (let s = 0; s < nStr; s++) {
const g = new T.BoxGeometry(strSpan, STR_THICK, STR_THICK);
// Each string gets its own material instance so emissiveIntensity is per-string
// (and per-frame opacity is set by updateStringHighlights via _vibrancyIdleOp)
const mat = new T.MeshStandardMaterial({
color: activePalette[s], emissive: activePalette[s],
emissiveIntensity: 0.002,
transparent: true, opacity: _vibrancyIdleOp, roughness: 1,
});
const mesh = new T.Mesh(g, mat);
mesh.renderOrder = renderOrderForLayerAtZ(0, 'BOARD_STRING');
mesh.position.set(boardStringStartX + strSpan * 0.5, sY(s), 0);
fretG.add(mesh);
stringLines.push(mesh);
}
// Guitar nut + headstock — grouped so visibility + colors are user-tunable.
{
nutHeadstockGroup = new T.Group();
const yTopN = Math.max(sY(0), sY(nStr - 1));
const yBottomN = Math.min(sY(0), sY(nStr - 1));
const yMidN = (yTopN + yBottomN) / 2;
const spanY = Math.abs(yTopN - yBottomN) + S_GAP * 1.05;
const nutD = 0.95 * K;
const nutZc = -0.62 * K;
const nutH = spanY * 1.06;
const nutHalfH = nutH * 0.5;
const zBack = -1.38 * K;
const zJoint = -0.58 * K;
const nutInt = _h3dHexOrDefault(nutColor, BG_DEFAULTS.nutColor);
const hsInt = _h3dHexOrDefault(headstockColor, BG_DEFAULTS.headstockColor);
const nutBase = new T.Color(nutInt);
const nutHi = nutBase.clone().lerp(new T.Color(0xffffff), 0.14);
const nutGro = nutBase.clone().multiplyScalar(0.72);
const hsBase = new T.Color(hsInt);
const hsDarkC = hsBase.clone().multiplyScalar(0.76);
const mapleMat = new T.MeshStandardMaterial({
color: hsBase, roughness: 0.55, metalness: 0.02,
});
const mapleDark = new T.MeshStandardMaterial({
color: hsDarkC, roughness: 0.62, metalness: 0.02,
});
const coreLen = Math.max(Math.abs(nutRearX - xHeadLeft), 2 * K);
const coreCX = (nutRearX + xHeadLeft) * 0.5;
const headCoreD = 1.05 * K;
const headCore = new T.Mesh(
new T.BoxGeometry(coreLen, spanY * 1.12, headCoreD),
mapleDark,
);
headCore.position.set(coreCX, yMidN, zBack - headCoreD * 0.35);
nutHeadstockGroup.add(headCore);
const xs = 14;
const ys = 12;
const yLo = yMidN - spanY * 0.58;
const yHi = yMidN + spanY * 0.58;
const posR = new Float32Array((xs + 1) * (ys + 1) * 3);
const idxR = [];
let ri = 0;
for (let j = 0; j <= ys; j++) {
const v = j / ys;
const wy = yLo + v * (yHi - yLo);
const yArc = 1 - Math.abs((wy - yMidN) / (spanY * 0.55 + 1e-6));
const yArcCl = Math.max(0, Math.min(1, yArc));
for (let i = 0; i <= xs; i++) {
const u = i / xs;
const wx = xHeadLeft + u * (nutRearX - xHeadLeft);
const smooth = Math.sin(u * Math.PI * 0.5);
let wz = zBack + (zJoint - zBack) * smooth;
wz += 0.14 * K * yArcCl * yArcCl;
posR[ri++] = wx;
posR[ri++] = wy;
posR[ri++] = wz;
}
}
const row = xs + 1;
for (let j = 0; j < ys; j++) {
for (let i = 0; i < xs; i++) {
const a = j * row + i;
const b = a + row;
idxR.push(a, b, a + 1, b, b + 1, a + 1);
}
}
const rampGeo = new T.BufferGeometry();
rampGeo.setAttribute('position', new T.BufferAttribute(posR, 3));
rampGeo.setIndex(idxR);
rampGeo.computeVertexNormals();
nutHeadstockGroup.add(new T.Mesh(rampGeo, mapleMat));
const boneMat = new T.MeshStandardMaterial({
color: nutBase, roughness: 0.38, metalness: 0.02,
});
const boneTop = new T.MeshStandardMaterial({
color: nutHi, roughness: 0.32, metalness: 0.02,
});
const grooveMat = new T.MeshStandardMaterial({
color: nutGro, roughness: 0.85, metalness: 0,
});
const nutBody = new T.Mesh(
new T.BoxGeometry(nutLenX, nutH, nutD),
boneMat,
);
nutBody.position.set(nutXC, yMidN, nutZc);
nutHeadstockGroup.add(nutBody);
const crownR = nutLenX * 0.52;
const crownSeg = new T.CylinderGeometry(
crownR, crownR, nutLenX * 0.92, 20, 1, true,
Math.PI * 0.08, Math.PI * 0.42,
);
const crown = new T.Mesh(crownSeg, boneTop);
crown.rotation.z = Math.PI * 0.5;
crown.position.set(
nutXC,
yMidN + nutHalfH - 0.02 * K,
nutZc + nutD * 0.22,
);
nutHeadstockGroup.add(crown);
const slotDrop = 0.11 * K;
const slotHalfW = STR_THICK * 1.15;
const slotZ = nutZc + nutD * 0.12;
for (let st = 0; st < nStr; st++) {
const gr = new T.Mesh(
new T.BoxGeometry(slotHalfW * 2, slotDrop, nutD * 0.42),
grooveMat,
);
gr.position.set(nutXC, sY(st), slotZ);
nutHeadstockGroup.add(gr);
}
nutHeadstockGroup.visible = nutHeadstockVisible;
fretG.add(nutHeadstockGroup);
}
// Fret wires — bowed metal TubeGeometry (backported from
// highway_babylon). Board-string and fret-wire layers live in
// RENDER_ORDER_LAYER_STACK so the fretboard draws above note
// symbols and below fret labels.
// Tube (not T.Line): WebGL ignores linewidth > 1px on almost all
// platforms, so Line objects always render as hairlines. The tube
// bows in Z (middle strings pushed away from camera) so the row of
// frets reads as wrapping a cylindrical neck — see FRET_BOW_DZ.
// MeshStandardMaterial (vs the old flat MeshBasic): the scene's
// ambient+directional light glints across the rounded surface for a
// polished-steel look; the per-frame gold albedo (in-anchor) then
// reads as brass. depthTest:false: string BoxGeometry (MeshStandard,
// depthWrite:true) writes depth at Z=+STR_THICK/2; wires near Z=0
// would fail the depth test at string pixels despite higher layer.
// Colors are updated each frame by the fretWireMats loop in update():
// default → gray 0x666688, opacity 0.4
// in-anchor→ gold 0xD8A636, opacity 0.8 (same as FRET_LABEL_GOLD_HEX)
const yTop = Math.max(sY(0), sY(nStr - 1));
const yBottom = Math.min(sY(0), sY(nStr - 1));
const wireH = (yTop + S_GAP * 0.3) - (yBottom - S_GAP * 0.3);
const wireMidY = (yTop + yBottom) / 2;
// Single shared geometry centered at x=0, local Y -half..+half,
// bowed in Z by FRET_BOW_DZ * [0,0.6,1,0.6,0]. Reused by every fret
// (only mesh position differs). Symmetric in Y → invert/lefty-safe.
const yHalf = wireH * 0.5;
const zMults = [0, 0.6, 1, 0.6, 0];
const tubePath = zMults.map((zm, i) => new T.Vector3(
0,
-yHalf + (wireH * i) / (zMults.length - 1),
FRET_BOW_DZ * zm,
));
const tubeCurve = new T.CatmullRomCurve3(tubePath);
fretTubeGeo = new T.TubeGeometry(
tubeCurve, FRET_TUBE_SEG, FRET_TUBE_RADIUS, FRET_TUBE_RADIAL, false,
);
for (let f = 0; f <= NFRETS; f++) {
const x = xFret(f);
const mat = new T.MeshStandardMaterial({
color: 0x666688, metalness: FRET_METALNESS, roughness: FRET_ROUGHNESS,
emissive: FRET_EMISSIVE,
// depthWrite:false (matches other transparent overlays here):
// a transparent fret must not write depth or it can occlude
// later-drawn transparent elements despite depthTest:false.
transparent: true, opacity: 0.4, depthTest: false, depthWrite: false,
});
const fw = new T.Mesh(fretTubeGeo, mat);
fw.position.set(x, wireMidY, 0);
fw.renderOrder = renderOrderForLayerAtZ(0, 'BOARD_FRET_WIRE');
fretG.add(fw);
fretWireMats[f] = mat;
}
// Fret dots — flat circles (CircleGeometry) lying in the XY plane and
// facing +Z so they always appear as perfect circles from the camera.
// depthWrite:false so they don't steal the depth buffer from the
// transparent string meshes. Slight negative Z recessed under the
// string plane. Radius 10% below the former 1.5*K dots.
const dotRZ = (1.5 * K * 0.9);
const dg = new T.CircleGeometry(dotRZ, 64);
const dm = new T.MeshBasicMaterial({
color: 0x556677,
transparent: true,
opacity: 1,
depthWrite: false,
});
const dotZBack = -STR_THICK * 0.85;
const my = (sY(0) + sY(nStr - 1)) / 2;
const addDot = (x, y) => {
const d = new T.Mesh(dg, dm);
d.position.set(x, y, dotZBack);
// Above the dynamic lane (1) and its dividers (2) so the
// translucent blue lane no longer paints over and hides the
// inlay; still well below strings / wires / notes,
// so those keep drawing on top of the inlay.
d.renderOrder = 3;
fretG.add(d);
};
for (const f of DOTS) {
const cx = xFretMid(f);
if (DDOTS.has(f)) {
addDot(cx, my - S_GAP * 0.7);
addDot(cx, my + S_GAP * 0.7);
} else {
addDot(cx, my);
}
}
// Fret inlay number labels — sprites sitting just behind the hit line
// (Z = -K) so camera-distance sorting in the transparent pass puts
// them before notes at Z = 0, letting notes paint on top.
// Materials are cloned from the txtMat cache with depthWrite:false so
// the sprites don't write stale depth values that would clip incoming
// notes (which arrive from large negative Z). Clones are tracked in
// _inlayMats for explicit disposal on rebuild and destroy().
// Scale uses (0.5 + textSize) directly — _textSizeMul is stale here
// (only refreshed at the top of update()); update() rescales live.
for (const m of _inlayMats) m.dispose();
_inlayMats = [];
_inlayLabels = [];
for (const f of INLAY_LABEL_FRETS) {
const mat = txtMat(f, '#7abfcc', false, 'fretRow').clone();
mat.depthWrite = false;
mat.opacity = 0.55;
const lbl = new T.Sprite(mat);
const scale = 5.5 * (0.5 + textSize) * fretLabelScaleForFret(f);
lbl.scale.set(scale * K, scale * K, 1);
lbl.position.set(xFretMid(f), yTop - S_GAP * 0.4, -K);
lbl.visible = inlayLabelsVisible;
fretG.add(lbl);
_inlayLabels.push(lbl);
_inlayMats.push(mat);
}
}
/* ── String glow (called each frame) ────────────────────────────── */
function updateStringHighlights(noteState) {
// Glow slider scales both the idle floor and anticipation peak,
// so glowMul=0 fully silences the per-string emissive pulse.
// Vibrancy controls the idle opacity floor — anticipation
// still rides on top regardless of vibrancy so play-feedback
// through the opacity channel survives even at glowMul=0.
//
// Folded with the post-noteState mGlow / mAccentCore writes
// (was a separate `for (s = 0; s < nStr)` loop in update()),
// so the per-string scratch arrays stay hot in L1 across all
// material writes for a given string.
const BASE_GLOW = 0.02 * glowMul;
const MAX_GLOW = 3.5 * glowMul;
const IDLE_OP = _vibrancyIdleOp;
const g = glowMul;
const venueGemMul = _venueSceneOverride ? VENUE_GEM_EMISSIVE_MUL : 1;
for (let s = 0; s < nStr; s++) {
const mesh = stringLines[s];
if (mesh) {
const intensity = Math.max(
noteState.stringSustain[s] ? 1 : 0,
noteState.stringAnticipation[s] || 0,
);
mesh.material.emissiveIntensity = BASE_GLOW + intensity * MAX_GLOW;
mesh.material.opacity = IDLE_OP + intensity * (1 - IDLE_OP);
mesh.scale.set(1, 1 + intensity * 0.3, 1 + intensity * 0.3);
}
// Hit-note emissive — same write pattern as the standalone
// loop that previously lived at update()'s post-call site.
// The glow slider scales it here since this assignment
// stomps anything _applyGlow() set statically.
const bg = noteState.strGlow[s] * g;
if (mGlow[s]) mGlow[s].emissiveIntensity = bg * venueGemMul;
if (mAccentCore[s]) {
mAccentCore[s].emissiveIntensity =
(bg + noteState.accentFillBoost[s] * g) * venueGemMul;
}
}
}
/* ── Lookahead fret bounds + smooth camera ───────────────────────── */
// End time of the lookahead window = start of the measure that is
// CAM_LOOKAHEAD_MEASURES measures ahead of the current one. Uses the
// _measureStarts cache (times of beats with measure !== -1). With no
// beats it falls back to CAM_LOOKAHEAD_SEC seconds. Past the last known
// measure it extrapolates using the average measure duration.
function lookaheadEndTime(now) {
const ms = _measureStarts;
if (!ms || ms.length === 0) return now + CAM_LOOKAHEAD_SEC;
// Binary search: lo = first index with ms[lo] > now.
let lo = 0, hi = ms.length;
while (lo < hi) { const mid = (lo + hi) >> 1; if (ms[mid] <= now) lo = mid + 1; else hi = mid; }
const curIdx = lo - 1; // current measure (-1 if before the first)
const targetIdx = curIdx + CAM_LOOKAHEAD_MEASURES;
if (targetIdx >= 0 && targetIdx < ms.length) return ms[targetIdx];
// Past the last measure: extrapolate using the average measure duration.
if (ms.length >= 2) {
const avg = (ms[ms.length - 1] - ms[0]) / (ms.length - 1);
if (avg > 0) return ms[ms.length - 1] + (targetIdx - (ms.length - 1)) * avg;
}
return now + CAM_LOOKAHEAD_SEC;
}
function lookaheadComputeFretBounds(now, anchors, notes, chords) {
const tEnd = lookaheadEndTime(now);
let minF = 99;
let maxF = 0;
let any = false;
if (anchors && anchors.length) {
for (let tt = now; tt <= tEnd + 1e-9; tt += 0.125) {
const a = getChartAnchorAt(anchors, tt);
if (!a) continue;
let fStart = Math.round(Number(a.fret));
if (!Number.isFinite(fStart) || fStart < 1) fStart = 1;
let w = Number(a.width);
if (!Number.isFinite(w)) w = 4;
w = Math.max(1, Math.round(w));
const fHi = Math.min(NFRETS, fStart + w - 1);
minF = Math.min(minF, fStart);
maxF = Math.max(maxF, fHi);
any = true;
}
}
const consider = f => {
if (!(f > 0)) return;
minF = Math.min(minF, f);
maxF = Math.max(maxF, f);
any = true;
};
if (notes) {
let i = lowerBoundT(notes, now);
for (; i < notes.length; i++) {
const n = notes[i];
if (n.t > tEnd) break;
if (!validString(n.s)) continue;
consider(n.f);
}
}
if (chords) {
let i = lowerBoundT(chords, now);
for (; i < chords.length; i++) {
const ch = chords[i];
if (ch.t > tEnd) break;
if (!ch.notes) continue;
for (const cn of ch.notes) {
if (!validString(cn.s)) continue;
consider(cn.f);
}
}
}
if (!any || minF > maxF) return null;
return { minF, maxF };
}
function lookaheadTargetWorldX(minF, maxF) {
const wb = CAM_FRET_EDGE_BLEND;
const middle = (xFretMid(minF) + xFretMid(maxF)) * 0.5;
const weighted = 0.6 * xFret(0) + 0.4 * xFret(NFRETS);
return middle * (1 - wb) + weighted * wb;
}
function lookaheadSmoothCamStep(dtSec, tgtXWorld, tgtSpanInt) {
const d = Math.min(0.2, Math.max(1e-4, dtSec));
const fs = 1 - Math.pow(1 - CAM_FOCUS_BLEND_RATE, d);
_lookaheadCamX = tgtXWorld * fs + _lookaheadCamX * (1 - fs);
_lookaheadFretSpan = tgtSpanInt * fs + _lookaheadFretSpan * (1 - fs);
}
/* ── Camera target helper ────────────────────────────────────────── */
// Compute and apply tgtX + tgtDist from note-window-accumulated data.
// Used by BOTH the snap pre-pass (before drawNote() calls, skipDistHyst=true)
// and the main per-frame camera-target block (skipDistHyst=false) so the
// two paths can never drift out of sync.
//
// wX/wSum recency-weighted fret-position centroid accumulator
// distMin/Max min/max fret seen in the camera targeting window
// distGot true iff at least one fretted note was in the window
// camHystF X-axis hysteresis factor (from cameraSmoothing)
// camDistHystF dist hysteresis factor (from zoomSmoothing)
// skipDistHyst true on the snap/first-data frame — no previous tgtDist
// state exists, so bypass the dead-zone gate
//
// Side-effects: updates tgtX, tgtDist, prevLowFretBonus.
// Returns: computed lockActive flag (caller is responsible for setting
// prevLockActive from the returned value).
function _applyNoteCamTargets(wX, wSum, distMin, distMax, distGot,
camHystF, camDistHystF, skipDistHyst) {
const lockActive = cameraLockLow && (!distGot || distMax <= 12);
if (lockActive) {
// Locked view: frets 0-12 fit in frame, with the peak
// low-fret bonus baked in so nut chords stay framed.
// Both halves derive from the same helpers as the
// dynamic branch so future tuning of the base zoom
// curve or low-fret pullback can't desync them.
const lockedBaseU = camBaseDistU(12);
const lockedBonusU = camLowFretPullbackU(1);
// cameraLockZoom slider 0..1 blends between MIN (closest)
// and MAX (furthest). Default 0.5 maps to ~1.0× so existing
// users see the same locked view as before this slider.
const lockZoomMul = CAM_LOCK_ZOOM_MIN +
(CAM_LOCK_ZOOM_MAX - CAM_LOCK_ZOOM_MIN) * cameraLockZoom;
tgtX = xFretMid(CAM_LOCK_CENTER_FRET);
tgtDist = (lockedBaseU + lockedBonusU) * K * lockZoomMul;
prevLowFretBonus = lockedBonusU;
} else if (distGot) {
// Base zoom scales by fret count (distMax - distMin).
const baseDistU = camBaseDistU(distMax - distMin);
// Low-fret pullback: world-X distance between frets is
// logarithmic, so a 2-fret span at the nut takes much
// more horizontal screen than the same span at fret 12.
// The base term scales by *fret count*, not world-X
// span, so low-fret clusters were under-allotted camera
// distance and clipped at the left edge (e.g. F power
// chord at fret 1 partially off-screen). Add a tapered
// bonus that kicks in below fret 5 and peaks at fret 1
// (≈16 extra fret-span units, i.e. 16*K world-units of
// distance), without affecting mid/high neck framing.
const lowFretBonusU = camLowFretPullbackU(distMin);
if (skipDistHyst) {
// First data frame — no previous tgtDist state; apply
// directly without the hysteresis dead-zone check.
tgtDist = (baseDistU + lowFretBonusU) * K;
} else {
// tgtDist scales at (3 * K) per fret-span unit, so the
// hysteresis threshold (a fret-span dead zone) converts
// to tgtDist-space by multiplying by 3 * K — NOT by
// FRET_WIDTH_MID, which is X-axis world-units-per-fret
// and a different unit (would over-tighten the gate by
// ~4x at SCALE = 2.25).
//
// Hysteresis is applied to the BASE portion only. The
// lowFretBonus changes by 4 fret-span units per integer
// fret near the nut, which sits below the default-
// cameraSmoothing (cs=0.5) dead zone of ~8.25 fret-span
// units (= 2.75 * 3) and would otherwise be suppressed
// for fret 2 → 1 / 3 → 1 transitions — exactly the
// corrections this bonus exists to provide. So gate the
// base, then always reflect bonus changes on top by
// tracking the last-committed bonus contribution
// (prevLowFretBonus) and adjusting tgtDist for its
// delta whether or not the base hysteresis fires.
//
// First frame after a lock release bypasses the gate
// entirely so a >12 fret note that disengaged the lock
// is guaranteed to widen the view. Without this, a
// small span jump (12→13 frets) at default settings
// can sit inside the dead zone and the camera fails
// to follow the high note that just opened the lock.
const candidateBase = baseDistU * K;
const baseTgt = tgtDist - prevLowFretBonus * K;
const justUnlocked = prevLockActive;
if (justUnlocked || Math.abs(candidateBase - baseTgt) > camDistHystF * 3 * K) {
tgtDist = (baseDistU + lowFretBonusU) * K;
} else if (lowFretBonusU !== prevLowFretBonus) {
tgtDist = baseTgt + lowFretBonusU * K;
}
}
prevLowFretBonus = lowFretBonusU;
}
// X-axis: recency-weighted centroid with a hysteresis dead zone
// so small cluster shifts don't trigger visible pan motion.
if (!lockActive && wSum > 0) {
const candidateX = wX / wSum;
if (Math.abs(candidateX - tgtX) > camHystF * FRET_WIDTH_MID) tgtX = candidateX;
}
return lockActive;
}
/** Tolerate RS/sloppak boolean-ish ``true`` / ``1`` forms. */
function truthyChartFlag(v) {
if (v === true || v === 1) return true;
if (v === '1') return true;
return typeof v === 'string' && v.toLowerCase() === 'true';
}
/** RS / sloppak `hd` (highDensity); tolerate occasional string forms. */
function chordWireHighDensity(ch) {
return truthyChartFlag(ch && ch.hd);
}
/**
* Per spec, `displayName` is the UI label for a chord template
* (defaulting to `name` when the chart didn't set it). Always go
* through this helper so name vs. displayName drift can't surface
* the wrong label or break displayName-based dedupe heuristics.
*/
function chordTemplateLabel(tmpl) {
if (!tmpl) return '';
const d = tmpl.displayName;
if (typeof d === 'string' && d.length > 0) return d;
const n = tmpl.name;
return typeof n === 'string' ? n : '';
}
/**
* Arpeggio styling is driven by authored metadata, not by post-hoc
* note-stream inference. Prefer explicit hand-shape flags and fall back
* to template markers when present.
*/
function chordTemplateMarkedArpeggio(cid, chordTemplates) {
if (cid == null || !chordTemplates) return false;
const tmpl = chordTemplates[cid] ?? chordTemplates[Number(cid)];
if (!tmpl) return false;
if (truthyChartFlag(tmpl.arp) || truthyChartFlag(tmpl.arpeggio)) return true;
const displayName = typeof tmpl.displayName === 'string' ? tmpl.displayName.toLowerCase() : '';
if (displayName.includes('-arp')) return true;
const name = typeof tmpl.name === 'string' ? tmpl.name.toLowerCase() : '';
return name.endsWith('(arp)') || name.includes(' arpeggio');
}
function handShapeMarkedArpeggio(hs, chordTemplates) {
if (!hs) return false;
if (truthyChartFlag(hs.arp) || truthyChartFlag(hs.arpeggio)) return true;
return chordTemplateMarkedArpeggio(hsChordIdNorm(hs), chordTemplates);
}
/**
* Matching hand-shape metadata for a chord onset. ``explicit`` follows
* authored arpeggio markers only; note inference is handled separately
* by the callers that still need it for non-visual behavior.
*
* Cached per chord: result depends only on (ch, hss, chordTemplates),
* all chart-static for the lifetime of an arrangement. The cache is
* swapped on (hss, templates) ref change so an arrangement switch
* cannot resurrect stale entries. Empty-input case bypasses the cache
* — it returns a fresh sentinel anyway and isn't hot enough to share.
*/
const _HINT_NONE = Object.freeze({ explicit: false, covered: false, hs: null });
let _hintCache = new WeakMap();
let _hintCacheHsRef = null;
let _hintCacheTplRef = null;
function chordHandShapeArpeggioHint(ch, hss, chordTemplates) {
if (!hss || hss.length === 0) return _HINT_NONE;
if (_hintCacheHsRef !== hss || _hintCacheTplRef !== chordTemplates) {
_hintCache = new WeakMap();
_hintCacheHsRef = hss;
_hintCacheTplRef = chordTemplates;
}
const cached = _hintCache.get(ch);
if (cached !== undefined) return cached;
const t = ch.t;
const cid = ch.id;
let result = _HINT_NONE;
for (let i = 0; i < hss.length; i++) {
const hs = hss[i];
const tLo = hsStart(hs);
const tHi = hsEnd(hs);
if (Number.isNaN(tLo) || Number.isNaN(tHi)) continue;
if (t + 1e-4 < tLo || t > tHi + 1e-4) continue;
const hsCid = hsChordIdNorm(hs);
if (hsCid !== cid && Number(hsCid) !== Number(cid)) continue;
const explicit = handShapeMarkedArpeggio(hs, chordTemplates);
result = { explicit, covered: true, hs };
break;
}
_hintCache.set(ch, result);
return result;
}
/** Build ``ch.notes`` from ``chordTemplates[cid].frets`` (-1 omitted). */
function chordNotesFromTemplate(cid, templates) {
if (templates == null || cid == null) return [];
const tmpl = templates[cid] ?? templates[Number(cid)];
if (!tmpl || !Array.isArray(tmpl.frets)) return [];
const out = [];
for (let si = 0; si < tmpl.frets.length; si++) {
const f = tmpl.frets[si];
if (f >= 0 && validString(si)) out.push({ s: si, f, sus: 0 });
}
return out;
}
/**
* Chart-format fingerpicking passages often have ``<handShape>`` + per-string
* ``<note>`` rows but **no** ``<chord>`` events. The 3D chord frame / arp
* styling only runs over ``bundle.chords``, so synthesize minimal chord
* rows at each hand-shape onset when the chart omits them.
*/
function mergeHandShapeSynthChords(realChords, handShapes, chordTemplates) {
if (!handShapes || handShapes.length === 0) return realChords;
const reals = realChords && realChords.length ? realChords : [];
const synth = [];
const seenSynth = new Set();
const tol = 0.028;
/**
* Suppress a synth chord box when a real chord with the **same trimmed
* display name** played within this window — Custom songs commonly authors
* several ``<chordTemplate>`` rows that share a display name (with
* trailing-whitespace IDs) for fingering variants. The follow-up
* hand-shape with no chord row is a fingering hint, not a new strum
* (e.g. Jackson 5 "I Want You Back" ~0:27 — Fm7 cid=18 strum followed
* by Fm7 cid=19 hand-shape, which earlier produced a stacked second
* "Fm7" label and an extra chord frame).
*/
const SAME_NAME_RUN_S = 0.5;
const trimmedTemplateName = (cid) => {
if (cid == null || !chordTemplates) return '';
const tmpl = chordTemplates[cid] ?? chordTemplates[Number(cid)];
// custom songs commonly authors several <chordTemplate> rows that share
// a displayName for fingering variants; the suppression
// heuristic in the surrounding code dedupes on the *label*,
// not the underlying name, so go through chordTemplateLabel.
return chordTemplateLabel(tmpl).trim();
};
outer: for (let i = 0; i < handShapes.length; i++) {
const hs = handShapes[i];
const cid = hs.chord_id != null ? hs.chord_id : hs.chordId;
const st = hs.start_time != null ? hs.start_time : hs.startTime;
if (cid == null || st == null || Number.isNaN(Number(st))) continue;
const key = `${cid}|${Number(st).toFixed(3)}`;
if (seenSynth.has(key)) continue;
seenSynth.add(key);
const myName = trimmedTemplateName(cid);
for (let j = 0; j < reals.length; j++) {
const ch = reals[j];
const rid = ch.id;
const sameId = rid === cid || Number(rid) === Number(cid);
if (sameId && Math.abs(ch.t - st) <= tol) continue outer;
// A real strum at the same onset already represents this
// chord — never synthesize a phantom on top of it. The
// id/name checks alone miss hand-shapes whose template
// differs from (or shares no name with) the coincident real
// chord — e.g. an edited chart that left a stale hand-shape
// template pointing at the pre-edit shape, which then drew a
// spurious second power chord beside the real one.
if (Math.abs(ch.t - st) <= tol) continue outer;
if (!sameId && myName !== '') {
const otherName = trimmedTemplateName(rid);
if (otherName === myName
&& st > ch.t
&& st - ch.t <= SAME_NAME_RUN_S) {
continue outer;
}
}
}
const notes = chordNotesFromTemplate(cid, chordTemplates);
if (notes.length === 0) continue;
const et = hs.end_time != null ? hs.end_time : hs.endTime;
synth.push({
t: st,
id: cid,
// `hd` is the chart-format `highDensity` wire field (gallops /
// repeated strums), not an arpeggio carrier — arpeggio
// intent is read directly from the hand-shape via
// chordHandShapeArpeggioHint() downstream. Keep `hd` false
// so chordWireHighDensity() / label-suppression behave the
// same as for any other non-gallop chord row.
hd: false,
notes,
/** Hand-shape fill-in (no authored chord row) — skip note-stream arp frame. */
h3dSynth: true,
/** Hand-shape end time — used to draw the shape-sustain border for non-arp cases. */
h3dSynthEnd: et != null ? Number(et) : null,
});
}
if (synth.length === 0) return reals;
const merged = reals.concat(synth);
merged.sort((a, b) => {
const dt = a.t - b.t;
if (Math.abs(dt) > 1e-6) return dt;
const ia = Number(a.id);
const ib = Number(b.id);
return (ia - ib) || 0;
});
return merged;
}
/**
* Merge chart-format ``chordTemplates[id].frets`` with live ``chordNote`` rows.
* Cached via WeakMap on the chord object — chord data never changes after
* chart load, so the Map is computed once and reused every frame.
* The init-time callers (fillArpeggioGhostInferFlags) pass ephemeral `fakeCh`
* objects that are never seen again, so they bypass the cache naturally.
*/
let _chordShapeCache = new WeakMap();
// Reset the validString()/nStr-dependent chord caches. Called when nStr
// changes so a string count discovered after the first frame (e.g. a
// 7-string chart whose stringCount arrives in song_info) doesn't leave
// string-6+ notes filtered out of cached chord shapes/signatures.
function _resetStringDependentCaches() {
_filterValidNotesCache = new WeakMap();
_chordSigCache = new WeakMap();
_chordShapeCache = new WeakMap();
// mergeHandShapeSynthChords() is nStr-dependent too: its synth
// notes come from chordNotesFromTemplate() -> validString(). The
// merge result is memoised by input identity (not nStr), so force a
// recompute or string-6+ template notes stay dropped from synth
// chords after the count grows.
_mergeCacheResult = null;
}
function mergeChordShape(ch, chordNotes, templates) {
if (_chordShapeCache.has(ch)) return _chordShapeCache.get(ch);
const shape = new Map();
const tid = ch && ch.id != null ? ch.id : null;
const tmpl = (tid != null && templates)
? (templates[tid] ?? templates[Number(tid)])
: null;
if (tmpl && Array.isArray(tmpl.frets)) {
for (let si = 0; si < tmpl.frets.length; si++) {
if (!validString(si)) continue;
const f = tmpl.frets[si];
if (f >= 0) shape.set(si, f);
}
}
for (let i = 0; i < chordNotes.length; i++) {
const cn = chordNotes[i];
if (!validString(cn.s)) continue;
if (cn.f < 0) shape.delete(cn.s);
else shape.set(cn.s, cn.f);
}
_chordShapeCache.set(ch, shape);
return shape;
}
function hitTimesQualifyArpeggioSpread(hitTimes) {
if (hitTimes.length < 2) return false;
hitTimes.sort((a, b) => a - b);
const spread = hitTimes[hitTimes.length - 1] - hitTimes[0];
if (spread >= 0.03) return true;
return hitTimes.length >= 4 && spread >= 0.016;
}
/** RS XML / IPC payloads use snake_case or camelCase field names. */
function hsStart(hs) {
if (!hs) return NaN;
const v = hs.start_time != null ? hs.start_time : hs.startTime;
if (v == null) return NaN;
const n = Number(v);
return Number.isNaN(n) ? NaN : n;
}
function hsEnd(hs) {
if (!hs) return NaN;
const v = hs.end_time != null ? hs.end_time : hs.endTime;
if (v == null) return NaN;
const n = Number(v);
return Number.isNaN(n) ? NaN : n;
}
function hsChordIdNorm(hs) {
if (!hs) return null;
const v = hs.chord_id != null ? hs.chord_id : hs.chordId;
return v == null ? null : v;
}
/** ``<handShape>`` chart duration in seconds (snake_case or camelCase XML). */
function handShapeChartSpanSec(hs) {
const a = hsStart(hs), b = hsEnd(hs);
if (Number.isNaN(a) || Number.isNaN(b)) return 0;
return Math.max(0, b - a);
}
/**
* When ``hd`` is missing/false, detect arpeggio from the **note** stream
* using the **full voicing** (template chord notes). RS often stores the
* plucks only in ``notes[]``, not as duplicate chord rows.
*
* @param {{ tLo: number, tHi: number } | null} [timeWin]
* When set (e.g. from ``<handShape>`` span), scan staggered picks
* across the whole held-shape window — RS often omits ``arp`` and ``hd``.
*/
// Cached per chord: result depends on (ch, shape, notesArr) and an
// optional timeWin which itself is a function of the chord's matching
// <handShape>. Both inputs are chart-static, so the cache invalidates
// on (notesArr, hss) ref change — `hss` is threaded in purely as the
// invalidation key for the chord-loop caller, which passes a stable
// `ch` (reused across frames) and a timeWin that is null until
// bundle.handShapes arrives over the WS; without the hss check the
// null-timeWin result would stick once handShapes loaded late. shape
// comes from mergeChordShape(ch) which is also chart-static, so it
// doesn't enter the invalidation key directly. The cache deliberately
// stores boolean results; a sentinel distinguishes "not computed"
// from "false".
let _arpInferCache = new WeakMap();
let _arpInferCacheNotesRef = null;
let _arpInferCacheHssRef = null;
function inferArpeggioFromNotePattern(ch, shape, notesArr, timeWin, hss = null) {
if (!notesArr || notesArr.length === 0 || shape.size < 2) return false;
if (_arpInferCacheNotesRef !== notesArr || _arpInferCacheHssRef !== hss) {
_arpInferCache = new WeakMap();
_arpInferCacheNotesRef = notesArr;
_arpInferCacheHssRef = hss;
}
const cached = _arpInferCache.get(ch);
if (cached !== undefined) return cached;
const result = _inferArpeggioFromNotePatternUncached(ch, shape, notesArr, timeWin);
_arpInferCache.set(ch, result);
return result;
}
function _inferArpeggioFromNotePatternUncached(ch, shape, notesArr, timeWin) {
const tHi = timeWin ? timeWin.tHi : ch.t + 2.35;
const tLo = timeWin ? timeWin.tLo : ch.t - 0.28;
let i2 = lowerBoundT(notesArr, tLo - 0.02);
const hitTimes = [];
const hitStrings = new Set();
for (; i2 < notesArr.length; i2++) {
const n = notesArr[i2];
if (n.t > tHi) break;
if (n.t < tLo) continue;
if (!validString(n.s)) continue;
const ef = shape.get(n.s);
if (ef === undefined || ef !== n.f) continue;
hitTimes.push(n.t);
hitStrings.add(n.s);
}
if (!hitTimesQualifyArpeggioSpread(hitTimes)) return false;
// A genuine arpeggio SWEEPS across the held shape, so its standalone
// notes land on MULTIPLE strings of the shape. When every matching
// hit is on a single string, this is a repeated single-string run
// (e.g. a palm-muted gallop hammering the chord's root) that happens
// to share one string/fret with the chord — NOT an arpeggio. Inferring
// one here deferred the chord's gems and made the power chord render as
// just that one repeated note (bar 25 of starlight). Require ≥2 strings.
if (hitStrings.size < 2) return false;
// Strumming/gallop rejection — far more hits than the shape has
// strings means the chord's notes are being re-struck repeatedly
// (a riff/gallop reusing both power-chord notes), not swept once as
// an arpeggio. This guard used to live inside `if (timeWin)`, so it
// was skipped for charts with no hand-shapes (timeWin null) — which
// let dense two-string gallops over a power chord infer a bogus
// arpeggio and defer the chord's gems (bar 88 of starlight: a
// (s5:4,s6:2) chord whose root+fifth recur ~16x over 2 s). Apply it
// with the actual window span whether or not a hand-shape is present.
const winSpan = timeWin ? (timeWin.tHi - timeWin.tLo) : (tHi - tLo);
if (winSpan > ARP_INFER_MULTI_STRUM_WIN_MIN_S
&& hitTimes.length > shape.size + ARP_INFER_MULTI_STRUM_HIT_SLACK) {
return false;
}
if (timeWin) {
if (winSpan < 0.70 && hitTimes.length < 4) {
const spread = hitTimes[hitTimes.length - 1] - hitTimes[0];
if (spread < ARP_INFER_STRUM_VS_ARP_SPREAD_MIN_S) return false;
}
// Reject when too few staggered hits for a genuine sweep across
// the held shape — see ARP_INFER_MIN_HITS_VS_SHAPE_CAP.
const minHits = Math.min(shape.size, ARP_INFER_MIN_HITS_VS_SHAPE_CAP);
if (hitTimes.length < minHits) return false;
}
return true;
}
/**
* True when standalone note rows already cover every string/fret in the
* arpeggio shape, so drawing the chord gems too would duplicate the same
* authored passage.
*/
// Cached per chord: result depends on (ch, shape, notesArr) — chart-
// static; the cache invalidates on notesArr ref change. The same
// ``ch`` may be queried multiple times per frame from the chord
// render loop (deferChordGems / _deferFallback / suppressSynthChord),
// so survival across frames is also useful.
let _arpCoverCache = new WeakMap();
let _arpCoverCacheNotesRef = null;
function chordShapeCoveredByStandaloneNotes(ch, shape, notesArr, timeWin) {
if (!notesArr || notesArr.length === 0 || !shape || shape.size === 0) return false;
if (_arpCoverCacheNotesRef !== notesArr) {
_arpCoverCache = new WeakMap();
_arpCoverCacheNotesRef = notesArr;
}
const cached = _arpCoverCache.get(ch);
if (cached !== undefined) return cached;
const tLo = (timeWin ? timeWin.tLo : ch.t - ARP_FRAME_ONSET_PAD_S) - NEXT_ON_STRING_T_EPS;
const tHi = (timeWin ? timeWin.tHi : ch.t + ARP_FRAME_ONSET_CLUSTER_S) + NEXT_ON_STRING_T_EPS;
let i2 = lowerBoundT(notesArr, tLo);
const matchedStrings = new Set();
let result = false;
for (; i2 < notesArr.length; i2++) {
const n = notesArr[i2];
if (n.t > tHi) break;
if (!validString(n.s) || matchedStrings.has(n.s)) continue;
const ef = shape.get(n.s);
if (ef === undefined || ef !== n.f) continue;
matchedStrings.add(n.s);
if (matchedStrings.size >= shape.size) { result = true; break; }
}
_arpCoverCache.set(ch, result);
return result;
}
/**
* Notes in an inferred arpeggio passage are charted in ``notes[]`` with
* staggered times; treat them like chord-cluster notes for chart-format-style
* board-ghost fret digits (``fromChord`` + template column).
*/
function arpeggioChordIdForNote(n, handShapes, chordTemplates, notesArr) {
if (!handShapes || handShapes.length === 0 || !notesArr || notesArr.length === 0) return null;
if (!validString(n.s)) return null;
for (let i = 0; i < handShapes.length; i++) {
const hs = handShapes[i];
const hsLo = hsStart(hs);
const hsHi = hsEnd(hs);
if (Number.isNaN(hsLo) || Number.isNaN(hsHi)) continue;
if (n.t + 1e-4 < hsLo || n.t > hsHi + 1e-4) continue;
const cid = hsChordIdNorm(hs);
if (cid == null) continue;
const tmpl = chordTemplates?.[cid] ?? chordTemplates?.[Number(cid)];
if (!tmpl || !Array.isArray(tmpl.frets)) continue;
const tf = tmpl.frets[n.s];
if (typeof tf !== 'number' || tf < 0 || n.f !== tf) continue;
const synthNotes = chordNotesFromTemplate(cid, chordTemplates);
if (synthNotes.length === 0) continue;
const fakeCh = { t: hsLo, id: cid, notes: synthNotes };
const shape = mergeChordShape(fakeCh, synthNotes, chordTemplates);
const tw = { tLo: hsLo - 0.06, tHi: hsHi + 0.06 };
if (handShapeChartSpanSec(hs) < ARP_INFER_MIN_HAND_SHAPE_SPAN_S) continue;
if (inferArpeggioFromNotePattern(fakeCh, shape, notesArr, tw, handShapes)) return cid;
}
return null;
}
/**
* Per-frame warmup: ``inferArpeggioFromNotePattern`` depends only on
* ``handShape × chart``, not on the candidate note — the old path
* recomputed it for every visible note (O(notecount × hs × notescan)).
* Fill ``outFlags[i]`` with the boolean once per ``handShapes[i]``.
*/
function fillArpeggioGhostInferFlags(handShapes, chordTemplates, notesArr, outFlags, outSynthOnsetSet = null) {
for (let i = 0; i < handShapes.length; i++) {
let infer = false;
const hs = handShapes[i];
if (handShapeChartSpanSec(hs) < ARP_INFER_MIN_HAND_SHAPE_SPAN_S) {
outFlags[i] = false;
continue;
}
const cid = hsChordIdNorm(hs);
if (cid != null && notesArr.length > 0) {
const tmpl = chordTemplates?.[cid] ?? chordTemplates?.[Number(cid)];
if (tmpl && Array.isArray(tmpl.frets)) {
const synthNotes = chordNotesFromTemplate(cid, chordTemplates);
if (synthNotes.length > 0) {
const hsLo = hsStart(hs);
const hsHi = hsEnd(hs);
const fakeCh = { t: hsLo, id: cid, notes: synthNotes };
const shape = mergeChordShape(fakeCh, synthNotes, chordTemplates);
const tw = { tLo: hsLo - 0.06, tHi: hsHi + 0.06 };
infer = inferArpeggioFromNotePattern(fakeCh, shape, notesArr, tw, handShapes);
// Chord-hold gate: inferArpeggioFromNotePattern can fire true
// when open-string notes coincidentally match the template's
// open positions but only a SINGLE fretted (f>0) string is
// actually played at the handshape onset. Treat that as a
// chord hold (not an arpeggio) — clear the arp flag, no
// brackets. The original implementation also intended to
// record a synthetic sustain extending to hsEnd for the
// onset note, but that read-side was never wired up; the
// visual decay-before-handshape-end is benign.
if (infer) {
let _frettedCount = 0;
let _onsetNote = null;
const _fSeen = new Set();
let _ci = lowerBoundT(notesArr, tw.tLo - 0.02);
for (; _ci < notesArr.length; _ci++) {
const _cn = notesArr[_ci];
if (_cn.t > tw.tHi + 0.02) break;
if (_cn.t < tw.tLo) continue;
if (!validString(_cn.s)) continue;
if (shape.get(_cn.s) !== _cn.f) continue;
if (_cn.f > 0 && !_fSeen.has(_cn.s)) {
_frettedCount++;
_fSeen.add(_cn.s);
if (_onsetNote === null) _onsetNote = _cn;
}
}
if (_frettedCount <= 1 && _onsetNote !== null) {
outFlags[i] = false;
continue; // chord hold handled — skip onset-match and outFlags assignment
}
}
// Non-arp template inferred as arpeggio: suppress brackets.
// Only explicit arp-marked templates (arp:true / displayName "-arp")
// should show [ ] / < > bracket markers.
if (infer && outSynthOnsetSet != null
&& !handShapeMarkedArpeggio(hs, chordTemplates)) {
outSynthOnsetSet.add(hsLo);
}
// Also treat as arp ghost when the hs generated a suppressed
// synth chord: any standalone note in the onset window matches
// any shape string. Handles patterns where inferArpeggioFromNotePattern
// returns false (e.g. repeated arpeggio across a long hs span
// triggers the multi-strum rejection), but the player still
// needs the "hold this shape" ghost fret numbers on the board.
if (!infer) {
const _oLo = hsLo - ARP_FRAME_ONSET_PAD_S;
const _oHi = hsLo + ARP_FRAME_ONSET_CLUSTER_S;
let _oi = lowerBoundT(notesArr, _oLo - 0.02);
for (; _oi < notesArr.length; _oi++) {
const _on = notesArr[_oi];
if (_on.t > _oHi) break;
if (_on.t < _oLo) continue;
if (shape.get(_on.s) === _on.f) {
infer = true;
// Only suppress brackets when the handshape is NOT an
// explicit arpeggio (arp:true template / displayName "-arp").
// Genuine arp handshapes reached via onset-match still need
// the [ ] bracket markers — only non-arp synth chords are
// "false positives" that should hide the brackets.
if (outSynthOnsetSet != null
&& !handShapeMarkedArpeggio(hs, chordTemplates)) {
outSynthOnsetSet.add(hsLo);
}
break;
}
}
}
}
}
}
outFlags[i] = infer;
}
}
// Chart-static WeakMap cache: note object → chord-id (or null sentinel).
// The result depends only on the note's (t, s, f) and the chart's handShapes
// + chordTemplates, which never change after load. Keyed by note object so
// switching songs/arrangements drops the entries with the old array.
const _ARP_CID_NULL = Object.freeze({});
const _arpCidCache = new WeakMap();
function arpeggioChordIdForNoteWithInferCache(n, handShapes, chordTemplates, notesArr, hsInferFlags) {
const cached = _arpCidCache.get(n);
if (cached !== undefined) return cached === _ARP_CID_NULL ? null : cached;
let result = null;
if (!handShapes || handShapes.length === 0 || !notesArr || notesArr.length === 0 || !hsInferFlags) {
result = arpeggioChordIdForNote(n, handShapes, chordTemplates, notesArr);
} else if (validString(n.s)) {
for (let i = 0; i < handShapes.length; i++) {
if (!hsInferFlags[i]) continue;
const hs = handShapes[i];
const hsLo = hsStart(hs);
const hsHi = hsEnd(hs);
if (Number.isNaN(hsLo) || Number.isNaN(hsHi)) continue;
if (n.t + 1e-4 < hsLo || n.t > hsHi + 1e-4) continue;
const cid = hsChordIdNorm(hs);
if (cid == null) continue;
const tmpl = chordTemplates?.[cid] ?? chordTemplates?.[Number(cid)];
if (!tmpl || !Array.isArray(tmpl.frets)) continue;
const tf = tmpl.frets[n.s];
if (typeof tf !== 'number' || tf < 0 || n.f !== tf) continue;
result = cid;
break;
}
}
_arpCidCache.set(n, result === null ? _ARP_CID_NULL : result);
return result;
}
/** Returns {start, end} chart-time bounds of the arpeggio handshape that contains
* this note, or null when not found. Uses hsInferFlags to skip ruled-out
* handshapes; falls back to a full scan when hsInferFlags is null. */
// WeakMap cache — arpHsBoundsForNote result is chart-static (note, handShapes,
// and hsInferFlags never change after chart load). Each renderer instance has
// its own WeakMap, so splitscreen panels don't interfere.
// Sentinel: _ARP_BOUNDS_NULL = {} distinguishes "no matching hs" from "uncached".
const _ARP_BOUNDS_NULL = Object.freeze({});
const _arpBoundsCache = new WeakMap();
function arpHsBoundsForNote(n, handShapes, hsInferFlags) {
if (!handShapes || handShapes.length === 0) return null;
const cached = _arpBoundsCache.get(n);
if (cached !== undefined) return cached === _ARP_BOUNDS_NULL ? null : cached;
let result = null;
for (let i = 0; i < handShapes.length; i++) {
if (hsInferFlags && !hsInferFlags[i]) continue;
const hs = handShapes[i];
const lo = hsStart(hs);
const hi = hsEnd(hs);
if (Number.isNaN(lo) || Number.isNaN(hi)) continue;
if (n.t + 1e-4 < lo || n.t > hi + 1e-4) continue;
result = { start: lo, end: hi };
break;
}
_arpBoundsCache.set(n, result === null ? _ARP_BOUNDS_NULL : result);
return result;
}
function handShapeIsArpeggioForLaneRail(hs, chordTemplates) {
return handShapeMarkedArpeggio(hs, chordTemplates);
}
/**
* Chart-time window for purple rails: hand-shape span clipped to matching
* ``chords[].t`` and template notes in the passage — same times that drive
* the 3D arpeggio frame (``ch.t`` + note stream), avoiding rails that start
* before the box or end before the last arpeggiated note.
*/
function effectiveArpRailChartBoundsForHandShape(hs, chords, chordTemplates, notesArr) {
let shapeLo = hsStart(hs);
const _hsEndOrig = hsEnd(hs);
let shapeHi = _hsEndOrig;
const cid = hsChordIdNorm(hs);
if (Number.isNaN(shapeLo) || Number.isNaN(shapeHi)) {
return { shapeLo: 1e9, shapeHi: -1e9 };
}
if (notesArr && notesArr.length > 0 && chordTemplates && cid != null) {
const tmpl = chordTemplates[cid] ?? chordTemplates[Number(cid)];
if (tmpl && Array.isArray(tmpl.frets)) {
let tFirst = null;
let tLast = null;
for (let i = 0; i < notesArr.length; i++) {
const n = notesArr[i];
if (n.t + 1e-4 < shapeLo - 0.18 || n.t > shapeHi + 0.45) continue;
if (!validString(n.s)) continue;
const tf = tmpl.frets[n.s];
if (typeof tf !== 'number' || tf < 0 || n.f !== tf) continue;
if (tFirst === null || n.t < tFirst) tFirst = n.t;
if (tLast === null || n.t > tLast) tLast = n.t;
}
if (tFirst != null) shapeLo = Math.max(shapeLo, tFirst);
if (tLast != null) shapeHi = Math.max(shapeHi, tLast);
}
}
if (chords && chords.length && cid != null) {
let tMinC = null;
let tMaxC = null;
for (let j = 0; j < chords.length; j++) {
const ch = chords[j];
if (ch.id !== cid && Number(ch.id) !== Number(cid)) continue;
if (ch.t + 1e-4 < shapeLo || ch.t > shapeHi + 0.28) continue;
if (tMinC === null || ch.t < tMinC) tMinC = ch.t;
if (tMaxC === null || ch.t > tMaxC) tMaxC = ch.t;
}
if (tMinC != null) shapeLo = Math.max(shapeLo, tMinC);
if (tMaxC != null) shapeHi = Math.max(shapeHi, tMaxC);
}
shapeLo -= ARP_HWY_RAIL_START_LEAD_S;
// Only extend past the handshape end when notes/chords genuinely reach
// beyond it — otherwise the tail would make the rail visually larger
// than the actual handshape duration (e.g. 0.38 s / 1.3 s ≈ 29% extra).
if (shapeHi > _hsEndOrig) shapeHi += ARP_HWY_RAIL_END_TAIL_S;
return { shapeLo, shapeHi };
}
/** Cache the authored arpeggio marker per hand shape. */
function fillLaneRailHandShapeFlags(handShapes, chordTemplates, outFlags) {
const nHs = handShapes.length;
for (let i = 0; i < nHs; i++) {
outFlags[i] = handShapeIsArpeggioForLaneRail(handShapes[i], chordTemplates);
}
}
function fillArpeggioRailShapeBoundsCaches(
handShapes, chords, chordTemplates, notesArr, laneRailFlags, loOut, hiOut,
) {
const nHs = handShapes.length;
for (let i = 0; i < nHs; i++) {
if (!laneRailFlags[i]) continue;
const b = effectiveArpRailChartBoundsForHandShape(
handShapes[i], chords, chordTemplates, notesArr,
);
loOut[i] = b.shapeLo;
hiOut[i] = b.shapeHi;
}
}
/** ``[tChartLo,tChartHi]`` chart times that a lane slice covers (see module ``BEHIND`` / approach ``dt``). */
function arpeggioLaneOuterRailChartIntervalOverlaps(
tChartLo,
tChartHi,
handShapes,
boundLo,
boundHi,
laneRailFlags,
) {
if (!handShapes || handShapes.length === 0) return false;
if (!laneRailFlags) return false;
if (tChartHi < tChartLo) {
const s = tChartLo;
tChartLo = tChartHi;
tChartHi = s;
}
for (let i = 0; i < handShapes.length; i++) {
if (!laneRailFlags[i]) continue;
const shapeLo = boundLo[i];
const shapeHi = boundHi[i];
if (tChartHi < shapeLo - 1e-4 || tChartLo > shapeHi + 1e-4) continue;
return true;
}
return false;
}
function arpeggioLaneOuterRailLaneSlice(
dt0, dt1, nowClock,
handShapes, boundLo, boundHi, laneRailFlags,
) {
const tLo = nowClock + Math.min(dt0, dt1) - BEHIND;
const tHi = nowClock + Math.max(dt0, dt1) - BEHIND;
return arpeggioLaneOuterRailChartIntervalOverlaps(
tLo, tHi, handShapes, boundLo, boundHi, laneRailFlags,
);
}
/**
* True when **chart time** ``chartT`` falls inside an arpeggio hand-shape.
* Uses a short end tail only — no ``CHORD_HWY_LINGER_S`` — so purple lane
* rails match visible highway slices and do not leak after shapes end.
*/
function arpeggioLaneOuterRailAtChartTime(
chartT, handShapes, boundLo, boundHi, laneRailFlags,
) {
return arpeggioLaneOuterRailChartIntervalOverlaps(
chartT, chartT, handShapes, boundLo, boundHi, laneRailFlags,
);
}
/**
* Same ``chordAccent ? ft *= 1.22`` as the 3D arpeggio chord rim so lane
* rails match an accented frame when the active hand shape links to a
* chord row that carries ``.ac`` notes.
*/
function arpeggioLaneDividerFrameAccentMul(nowT, handShapes, chords, boundLo, boundHi, laneRailFlags) {
if (!handShapes || handShapes.length === 0 || !chords || chords.length === 0) return 1;
if (!laneRailFlags) return 1;
for (let i = 0; i < handShapes.length; i++) {
if (!laneRailFlags[i]) continue;
const shapeLo = boundLo[i];
const shapeHi = boundHi[i];
if (nowT + 1e-4 < shapeLo || nowT > shapeHi + 1e-4) continue;
const cid = hsChordIdNorm(handShapes[i]);
if (cid == null) return 1;
for (let j = 0; j < chords.length; j++) {
const ch = chords[j];
if (ch.id !== cid && Number(ch.id) !== Number(cid)) continue;
if (Math.abs(ch.t - hsStart(handShapes[i])) > 0.12) continue;
const chordNotes = ch.notes ? filterValidNotes(ch.notes) : [];
if (chordNotes.some(cn => cn.ac)) return 1.22;
return 1;
}
return 1;
}
return 1;
}
/** World-scale XY for purple lane rails = arpeggio ``ftSide`` / ``gLaneDivider`` edge (0.15×K). */
function arpeggioLaneDividerXYScaleMatchFrameRim(accentMul = 1) {
const yA = sY(0), yB = sY(nStr - 1);
const yMinF = Math.min(yA, yB) - S_GAP * 0.8;
const yMaxF = Math.max(yA, yB) + S_GAP * 0.8;
const fullChordBoxH = yMaxF - yMinF;
let ft = Math.max(CHORD_FRAME_RIM_MIN * K, fullChordBoxH * CHORD_FRAME_RIM_FRAC_H);
if (accentMul !== 1 && accentMul > 0) ft *= accentMul;
const ftSide = ft * 1.55;
return ftSide / (0.15 * K);
}
/* ── Fret-label measure-skip rule ───────────────────────────────── */
// For each (note_time, fret) pair across standalone notes and chord
// notes, determine which ones are allowed to display their fret
// indicator number. Rule: per fret (regardless of string), show the
// number only on the first note in a given measure; suppress it for
// the immediately following measure; then allow it again (current
// measure + 2).
// Key scheme: Math.round(t * 25) * 100 + fret (40 ms time buckets).
// Using a coarse time-bucket (not exact time) ensures that a synthetic
// chord template whose .t differs from the corresponding standalone
// arpeggio note by a few ms still resolves to the same key.
// Only standalone notes (notesArr) populate the set; regular chord notes
// never show labels, and synthetic chord notes share frets/onsets with
// their arpeggio counterparts, so the same keys are found at lookup time.
// Returns a Set of numeric keys (Math.round(t*25)*100 + fret).
function _buildFretLabelSet(notesArr, _chordsArr, beatsArr) {
const events = [];
if (notesArr) {
for (let _i = 0; _i < notesArr.length; _i++) {
const _n = notesArr[_i];
if (_n.f > 0) events.push({ t: _n.t, f: _n.f });
}
}
// Chord events intentionally excluded: regular chord notes don't show
// fret labels; synthetic chord notes share frets with arpeggio note-stream
// notes already captured above, so no separate chord processing needed.
events.sort((a, b) => a.t - b.t);
const beats = beatsArr || [];
let beatIdx = 0;
let currentMeasure = 0;
const nextShowMeasure = new Map(); // fret → next measure where label is allowed
const allowed = new Set();
for (let _ei = 0; _ei < events.length; _ei++) {
const { t, f } = events[_ei];
// Advance beats pointer: find the current measure for time t.
while (beatIdx < beats.length && beats[beatIdx].time <= t + 1e-4) {
if (beats[beatIdx].measure >= 0) currentMeasure = beats[beatIdx].measure;
beatIdx++;
}
const nextM = nextShowMeasure.get(f) ?? 0;
if (currentMeasure >= nextM) {
// Time-bucket key: 40 ms groups absorb timing jitter while
// still distinguishing notes at different positions in the measure.
allowed.add(Math.round(t * 25) * 100 + f);
// Suppress this fret for the next measure; re-allow at +2.
nextShowMeasure.set(f, currentMeasure + 2);
}
}
return allowed;
}
// Smoothed playback clock for this frame. Called once per frame at the
// top of update(); camUpdate() reads the stored _frameNow afterward so
// notes and camera share one clock. See the _clk* state block above.
function smoothNow(bundle) {
const raw = bundle.currentTime;
const p = performance.now();
// Host pause signal (slopsmith core's bundle.isPlaying): when the
// chart clock isn't advancing (paused / stalled / mid-seek), don't
// extrapolate forward against a frozen audio sample — that creeps
// the highway ahead by up to the interp cap and then snaps back
// when dt finally crosses 0.1. Re-anchor to raw so the next
// playing frame resumes from a clean segment. `=== false` so
// downlevel hosts (isPlaying undefined) fall through to the
// staleness-based cap below, preserving prior behavior there.
if (bundle.isPlaying === false) {
_clkAudioT = raw;
_clkPerf = p;
_clkRate = 1;
return (_frameNow = raw);
}
if (raw !== _clkAudioT) {
// New audio sample — re-anchor and refine the rate estimate.
if (!Number.isNaN(_clkPerf)) {
const dP = (p - _clkPerf) / 1000;
if (dP > 0.001 && dP < 0.5) {
const r = (raw - _clkAudioT) / dP;
_clkRate = (r > 0.05 && r < 5) ? r : 1; // seek/loop → reset
} else if (dP >= 0.5) {
_clkRate = 1; // long gap (paused / tab inactive)
}
}
_clkAudioT = raw;
_clkPerf = p;
return (_frameNow = raw);
}
// Same audio sample as last call — interpolate forward, capped so a
// stalled main thread or paused audio can't run the clock away.
const dt = (p - _clkPerf) / 1000;
if (dt <= 0 || dt > 0.1) return (_frameNow = raw);
return (_frameNow = _clkAudioT + _clkRate * dt);
}
/* ── Per-frame rendering ─────────────────────────────────────────── */
function update(bundle) {
pbBeg(0);
// [verdict glow] Apply the level-driven verdict brightness captured
// last frame (1-frame lag is imperceptible), then reset for this
// frame's capture in the gem path below. vg = 1 when no provider
// alpha was seen (legacy event path / note_detect off), leaving the
// authored 4.0/0.7 × glowMul brightness from _applyGlow() untouched.
// Only the verdict-only materials (mHitBright + its face-fill arrays,
// and the hit sustain outline) are scaled — never mStrHitOutline,
// which is the default rim for every fretted note.
{
const vg = _ndVerdictSawAlpha ? _ndVerdictMaxAlpha : 1;
const venueGemMul = _venueSceneOverride ? VENUE_GEM_EMISSIVE_MUL : 1;
for (let s = 0; s < mHitBright.length; s++) {
if (mHitBright[s]) mHitBright[s].emissiveIntensity = 4.0 * glowMul * vg * venueGemMul;
}
if (mHitSusOutline) mHitSusOutline.emissiveIntensity = 0.7 * glowMul * vg * venueGemMul;
_ndVerdictMaxAlpha = 0;
_ndVerdictSawAlpha = false;
}
// Lean sustain rendering is the default (see declaration above):
// the trail/ribbon outline always draws; only the additive rail
// bloom halo is dropped. The full look (with bloom) is an opt-out.
// Cheap per-frame read so the console flag takes effect live.
try {
_leanSus = localStorage.getItem('h3d_full_sus') !== '1';
} catch (_) { _leanSus = true; }
// Materialize the text-size multiplier from the user's slider.
// textSize ∈ [0,1]; _textSizeMul ∈ [0.5, 1.5] with 0.5 ↦ 1.0×
// so default behaviour matches what the renderer did pre-slider.
_textSizeMul = 0.5 + textSize;
// Rescale inlay labels to track the live text-size slider.
// buildBoard() sets an initial scale using (0.5 + textSize) but
// _textSizeMul is only authoritative from here onward.
// Guard: only update when the multiplier actually changed.
if (_textSizeMul !== _textSizeMulApplied) {
_textSizeMulApplied = _textSizeMul;
for (let i = 0; i < _inlayLabels.length; i++) {
const f = INLAY_LABEL_FRETS[i];
const s = 5.5 * _textSizeMul * K * fretLabelScaleForFret(f);
_inlayLabels[i].scale.set(s, s, 1);
}
}
_syncOpenStringPitchLabels(bundle);
pNote.reset(); pNoteEdge.reset(); pSus.reset(); pSusOutline.reset(); pSusRibbon.reset(); pSusRibbonOl.reset(); pTapChevron.reset(); pAccentHalo.reset(); pLbl.reset();
pBeat.reset(); pSec.reset();
if (projMeshArr) for (const arr of projMeshArr) for (const m of arr) m.visible = false;
pFretLbl.reset(); pLane.reset(); pLaneDivider.reset();
if (pGhostFretLbl) pGhostFretLbl.reset();
_scrGhostUpcomingCount.fill(0, 0, nStr);
pChordBox.reset(); pChordFrameFill.reset(); pChordLbl.reset(); pBarreLine.reset(); pArpBracket.reset(); pHaloBar.reset();
_imPMTechCount = _imFHTechCount = 0;
_imPMXFillCount = _imPMXLinesCount = _imFHXFillCount = _imFHXLinesCount = 0;
if (pPMXFill) pPMXFill.reset();
if (pFHXFill) pFHXFill.reset();
if (pMuteXLines) pMuteXLines.reset();
if (pFHXLines) pFHXLines.reset();
pNoteFretLabel.reset(); pConnectorLine.reset(); pDropLine.reset();
pFretColMarker.reset(); pSusRail.reset(); pSusRailBloom.reset(); pTechPlane.reset();
// Clear per-frame queues in-place (avoid reallocating the array object).
_ndLabels.length = 0;
let hwyLaneArpOuterDividers = false;
// Prune expired notedetect marks once per frame instead of
// once per drawNote call (issue #9 perf nit). drawNote then
// only does the bounded (s, f, t) match — no per-note
// performance.now() / filter() needed. No arr[0] gate: the
// dedupe path can refresh any entry's expiresAt, so gating on
// arr[0] would silently skip expired entries behind it.
_ndFrameNowMs = performance.now();
// In-place prune — avoids allocating a new array every frame.
// Marks are tiny (05 entries typically), so a backwards splice
// loop is cheap and keeps the existing array object alive.
if (_ndHitMarks.length) {
for (let _pi = _ndHitMarks.length - 1; _pi >= 0; _pi--) {
if (_ndHitMarks[_pi].expiresAt <= _ndFrameNowMs) _ndHitMarks.splice(_pi, 1);
}
}
if (_ndMissMarks.length) {
for (let _pi = _ndMissMarks.length - 1; _pi >= 0; _pi--) {
if (_ndMissMarks[_pi].expiresAt <= _ndFrameNowMs) _ndMissMarks.splice(_pi, 1);
}
}
// slopsmith#254 — capture core's per-note judgment provider for
// this frame's drawNote() calls (held-sustain glow + lit gems).
// bundle.getNoteState is ALWAYS present (the core stub returns
// null when no provider is registered), so its existence isn't
// a "detect mode active" signal on its own.
// bundle.getNoteStateProvider exposes the registered provider
// (or null) directly — drive cull-window / chord-rim-floor
// extensions off that so they don't activate in non-detect
// mode. Downlevel hosts without getNoteStateProvider fall
// back to the existence check, matching pre-PR behavior on
// those builds.
_ndGetNoteState = (bundle && typeof bundle.getNoteState === 'function') ? bundle.getNoteState : null;
_ndHasProvider = (bundle && typeof bundle.getNoteStateProvider === 'function')
? bundle.getNoteStateProvider() != null
: !!_ndGetNoteState;
const now = smoothNow(bundle);
const t0 = now - BEHIND;
const t1 = now + AHEAD;
// With a verdict provider attached, keep notes and chord frames
// in the outer loop past BEHIND so async verdicts (~0.4 s late)
// still land while drawable; per-note / per-frame culling is
// tightened back below.
const ndVerdictT0 = _ndHasProvider
? now - Math.max(BEHIND, NOTEDETECT_GEM_VERDICT_WINDOW)
: t0;
// Prune _chordVerdicts latches whose chord has fully scrolled
// past the loop's verdict-window cull. Forward playback never
// re-encounters a chord, so without this prune the map would
// grow unbounded for the rest of the song (each chord onset
// contributes one entry, ~hundreds for a typical song).
// verdictKey is now an integer encoded by _encodeChordVerdictKey
// — time component sits in the upper bits, so a direct
// ``k < pruneBeforeKey`` test prunes correctly without
// parseFloat / String.slice on every entry.
//
// Backward seek (now < lastNow): every latched entry's
// chord time is now ahead of `now`, the forward-only check
// below would skip them all and the map would grow on every
// loop. Clear wholesale — the chord-loop's `chDt > 0` eviction
// re-creates entries as chords re-enter the pre-hit window.
//
// Forward playback: iterate every entry. An earlier `break`
// optimization assumed Map insertion order tracked chord
// time, but entries are inserted when a verdict OBSERVATION
// lands — so a later chord whose verdict arrived first could
// sit before an earlier chord whose verdict was still
// pending, and breaking on the first in-window entry would
// leave the now-older later-inserted entries un-pruned. Full
// scan is O(n) but n is bounded (chord count in the song,
// ~hundreds) so the per-frame cost is microseconds.
if (_ndHasProvider && _chordVerdictsLastNow !== null && now < _chordVerdictsLastNow - 0.25) {
// Backward seek — wipe all verdict latches so notes re-judge
// from scratch regardless of whether chords were present.
_chordVerdicts.clear();
_susVerdictLatch.clear();
// Score-pop dedup too: a practice loop / rewind re-judges
// the same popKeys, and the wall-time TTL alone would
// suppress their fresh "+N" pops for up to 4 s.
_fxSeen.clear();
}
if (_ndHasProvider && _chordVerdicts.size > 0) {
if (_chordVerdictsLastNow !== null && now < _chordVerdictsLastNow - 0.25) {
// already cleared above
} else {
const pruneBefore = ndVerdictT0 - 0.5; // safety margin
const pruneBeforeKey = Math.round(pruneBefore * _CV_KEY_TIME_MUL) * _CV_KEY_TIME_SLOT;
for (const k of _chordVerdicts.keys()) {
if (k < pruneBeforeKey) _chordVerdicts.delete(k);
}
}
}
_chordVerdictsLastNow = now;
const notes = bundle.notes;
// Skip the merge when inputs are identity-equal to the last
// frame's; mergeHandShapeSynthChords is chart-static.
let chords;
if (_mergeCacheResult !== null
&& _mergeCacheChordsRef === bundle.chords
&& _mergeCacheHsRef === bundle.handShapes
&& _mergeCacheTplRef === bundle.chordTemplates) {
chords = _mergeCacheResult;
} else {
chords = mergeHandShapeSynthChords(
bundle.chords,
bundle.handShapes,
bundle.chordTemplates,
);
_mergeCacheResult = chords;
_mergeCacheChordsRef = bundle.chords;
_mergeCacheHsRef = bundle.handShapes;
_mergeCacheTplRef = bundle.chordTemplates;
}
let arpGhostHsInfer = null;
const hsForArpGhost = bundle.handShapes;
if (hsForArpGhost && hsForArpGhost.length && notes && notes.length) {
const nHs = hsForArpGhost.length;
while (_arpGhostHsInferScratch.length < nHs) _arpGhostHsInferScratch.push(false);
// fillArpeggioGhostInferFlags is chart-static — skip if
// the input refs match the previous frame's.
if (_arpGhostInferRefHs !== hsForArpGhost
|| _arpGhostInferRefNotes !== notes
|| _arpGhostInferRefTpl !== bundle.chordTemplates) {
_arpSynthOnsetHsSet.clear();
fillArpeggioGhostInferFlags(hsForArpGhost, bundle.chordTemplates, notes, _arpGhostHsInferScratch, _arpSynthOnsetHsSet);
_arpGhostInferRefHs = hsForArpGhost;
_arpGhostInferRefNotes = notes;
_arpGhostInferRefTpl = bundle.chordTemplates;
}
arpGhostHsInfer = _arpGhostHsInferScratch;
}
// ── Arpeggio-persist pre-pass ─────────────────────────────────
// Notes in active arpeggio handshapes must keep rendering their
// fretboard ghost + brackets until arpBounds.end, even after
// their onset+sustain exits the normal back-window (t0 = now-0.5s).
// Build a Set of "t_s" keys so the notes loop can skip the normal
// window check for these notes.
// Reuse hoisted Set — clear instead of reallocating every frame.
_scrArpPersistKeys.clear();
const _arpPersistKeys = _scrArpPersistKeys;
if (arpGhostHsInfer && bundle.handShapes && notes) {
for (let _hi = 0; _hi < bundle.handShapes.length; _hi++) {
if (!arpGhostHsInfer[_hi]) continue;
const _hs = bundle.handShapes[_hi];
const _lo = hsStart(_hs), _hi2 = hsEnd(_hs);
if (Number.isNaN(_lo) || Number.isNaN(_hi2)) continue;
if (now > _hi2 + 0.05) continue; // arpeggio already ended
// Only persist notes that have already exited the normal back-window
// (onset+sustain < t0). Notes still in the window enter the loop via
// the normal check; future notes are gated by the t1 check below.
const _nLo = lowerBoundT(notes, _lo - 0.01);
for (let _ni = _nLo; _ni < notes.length; _ni++) {
const _n = notes[_ni];
if (_n.t > _hi2 + 0.05) break;
if (_n.t + (_n.sus || 0) < t0) {
_arpPersistKeys.add(_noteKey(_n.t, _n.s));
}
}
}
}
// ── Slide-target gem-suppression pre-pass (chart-static) ──────
// Detects notes in bundle.notes that are the slide/link destination
// of a preceding note. The gem (outline+core) is suppressed via
// skipBody=true, but the sustain/slide trail still renders because
// the trail block is now outside the !skipBody gate in drawNote().
//
// NOTE: an authored `linkNext` flag is NOT present in bundle.notes —
// note_to_wire() in lib/song.py emits only t, s, f, sus, sl, slu,
// bn, ho, po, hm, hp, pm, mt, vb, tr, ac, tp. So this is an
// intentional timing/fret heuristic, not a link-flag lookup.
//
// Two source patterns (source has sus > 0):
// Case 1 — source has sl/slu: destination.f === source's slide target
// Case 2 — same fret (hold), destination has sl/slu (hold→slide)
//
// Sources can be single notes OR chord notes (bundle.chords).
if (notes !== _slideTargetNotesRef || bundle.chords !== _slideTargetChordsRef) {
_slideTargetSet = null;
if (notes && notes.length) {
const stSet = new Set();
const checkSrc = (srcT, srcS, srcF, srcSus, srcSl) => {
if (!(srcSus > 0)) return;
const endT = srcT + srcSus;
// Reuse the renderer's shared next-on-string tolerance
// rather than a separate hardcoded literal.
const EPS = NEXT_ON_STRING_T_EPS;
let lo = 0, hi = notes.length;
while (lo < hi) { const m = (lo + hi) >> 1; if (notes[m].t < endT - EPS) lo = m + 1; else hi = m; }
for (let j = lo; j < notes.length; j++) {
const q = notes[j];
if (q.t > endT + EPS) break;
if (q.s !== srcS || q.t <= srcT || Math.abs(q.t - endT) >= EPS) continue;
const qSl = (Number.isFinite(q.sl) && q.sl >= 0) ? q.sl
: (Number.isFinite(q.slu) && q.slu >= 0) ? q.slu : -1;
if (srcSl >= 0 && q.f === srcSl) { stSet.add(_noteKey(q.t, q.s)); break; } // case 1
if (q.f === srcF && qSl >= 0) { stSet.add(_noteKey(q.t, q.s)); break; } // case 2
}
};
for (let i = 0; i < notes.length; i++) {
const p = notes[i];
checkSrc(p.t, p.s, p.f, p.sus,
(Number.isFinite(p.sl) && p.sl >= 0) ? p.sl : (Number.isFinite(p.slu) && p.slu >= 0) ? p.slu : -1);
}
const rc = bundle.chords;
if (rc && rc.length) {
for (let ci = 0; ci < rc.length; ci++) {
const ch = rc[ci]; if (!ch.notes) continue;
for (let ni = 0; ni < ch.notes.length; ni++) {
const cn = ch.notes[ni];
checkSrc(ch.t, cn.s, cn.f, cn.sus,
(Number.isFinite(cn.sl) && cn.sl >= 0) ? cn.sl : (Number.isFinite(cn.slu) && cn.slu >= 0) ? cn.slu : -1);
}
}
}
if (stSet.size > 0) _slideTargetSet = stSet;
}
_slideTargetNotesRef = notes;
_slideTargetChordsRef = bundle.chords;
}
/** Arpeggio lane purple rails — authored-marker cache + bounds cache. */
let laneRailArpHsFlags = null;
let laneRailBoundLo = null;
let laneRailBoundHi = null;
const hsLaneRail = bundle.handShapes;
const notesArrForRails = notes || [];
if (hsLaneRail && hsLaneRail.length) {
const nHsL = hsLaneRail.length;
while (_arpLaneRailHsScratch.length < nHsL) _arpLaneRailHsScratch.push(false);
while (_arpRailBoundLoScratch.length < nHsL) {
_arpRailBoundLoScratch.push(0);
_arpRailBoundHiScratch.push(0);
}
// Authored-marker flags depend only on (handShapes, templates).
if (_laneRailFlagsRefHs !== hsLaneRail
|| _laneRailFlagsRefTpl !== bundle.chordTemplates) {
fillLaneRailHandShapeFlags(hsLaneRail, bundle.chordTemplates, _arpLaneRailHsScratch);
_laneRailFlagsRefHs = hsLaneRail;
_laneRailFlagsRefTpl = bundle.chordTemplates;
}
// Bounds cache depends on (handShapes, chords, templates, notes).
if (_laneRailBoundsRefHs !== hsLaneRail
|| _laneRailBoundsRefChords !== chords
|| _laneRailBoundsRefTpl !== bundle.chordTemplates
|| _laneRailBoundsRefNotes !== notesArrForRails) {
fillArpeggioRailShapeBoundsCaches(
hsLaneRail,
chords ?? [],
bundle.chordTemplates,
notesArrForRails,
_arpLaneRailHsScratch,
_arpRailBoundLoScratch,
_arpRailBoundHiScratch,
);
_laneRailBoundsRefHs = hsLaneRail;
_laneRailBoundsRefChords = chords;
_laneRailBoundsRefTpl = bundle.chordTemplates;
_laneRailBoundsRefNotes = notesArrForRails;
}
laneRailArpHsFlags = _arpLaneRailHsScratch;
laneRailBoundLo = _arpRailBoundLoScratch;
laneRailBoundHi = _arpRailBoundHiScratch;
}
const beats = bundle.beats;
// Rebuild the fret-label visibility set whenever the chart changes.
if (notes !== _fretLabelNotesRef) {
_fretLabelAllowed = _buildFretLabelSet(notes, chords, beats);
_fretLabelNotesRef = notes;
}
// Rebuild the measure-start time cache whenever beats change. Only
// beats that begin a measure carry measure >= 0; intra-measure beats
// (measure === -1) are skipped. Drives the lookahead window.
if (beats !== _measureStartsRef) {
_measureStartsRef = beats;
const _ms = [];
if (beats) {
for (let _bi = 0; _bi < beats.length; _bi++) {
const _b = beats[_bi];
if (_b && Number.isFinite(_b.measure) && _b.measure >= 0) _ms.push(_b.time);
}
}
_measureStarts = _ms;
}
const sections = bundle.sections;
const anchors = bundle.anchors;
// ── Fret wire anchor highlight ─────────────────────────────────
// Default all wires to gray; wires inside the active anchor range
// turn gold to match the dynamic highway lane boundary exactly.
// Uses laneBoundsFromAnchor() — the same helper the lane uses —
// so the gold fret wires on the board align with the lane edges:
// dMin = fret - 1, dMax = fret + width - 1
// e.g. { fret:3, width:4 } → dMin=2, dMax=6 → wires 2,3,4,5,6 gold.
if (fretWireMats.length) {
const _fwBounds = anchors && anchors.length
? anchorLaneBoundsAt(anchors, now) : null;
const _fwMin = _fwBounds ? _fwBounds.dMin : -1;
const _fwMax = _fwBounds ? _fwBounds.dMax : -1;
for (let _f = 0; _f <= NFRETS; _f++) {
const _m = fretWireMats[_f];
if (!_m) continue;
if (_fwMin >= 0 && _f >= _fwMin && _f <= _fwMax) {
_m.color.setHex(0xD8A636);
_m.opacity = 0.8;
} else {
_m.color.setHex(0x666688);
_m.opacity = 0.4;
}
}
}
const lookaheadBoundsNow = (cameraMode === 'lookahead')
? lookaheadComputeFretBounds(now, anchors, notes, chords)
: null;
// Open-string note width: same outer span as chord frame (anchor + padX,
// or default 4-fret window when chart has no anchor at t).
const padChordOpenX = NW * 0.4;
const openNoteLaneBoxW = chartTime => {
const chAncB = anchorLaneBoundsAt(anchors, chartTime);
if (chAncB) {
const xl = fretX(chAncB.dMin);
const xr = fretX(chAncB.dMax);
if (xr > xl) return (xr - xl) + padChordOpenX * 2;
}
const spanF = 4;
const fMinCh = 1;
const fMaxCh = fMinCh + spanF - 1;
const xl = fretX(fMinCh - 1);
const xr = fretX(Math.max(fMaxCh, fMinCh + 2));
if (xr > xl) return (xr - xl) + padChordOpenX * 2;
return 40 * K;
};
// ── Frame state ───────────────────────────────────────────────
// Reuse hoisted scratch arrays — reset only the live [0..nStr) /
// [0..NFRETS] range instead of allocating new arrays every frame.
_scrStringSustain.fill(false, 0, nStr);
_scrStringAnticipation.fill(0, 0, nStr);
_scrFretHeat.fill(0); // always NFRETS+1, cheap flat fill
_scrStrGlow.fill(0.5, 0, nStr);
_scrAccentFillBoost.fill(0, 0, nStr);
const noteState = {
stringSustain: _scrStringSustain,
stringAnticipation: _scrStringAnticipation,
fretHeat: _scrFretHeat,
strGlow: _scrStrGlow,
/** Per-string extra drive for `.ac` gem fill only (`mAccentCore`). */
accentFillBoost: _scrAccentFillBoost,
};
pbBeg(1);
// Compute sustain / anticipation / fret heat / per-string glow.
// Use lowerBoundT to skip notes far in the past (>30s sustain is
// unrealistic); break once notes are >2s ahead (nothing beyond
// contributes to fretHeat/anticipation/strGlow).
if (notes) {
const _fsLo = lowerBoundT(notes, now - 30);
for (let _ni = _fsLo; _ni < notes.length; _ni++) {
const n = notes[_ni];
if (!validString(n.s)) continue;
const dt = n.t - now;
if (dt > 2.0) break;
const susEnd = n.t + (n.sus || 0);
if (dt > 0 && dt < 0.6)
noteState.stringAnticipation[n.s] = Math.max(noteState.stringAnticipation[n.s], 1 - dt / 0.6);
if (n.f > 0) {
if (now >= n.t && now <= susEnd) noteState.fretHeat[n.f] = 1;
else if (n.t > now) noteState.fretHeat[n.f] = Math.max(noteState.fretHeat[n.f], Math.max(0, 1 - dt / 2));
}
if (now >= n.t && now <= susEnd) noteState.stringSustain[n.s] = true;
const sustained = dt < 0 && (n.sus || 0) > 0 && now <= susEnd;
const hitDist = Math.abs(dt);
if (hitDist < 0.15 || sustained) {
const hitFade = sustained ? 0.7 : (1 - hitDist / 0.15);
noteState.strGlow[n.s] = Math.max(noteState.strGlow[n.s], 1.0 + hitFade * 1.5);
}
}
}
if (chords) {
// Skip chords further than 30s in the past (covers any sustained chord).
const _cfsLo = lowerBoundT(chords, now - 30);
for (let _cni = _cfsLo; _cni < chords.length; _cni++) {
const ch = chords[_cni];
if (!ch.notes) continue;
const dt = ch.t - now;
if (dt > 2.0) break;
const chordNotes = filterValidNotes(ch.notes);
if (chordNotes.length === 0) continue;
let maxSus = 0;
for (const n of chordNotes) if ((n.sus || 0) > maxSus) maxSus = n.sus;
const susEnd = ch.t + maxSus;
for (const cn of chordNotes) {
if (dt > 0 && dt < 0.6)
noteState.stringAnticipation[cn.s] = Math.max(noteState.stringAnticipation[cn.s], 1 - dt / 0.6);
if (cn.f > 0) {
if (now >= ch.t && now <= susEnd) { noteState.fretHeat[cn.f] = 1; continue; }
if (ch.t > now) noteState.fretHeat[cn.f] = Math.max(noteState.fretHeat[cn.f], Math.max(0, 1 - dt / 2));
}
}
if (now >= ch.t && now <= susEnd)
for (const cn of chordNotes) noteState.stringSustain[cn.s] = true;
const sustained = dt < 0 && maxSus > 0 && now <= susEnd;
const hitDist = Math.abs(dt);
if (hitDist < 0.15 || sustained) {
const hitFade = sustained ? 0.7 : (1 - hitDist / 0.15);
for (const cn of chordNotes) {
noteState.strGlow[cn.s] = Math.max(noteState.strGlow[cn.s], 1.0 + hitFade * 1.5);
}
}
}
}
pbEnd(1);
pbBeg(2);
// ── Next-note-by-string lookahead (for anticipation projection) ──
// Ghost projection window is 0.6s; fretLastActiveTime needs +2s.
// Use lowerBoundT to skip past notes and break at +2s.
_scrNextNoteByString.fill(null, 0, nStr);
const nextNoteByString = _scrNextNoteByString;
if (notes) {
const _nnLo = lowerBoundT(notes, now);
for (let _ni = _nnLo; _ni < notes.length; _ni++) {
const n = notes[_ni];
if (n.t > now + 2) break;
if (!validString(n.s)) continue;
if (!nextNoteByString[n.s] || n.t < nextNoteByString[n.s].t) nextNoteByString[n.s] = n;
if (n.f > 0) fretLastActiveTime[n.f] = now;
}
}
if (chords) {
// Time-sorted: lowerBoundT skips past historical chords in O(log N)
// instead of walking the entire prefix every frame.
const _ncLo = lowerBoundT(chords, now);
for (let _ci = _ncLo; _ci < chords.length; _ci++) {
const ch = chords[_ci];
if (ch.t > now + 2) break;
if (!ch.notes || ch.t <= now) continue;
for (const cn of ch.notes) {
if (!validString(cn.s)) continue;
if (!nextNoteByString[cn.s] || ch.t < nextNoteByString[cn.s].t) {
// Reuse per-string scratch object — avoids `{ ...cn, t }` spread allocation.
const _sd = _scrNextNoteByStringData[cn.s];
Object.assign(_sd, cn);
_sd.t = ch.t;
nextNoteByString[cn.s] = _sd;
}
if (cn.f > 0) fretLastActiveTime[cn.f] = now;
}
}
}
_drawNextByString = nextNoteByString;
_drawChordTemplates = bundle.chordTemplates ?? null;
// ── Recent-past event per string (for _nextAnyT deadline) ─────
// Once a note/chord passes `now` it leaves _drawNextByString,
// resetting _nextAnyT and letting old gems linger too long.
// Scan back at least CHORD_HWY_LINGER_S so the deadline logic
// can see every event that lands inside any active linger
// window (chord frame linger and gem linger both cap at
// CHORD_HWY_LINGER_S — a tighter scan would miss events in
// (now - CHORD_HWY_LINGER_S, now - 0.6) and let the frame
// linger past the next event).
{
// Hoisted scratch — avoids `new Array(nStr).fill(...)` every frame.
const _recArr = _scrRecentByString;
for (let i = 0; i < nStr; i++) _recArr[i] = -Infinity;
if (notes) {
let _ri = lowerBoundT(notes, now);
for (let i = _ri - 1; i >= 0; i--) {
const n = notes[i];
if (n.t < now - CHORD_HWY_LINGER_S) break;
if (validString(n.s) && n.t > _recArr[n.s]) _recArr[n.s] = n.t;
}
}
if (chords) {
// Time-sorted: start at the last chord ≤ now instead of
// chords.length-1 (which walks past every future chord
// when `now` is early in the song).
//
// lowerBoundT returns the first index with t >= now. If
// chords share the same timestamp, walk forward through
// the t===now run to the LAST one (so all duplicates at
// `now` are included — the original `if (ch.t > now)
// continue` scan-from-end included them all). When no
// chord is exactly at `now`, start one slot back.
const _ncHi = lowerBoundT(chords, now);
let _ci = _ncHi;
if (_ci < chords.length && chords[_ci].t === now) {
while (_ci + 1 < chords.length && chords[_ci + 1].t === now) _ci++;
} else {
_ci -= 1;
}
for (; _ci >= 0; _ci--) {
const ch = chords[_ci];
if (ch.t < now - CHORD_HWY_LINGER_S) break;
if (!ch.notes) continue;
for (const cn of ch.notes) {
if (validString(cn.s) && ch.t > _recArr[cn.s]) _recArr[cn.s] = ch.t;
}
}
}
_drawRecentByString = _recArr;
}
// ── Sorted union of next/recent event times ──────────────────
// Populate the scalar scratch used by _firstEventTimeGreaterThan
// — at most 2 * nStr finite values, then sorted ascending.
// Float64Array.subarray returns a view, so .sort() runs in place
// over the live prefix without copying or allocating.
// Pulls directly from _drawNextByString / _drawRecentByString
// (closure-scoped, populated just above) so we're independent of
// the recent-event prepass's inner-block ``_recArr`` alias.
_scrEventTimesLen = 0;
for (let s = 0; s < nStr; s++) {
const nf = _drawNextByString[s];
if (nf) {
const tn = nf.t;
if (Number.isFinite(tn)) _scrEventTimes[_scrEventTimesLen++] = tn;
}
const rt = _drawRecentByString[s];
if (Number.isFinite(rt)) _scrEventTimes[_scrEventTimesLen++] = rt;
}
if (_scrEventTimesLen > 1) {
_scrEventTimes.subarray(0, _scrEventTimesLen).sort();
}
// ── Ghost preview gap prepass ──────────────────────────────────
// For each note/chord in the upcoming 0.65s window, record the
// onset time of its immediate predecessor on the same string.
// drawNote() uses this to shrink the ghost preview window from
// the fixed 0.6s down to min(0.6, gap) so in dense passages the
// fret label doesn't float 0.6s ahead with no gem in sight.
//
// Two-pointer merge over time-sorted notes + chords so the
// predecessor is correct even when notes and chords interleave.
// Map with numeric key avoids per-frame string allocation;
// key = Math.round(t*1e4)*10 + s (unique for notes > 0.1 ms apart).
// Buffer is hoisted (_scrGhostPrevBuf) and cleared at the top of
// the prepass; per-string predecessor tracker likewise (_scrGhostLastT).
_scrGhostPrevBuf.clear();
const _ghostPrevBuf = _scrGhostPrevBuf;
{
for (let _i = 0; _i < nStr; _i++) _scrGhostLastT[_i] = -Infinity;
const _gLastT = _scrGhostLastT;
let _gni = notes ? lowerBoundT(notes, now - 1) : 0;
let _gci = 0;
if (chords) while (_gci < chords.length && chords[_gci].t < now - 1) _gci++;
while (true) {
const nt = (notes && _gni < notes.length) ? notes[_gni].t : Infinity;
const ct = (chords && _gci < chords.length) ? chords[_gci].t : Infinity;
const minT = nt <= ct ? nt : ct;
if (minT > now + 0.65 || minT === Infinity) break;
if (nt <= ct) {
const n = notes[_gni++];
if (validString(n.s)) {
_ghostPrevBuf.set(Math.round(n.t * 1e4) * 10 + n.s, _gLastT[n.s]);
_gLastT[n.s] = n.t;
}
} else {
const ch = chords[_gci++];
if (ch.notes) for (const cn of ch.notes) {
if (validString(cn.s)) {
_ghostPrevBuf.set(Math.round(ch.t * 1e4) * 10 + cn.s, _gLastT[cn.s]);
_gLastT[cn.s] = ch.t;
}
}
}
}
}
// Ramp strGlow while the board ghost is visible so the flying note
// core + rim read as one solid string-coloured shape with proj.
// Window is (0, PROJ_WIN_MERGE=0.6s) — use lowerBoundT + break.
const PROJ_WIN_MERGE = 0.6;
if (notes) {
const _sgLo = lowerBoundT(notes, now);
for (let _ni = _sgLo; _ni < notes.length; _ni++) {
const n = notes[_ni];
if (!validString(n.s) || n.f <= 0) continue;
const dt = n.t - now;
if (dt >= PROJ_WIN_MERGE) break;
const nn = nextNoteByString[n.s];
if (!nn || Math.abs(nn.t - n.t) > NEXT_ON_STRING_T_EPS) continue;
const blend = 1 - dt / PROJ_WIN_MERGE;
noteState.strGlow[n.s] = Math.max(noteState.strGlow[n.s], 1.0 + blend * 1.2);
}
}
if (chords) {
const _projLo = lowerBoundT(chords, now);
for (let _pci = _projLo; _pci < chords.length; _pci++) {
const ch = chords[_pci];
if (!ch.notes || ch.t <= now) continue;
const dt = ch.t - now;
if (dt >= PROJ_WIN_MERGE) break;
const chordNotes = filterValidNotes(ch.notes);
for (const cn of chordNotes) {
if (cn.f <= 0) continue;
const nn = nextNoteByString[cn.s];
if (!nn || Math.abs(nn.t - ch.t) > NEXT_ON_STRING_T_EPS) continue;
const blend = 1 - dt / PROJ_WIN_MERGE;
noteState.strGlow[cn.s] = Math.max(noteState.strGlow[cn.s], 1.0 + blend * 1.2);
}
}
}
// Accent: brighter note body (`mGlow` in drawNote) instead of the old '>' sprite.
// Notes are sorted — break once past the AHEAD window.
if (notes) {
const _acLo = lowerBoundT(notes, now - AHEAD);
for (let _ni = _acLo; _ni < notes.length; _ni++) {
const n = notes[_ni];
if (!validString(n.s) || !n.ac) continue;
const dt = n.t - now;
if (dt > AHEAD) break;
const susEnd = n.t + (n.sus || 0);
const hasSus = (n.sus || 0) > 0;
if (dt < -ACCENT_NOTE_LINGER_EPS && (!hasSus || now > susEnd)) continue;
noteState.strGlow[n.s] = Math.max(noteState.strGlow[n.s], ACCENT_NOTE_STR_GLOW);
noteState.accentFillBoost[n.s] = Math.max(
noteState.accentFillBoost[n.s],
ACCENT_NOTE_FILL_BOOST,
);
}
}
if (chords) {
const _acChordLo = lowerBoundT(chords, now - 30);
for (let _aci = _acChordLo; _aci < chords.length; _aci++) {
const ch = chords[_aci];
if (!ch.notes) continue;
const dt = ch.t - now;
if (dt > AHEAD) break;
const chordNotes = filterValidNotes(ch.notes);
if (!chordNotes.length) continue;
let maxSus = 0;
for (const x of chordNotes) if ((x.sus || 0) > maxSus) maxSus = x.sus;
const susEnd = ch.t + maxSus;
const hasChordSus = maxSus > 0;
if (dt < -ACCENT_NOTE_LINGER_EPS && (!hasChordSus || now > susEnd)) continue;
for (const cn of chordNotes) {
if (!validString(cn.s) || !cn.ac) continue;
noteState.strGlow[cn.s] = Math.max(noteState.strGlow[cn.s], ACCENT_NOTE_STR_GLOW);
noteState.accentFillBoost[cn.s] = Math.max(
noteState.accentFillBoost[cn.s],
ACCENT_NOTE_FILL_BOOST,
);
}
}
}
pbEnd(2);
pbBeg(3);
// mGlow / mAccentCore emissive writes are folded into
// updateStringHighlights() — same per-string scratch reads,
// one pass.
updateStringHighlights(noteState);
pbEnd(3);
// Active frets (notes in cooldown window) + highway intensity
_scrActiveFrets.clear();
const activeFrets = _scrActiveFrets;
let highwayIntensity = 0;
for (let f = 1; f <= NFRETS; f++) {
if (now - fretLastActiveTime[f] < FRET_COOLDOWN) activeFrets.add(f);
}
// Camera targeting — steady mode (#34): recency-weighted centroid +
// hysteresis over [camT0, camT1]. In lookahead mode, see
// lookaheadBoundsNow + lookaheadSmoothCamStep().
let cs = 0;
let camAhead = CAM_TGT_AHEAD_C;
let camTau = CAM_TGT_TAU_C;
let camHystF = CAM_TGT_HYST_C;
let camT0 = now - CAM_TGT_BEHIND;
let camT1 = now + camAhead;
let camWX = 0, camWSum = 0;
let camDistMin = 99, camDistMax = 0, camDistGot = false;
const camDistHystF = CAM_DIST_HYST_T + (CAM_DIST_HYST_C - CAM_DIST_HYST_T) * zoomSmoothing;
if (!(cameraMode === 'lookahead')) {
cs = cameraSmoothing;
camAhead = CAM_TGT_AHEAD_T + (CAM_TGT_AHEAD_C - CAM_TGT_AHEAD_T) * cs;
camTau = CAM_TGT_TAU_T + (CAM_TGT_TAU_C - CAM_TGT_TAU_T) * cs;
camHystF = CAM_TGT_HYST_T + (CAM_TGT_HYST_C - CAM_TGT_HYST_T) * cs;
camT0 = now - CAM_TGT_BEHIND;
camT1 = now + camAhead;
}
// Classic path (#34): tgtDist hysteresis tracks fret span over the
// narrowed [camT0, camT1]; lookahead mode uses lookaheadBoundsNow + span smoothing.
//
// Sustain extension: the outer loop keeps notes/chords
// whose sustain still rings into the visible window —
// n.t + (n.sus || 0) >= t0 for notes, ch.t + maxSus >= t0
// for chords — via the continue-filters below at the top
// of the single-note and chord branches. camT0 is narrower
// than t0, so an onset can age past camT0 while still
// being on screen and audible. Mirror that past-side
// allowance here so a held low-fret chord keeps
// contributing to both camDist (zoom) and camWX (X
// target); otherwise the camera dollies/pans away
// mid-sustain, re-clipping the very chord the low-fret
// pullback was added to keep on screen. The future side
// (camT1) is left alone so the #34 invariant (distant
// high-fret onsets don't pre-pull the camera) still holds.
// ── Song-change detection ─────────────────────────────────────────
// reconnect() (used for arrangement switches and splitscreen song
// changes) does not call renderer.destroy/init, so _camSnapped and
// _camPreScanned would persist into the new song and the snap pre-pass
// would never fire again. Detect the change by comparing the current
// song+arrangement identity against the last-seen key, and reset the
// camera snap state (and the camera position itself) whenever it flips.
{
const si = bundle.songInfo;
// bundle.songInfo has no filename field (the WS song_info message
// never includes it). Use window.slopsmith.currentSong.filename
// — set by highway.js from the WS URL — combined with the
// arrangement index as a reliable per-song-arrangement key.
const currentSong = window.slopsmith && window.slopsmith.currentSong;
const key = currentSong ? currentSong.filename + '\0' + (si ? (si.arrangement_index ?? '') : '') : null;
if (key !== null && key !== _songKey) {
_songKey = key;
_camSnapped = false;
_camPreScanned = false;
tgtX = curX = xFretMid(CAM_LOCK_CENTER_FRET);
tgtDist = curDist = CAM_DIST_BASE;
prevLowFretBonus = 0;
prevLockActive = false;
_lookaheadCamX = xFretMid(CAM_LOCK_CENTER_FRET);
_lookaheadFretSpan = DEFAULT_LOOKAHEAD_FRET_SPAN;
_lookaheadCamPrevNow = null;
_lookaheadLowBonusU = 0;
_lookaheadHiNeckLatch = false;
// Drop the previous song's measure-start cache. Otherwise
// lookaheadEndTime() would size the lookahead window off the
// old measure grid (with the new song's now reset to ~0 this
// yields a wrong/huge tEnd) until the new beats arrive and
// rebuild it — the resulting huge fret span over-zooms the
// first-data snap and stays latched. Clearing it falls back
// to the seconds window for this frame; the rebuild repopulates
// it next frame once bundle.beats is the new array.
_measureStarts = []; _measureStartsRef = null;
// Drop the clock anchor so the new song's currentTime
// re-anchors cleanly instead of measuring a bogus rate
// across the seek-to-0 discontinuity.
_clkAudioT = NaN; _clkPerf = NaN; _clkRate = 1;
}
}
// ── Camera pre-pass (first-data snap) ────────────────────────────
// Before any drawNote() call, iterate notes/chords to accumulate
// the camera targeting data for THIS frame. If this is the first
// frame where fretted notes appear in the targeting window, snap
// curX/curDist directly to the computed targets so open-string note
// placement (which reads curX) and the camera are consistent on the
// snap frame. After the snap _camSnapped is true and this block
// becomes a permanent no-op. Open-string notes (f === 0) do not
// contribute to preWX/preWSum and therefore do not trigger the snap.
if (!_camSnapped) {
// One-time full-chart scan (runs exactly once when both bundle.notes
// and bundle.chords are available). If no fretted note exists
// anywhere in either array the snap can never fire, so we disable
// the per-frame pre-pass immediately to avoid permanent overhead.
// Both arrays are checked because some arrangements have fretted
// notes only inside chords (chord-only charts, keys arrangements).
if (!_camPreScanned && notes && chords) {
_camPreScanned = true;
const hasFrettedNote = notes.some(n => n.f > 0 && validString(n.s));
const hasFrettedChord = chords.some(
ch => ch.notes && ch.notes.some(cn => cn.f > 0 && validString(cn.s)));
if (!hasFrettedNote && !hasFrettedChord) _camSnapped = true;
}
if (!_camSnapped) {
if (cameraMode === 'lookahead') {
const bd = lookaheadBoundsNow;
if (bd) {
_lookaheadCamX = lookaheadTargetWorldX(bd.minF, bd.maxF);
_lookaheadFretSpan = Math.max(1, bd.maxF - bd.minF + 1);
const lockSnapEl = cameraLockLow && bd.maxF <= 12;
if (lockSnapEl) {
const lockedBaseU = camBaseDistU(12);
const lockedBonusU = camLowFretPullbackU(1);
const lockZoomMul = CAM_LOCK_ZOOM_MIN +
(CAM_LOCK_ZOOM_MAX - CAM_LOCK_ZOOM_MIN) * cameraLockZoom;
tgtX = xFretMid(CAM_LOCK_CENTER_FRET);
tgtDist = (lockedBaseU + lockedBonusU) * K * lockZoomMul;
prevLowFretBonus = lockedBonusU;
_lookaheadLowBonusU = lockedBonusU;
} else {
const baseDU = camBaseDistU(_lookaheadFretSpan);
const lowBU = camLowFretPullbackU(bd.minF);
tgtDist = (baseDU + lowBU) * K;
prevLowFretBonus = lowBU;
_lookaheadLowBonusU = lowBU;
tgtX = _lookaheadCamX;
}
curX = tgtX;
curDist = tgtDist;
_camSnapped = true;
_lookaheadCamPrevNow = now;
}
} else {
let preWX = 0, preWSum = 0, preDistMin = 99, preDistMax = 0, preDistGot = false;
if (notes) {
for (const n of notes) {
// bundle.notes is time-sorted: skip fully-expired sustains,
// break once the onset is beyond the camera window.
if (n.t + (n.sus || 0) < camT0) continue;
if (n.t > camT1) break;
if (!validString(n.s)) continue;
const nInWin = n.f > 0 && n.t >= camT0;
const nSusNow = n.f > 0 && n.t < camT0 && n.t + (n.sus || 0) >= now;
if (nInWin || nSusNow) {
const w = Math.exp(-Math.abs(n.t - now) / camTau);
preWX += xFretMid(n.f) * w; preWSum += w;
if (n.f < preDistMin) preDistMin = n.f;
if (n.f > preDistMax) preDistMax = n.f;
preDistGot = true;
}
}
}
if (chords) {
for (const ch of chords) {
if (!ch.notes) continue;
// bundle.chords is time-sorted: break once onset is beyond window.
if (ch.t > camT1) break;
const chNotes = filterValidNotes(ch.notes);
if (!chNotes.length) continue;
let maxSus = 0;
for (const n of chNotes) if ((n.sus || 0) > maxSus) maxSus = n.sus;
if (ch.t + maxSus < camT0) continue; // fully expired
const chOnsetInWin = ch.t >= camT0;
const chSusNow = ch.t < camT0 && ch.t + maxSus >= now;
if (!chOnsetInWin && !chSusNow) continue;
const chW = Math.exp(-Math.abs(ch.t - now) / camTau);
for (const cn of chNotes) {
const cnOk = chOnsetInWin || (chSusNow && ch.t + (cn.sus || 0) >= now);
if (cn.f > 0 && cnOk) {
preWX += xFretMid(cn.f) * chW; preWSum += chW;
if (cn.f < preDistMin) preDistMin = cn.f;
if (cn.f > preDistMax) preDistMax = cn.f;
preDistGot = true;
}
}
}
}
if (preWSum > 0) {
_applyNoteCamTargets(preWX, preWSum, preDistMin, preDistMax, preDistGot,
camHystF, camDistHystF, /* skipDistHyst= */ true);
curX = tgtX;
curDist = tgtDist;
_camSnapped = true;
}
} // end steady-mode pre-pass branch
} // end !_camSnapped (post-prescan guard)
}
pbBeg(4);
// ── Single notes ──────────────────────────────────────────────
// Reset the per-frame fret-label dedup set so stacked labels from
// multiple strings at the same onset/fret (arpeggio, synth chord) don't repeat.
_frameLabeledKeys.clear();
// Tracks which (chordId → Set<stringIndex>) pairs already had
// brackets drawn by the note-stream loop, so the chord loop can
// skip duplicate bracket draws for the same string.
// Hoisted Map — clear (rather than reallocate) so the per-frame
// chord-bracket dedupe doesn't churn GC in dense arpeggio passages.
// (The inner Sets stored as values lose their Map reference on
// .clear() and get GC'd along with the keys; only the outer Map
// is reused.)
_scrNoteStreamBracketStrings.clear();
const _noteStreamBracketStrings = _scrNoteStreamBracketStrings;
_scrLastFretForString.fill(undefined, 0, nStr);
const lastFretForString = _scrLastFretForString;
if (notes) {
// Start 30s before now — conservative enough to include any arpeggio
// persist window while skipping the bulk of old notes in long songs.
// The arpPersistKeys check below guards the rare notes that are even
// older and still visible (only possible for unrealistically long HS).
const _noteRenderLo = lowerBoundT(notes, now - 30);
for (let _ni = _noteRenderLo; _ni < notes.length; _ni++) {
const n = notes[_ni];
if (n.f > 0 && n.t > now && n.t < now + 2) activeFrets.add(n.f);
if (n.t > now) {
const dt = n.t - now;
if (dt < AHEAD) highwayIntensity = Math.max(highwayIntensity, 1 - dt / AHEAD);
}
// Far-future notes are always skipped — arpGhostActive
// timing handles when the ghost appears for upcoming arp notes.
// Notes are time-sorted so everything beyond t1 can be skipped entirely.
if (n.t > t1) break;
// Past-window arp notes are exempted from the back-window skip
// so their fretboard ghost + brackets persist until arpBounds.end.
// ndVerdictT0 extends the window when a note-detect provider is
// attached so async verdicts still land while drawable.
const _inArpPersist = _arpPersistKeys.has(_noteKey(n.t, n.s));
if (!_inArpPersist && n.t + (n.sus || 0) < ndVerdictT0) continue;
if (!validString(n.s)) continue;
// Suppress the gem for linkNext slide-target notes (skipBody=true).
// The sustain/slide trail still renders because it now lives outside
// the !skipBody gate in drawNote().
const _isSlideTgt = !!(_slideTargetSet && _slideTargetSet.has(_noteKey(n.t, n.s)));
// Always show the fret label — suppressing it for repeated frets on the same
// string caused the label to be invisible throughout the note's flight and
// only appear moments before being played (when the previous note's linger
// window expired). Each note now owns its label for its full flight.
const skipLabel = false;
let singleOpenX;
if (n.f === 0) {
const ab = anchorLaneBoundsAt(anchors, n.t);
if (ab) singleOpenX = (xFret(ab.dMin) + xFret(ab.dMax)) / 2;
}
const singleOpenLaneW = n.f === 0 ? openNoteLaneBoxW(n.t) : undefined;
const arGhostCid = arpeggioChordIdForNoteWithInferCache(
n,
bundle.handShapes,
bundle.chordTemplates,
notes,
arpGhostHsInfer,
);
const _arpBoundsForNote = arGhostCid != null
? arpHsBoundsForNote(n, bundle.handShapes, arpGhostHsInfer)
: null;
drawNote(
n,
now,
singleOpenX,
skipLabel,
_isSlideTgt,
GHOST_HOLD_AFTER_ONSET,
singleOpenLaneW,
arGhostCid != null,
arGhostCid,
arGhostCid != null,
_arpBoundsForNote,
_ghostPrevBuf.get(Math.round(n.t * 1e4) * 10 + n.s) ?? -Infinity,
_arpBoundsForNote !== null, // showDropLine: white line for arp note-stream notes
);
if (arGhostCid != null) {
const _arpBounds = _arpBoundsForNote;
if (_arpBounds) {
// Synth-onset-match handshapes show ghost fret numbers but not [ ] brackets.
if (!_arpSynthOnsetHsSet.has(_arpBounds.start)) {
// Open-string bracket X: always use the anchor at the
// handshape START time (not n.t, not now) so the bracket
// position stays fixed throughout the arpeggio even when
// the chart anchor changes mid-pattern.
const _arpBrktAncB = n.f === 0
? anchorLaneBoundsAt(anchors, _arpBounds.start)
: null;
const _bx = n.f === 0
? (_arpBrktAncB
? (xFret(_arpBrktAncB.dMin) + xFret(_arpBrktAncB.dMax)) / 2
: (singleOpenX !== undefined ? singleOpenX : curX))
: xFretMid(n.f);
const _openHalfW = (() => {
if (n.f !== 0) return null;
if (_arpBrktAncB) {
const _xl = xFret(_arpBrktAncB.dMin), _xr = xFret(_arpBrktAncB.dMax);
if (_xr > _xl) return Math.max(0.22, (_xr - _xl + NW * 0.4 * 2) * 0.96 / (40 * K)) * 20 * K;
}
return singleOpenLaneW != null ? Math.max(0.22, singleOpenLaneW * 0.96 / (40 * K)) * 20 * K : null;
})();
drawArpBrackets(_bx, sY(n.s), _arpBounds.start - now, _arpBounds.end, now, n.s, n.f === 0, _openHalfW);
// Record that this (chordId:occurrenceStart, string) pair has brackets
// so the chord loop doesn't draw a second set on the same string.
// Key includes the arp occurrence start time so two separate arp
// sequences sharing the same chord template ID don't suppress each other.
const _nsbKey = arGhostCid + ':' + _arpBoundsForNote.start;
let _nsbSet = _noteStreamBracketStrings.get(_nsbKey);
if (!_nsbSet) { _nsbSet = new Set(); _noteStreamBracketStrings.set(_nsbKey, _nsbSet); }
_nsbSet.add(n.s);
}
}
}
lastFretForString[n.s] = n.f;
// Onset in window OR started before the window but
// still sustaining right now. Gate sustain carry-over
// against the current frame time so camera framing
// releases as soon as the sustain is no longer
// rendered on screen.
if (!(cameraMode === 'lookahead')) {
const nInWin = n.t >= camT0 && n.t <= camT1;
const nSusActive = n.t < camT0 && n.t + (n.sus || 0) >= now;
if (n.f > 0 && (nInWin || nSusActive)) {
// Symmetric decay around now: previously this
// clamped n.t - now at 0, giving every past-
// onset note weight 1. That was a tolerable
// approximation when the past window was 0.2 s
// (camT0), but the sustain extension widens
// the past side to seconds for held notes — a
// 2-second-old ringing sustain would otherwise
// pin camWX as strongly as a fresh note and
// stale-out the framing for the current
// phrase. Math.abs lets old sustains decay on
// the same time-constant as future notes,
// matching each mode's intent: twitchy
// (camTau=0.35 s) drops a 0.2 s-old note's
// weight to ~0.56 (consistent with "react to
// recent only"), calm (camTau=0.9 s) to ~0.80
// (consistent with "average a wider window").
// Weight is still 1 at onset.
const w = Math.exp(-Math.abs(n.t - now) / camTau);
camWX += xFretMid(n.f) * w;
camWSum += w;
if (n.f < camDistMin) camDistMin = n.f;
if (n.f > camDistMax) camDistMax = n.f;
camDistGot = true;
}
}
}
}
pbEnd(4);
pbBeg(5);
// ── Chords ────────────────────────────────────────────────────
if (chords) {
// Single-pass shape-run tracking: the previous pre-loop scanned
// every chord (and re-allocated chordShapeSignature() per chord)
// each frame, even though the render loop already iterates the
// full array. We compute runSig inline once per chord and reuse
// it for both first-in-run detection and isRepeat below.
// SHAPE_RUN_GAP_S also resets the run when the time gap from
// the previous chord exceeds the same 0.5 s window used for
// isRepeat — a chord shape that re-appears after a real
// musical gap should re-show its label, not be treated as a
// continuing run from many bars ago.
const SHAPE_RUN_GAP_S = 0.5;
let runSigPrev = null;
let prevAnyChordTime = -Infinity;
let prevChordSig = null;
let prevChordTime = -1;
// Skip past chords that are too old to render. The per-chord filter
// (ch.t + _chFilterSus >= ndVerdictT0) passes the earliest chord when
// ch.t >= ndVerdictT0 - AHEAD (worst case: _chFilterSus = AHEAD for a
// chord with no explicit sustain). Binary search avoids iterating
// hundreds of past chords every frame in dense PM/FH sections.
const _chordsLoIdx = lowerBoundT(chords, ndVerdictT0 - AHEAD);
// Prime shape-run tracking from the chord immediately before the window
// so isRepeat and firstInShapeRun are correct on the first visible chord.
if (_chordsLoIdx > 0) {
const _pc = chords[_chordsLoIdx - 1];
if (_pc && _pc.notes) {
const _ps = chordShapeSignature(_pc);
if (_ps !== null) {
runSigPrev = _ps;
prevAnyChordTime = _pc.t;
prevChordSig = _ps;
prevChordTime = _pc.t;
}
}
}
for (let ci = _chordsLoIdx; ci < chords.length; ci++) {
const ch = chords[ci];
// Chords are time-sorted — everything beyond t1 is outside the
// visible window and contributes nothing (activeFrets needs t<now+2,
// highwayIntensity needs dt<AHEAD, both < t1).
if (ch.t > t1) break;
const runSig = chordShapeSignature(ch);
let firstInShapeRun;
if (runSig === null) {
firstInShapeRun = true;
} else {
const gap = ch.t - prevAnyChordTime;
firstInShapeRun = (runSig !== runSigPrev) || gap > SHAPE_RUN_GAP_S;
runSigPrev = runSig;
// Only valid chords update the run-gap clock — an entry
// whose runSig is null (no notes / unusable chordId)
// shouldn't make the next real chord look like a tiny
// gap and silently fall into a "still in the run" state.
prevAnyChordTime = ch.t;
}
if (!ch.notes) continue;
// Filter chord notes to in-range strings once. All
// chord-level aggregations (maxSus, repeat-chord
// signature, open-string centroid, frame-box bounds,
// active-fret highlights, camera-window dist) read
// from chordNotes so a clamped 9th-string note can't,
// for instance, extend the chord's linger beyond its
// visible sustain.
const chordNotes = filterValidNotes(ch.notes);
if (chordNotes.length === 0) continue;
const chShape = mergeChordShape(ch, chordNotes, bundle.chordTemplates);
if (ch.t > now) {
const dt = ch.t - now;
if (dt < AHEAD) highwayIntensity = Math.max(highwayIntensity, 1 - dt / AHEAD);
}
if (ch.t > now && ch.t < now + 2)
for (const cn of chordNotes) { if (cn.f > 0) activeFrets.add(cn.f); }
let maxSus = 0;
for (const n of chordNotes) if ((n.sus || 0) > maxSus) maxSus = n.sus;
// When maxSus=0 (no explicit sustain on chord notes, including
// all h3dSynth chords) use AHEAD as the filter window so the
// chord stays in the loop long enough for a handshape-derived
// sustain rail to finish drawing. The rail itself gates on
// _dtSusEnd>0, so chords with no actual sustain produce no
// visual artifact despite staying in the loop longer.
// ndVerdictT0 extends the window when a note-detect provider is
// attached so async verdicts still land while drawable.
const _chFilterSus = maxSus > 0 ? maxSus : AHEAD;
if (ch.t + _chFilterSus < ndVerdictT0) continue;
if (ch.t > t1) break;
// Repeat-chord detection (consecutive same shape, short gap).
// Reuses runSig computed at loop entry — same signature as the
// dedicated chordShapeSignature() call we used to make twice.
// Synthetic chords (h3dSynth — injected at handshape onsets by
// mergeHandShapeSynthChords) are never real strums, so they must
// not update prevChordSig/prevChordTime. Without this guard a
// real chord whose handshape generates a synth onset at the
// handshape start_time (e.g. a slide-in where the real strum
// falls mid-handshape, > 28 ms after the onset) would see the
// synth as its "previous chord" and be falsely flagged isRepeat.
const isRepeat = runSig !== null && prevChordSig === runSig && Math.abs(ch.t - prevChordTime) < 0.5;
if (!ch.h3dSynth) {
prevChordSig = runSig;
prevChordTime = ch.t;
}
// Anchor selection for chord frame + open-string X + sustain rails:
// • Upcoming (chDtEarly > 0): onset time — frame previews the correct
// neck region before the chord hits the line.
// • Past, actively sustaining (now < ch.t + maxSus): onset time — frame
// stays at the frets where the chord was struck. Using `now` here
// causes the frame to jump to whichever anchor is active at `now`,
// which may be a different/wider region and makes the sustain box
// appear in the wrong fret zone ("invading" adjacent anchors).
// • Past, linger-only (sustain expired or chord had no sustain): `now`
// — brief fade-out frame tracks the current lane position so it
// doesn't visibly drift while the lane has already transitioned.
const chDtEarly = ch.t - now;
const _chAnchorT = chDtEarly > 0 ? ch.t
: (maxSus > 0 && now < ch.t + maxSus) ? ch.t
: now;
const chAncB = anchorLaneBoundsAt(anchors, _chAnchorT);
// Open-string X: chart <anchor> lane centre when present (not curX /
// fretted centroid), matching highway span.
let chordCX = curX;
if (chAncB) chordCX = (xFret(chAncB.dMin) + xFret(chAncB.dMax)) / 2;
else {
let cxL = Infinity, cxR = -Infinity, fretted = 0;
for (const cn of chordNotes) {
if (cn.f > 0) {
const fx = xFretMid(cn.f);
if (fx < cxL) cxL = fx;
if (fx > cxR) cxR = fx;
fretted++;
}
}
if (fretted > 0) chordCX = (cxL + cxR) / 2;
}
// Horizontals for chord frame + open-string mesh width. With anchors,
// span matches HWY lane columns (wire dMin..dMax); no extra pad.
let chordFrameXL = null, chordFrameXR = null, chordOpenBoxW = null;
let chordFrameAnchorMatched = false;
if (chShape.size > 1) {
let fMinCh = 99, fMaxCh = 0, anyFretted = false;
for (const [, f] of chShape) {
if (f > 0) {
anyFretted = true;
fMinCh = Math.min(fMinCh, f);
fMaxCh = Math.max(fMaxCh, f);
}
}
// Prefer the anchor span so chord frames and arpeggio
// frames align with the highway lane window — BUT only
// when the chord's fretted notes actually fall within
// the anchor range. If the anchor at this chord's time
// doesn't cover the chord's frets (e.g. a chord at frets
// 24 with an anchor locked to frets 58), the framebox
// would clip the very gems it's supposed to contain, so
// fall back to chord-fret-based bounds instead.
const anchorCoversChordFrets = chAncB && anyFretted
? (fMinCh >= chAncB.dMin && fMaxCh <= chAncB.dMax)
: true; // all-open chord: anchor centre is fine
if (chAncB && anchorCoversChordFrets) {
chordFrameXL = xFret(chAncB.dMin);
chordFrameXR = xFret(chAncB.dMax);
chordFrameAnchorMatched = true;
} else if (anyFretted) {
chordFrameXL = xFret(fMinCh - 1);
chordFrameXR = xFret(Math.max(fMaxCh, fMinCh + 2));
} else {
const wNut = openNoteLaneBoxW(ch.t);
chordFrameXL = chordCX - wNut * 0.5;
chordFrameXR = chordCX + wNut * 0.5;
}
if (chordFrameXL != null && chordFrameXR != null) {
const span = Math.abs(chordFrameXR - chordFrameXL);
if (span > 1e-8) {
// Anchor-driven lane stripes span [dMin..dMax] wire-to-wire with
// no horizontal pad — match that ONLY when the frame is actually
// following the anchor (all-open chord, fallback path). The
// fretted-span path always pads so the frame breathes around
// the outermost fretted notes; without the pad it sat exactly
// on the fret lines and looked clipped.
if (chordFrameAnchorMatched) chordOpenBoxW = span;
else {
const padX = NW * 0.4;
chordOpenBoxW = span + padX * 2;
}
}
}
}
const laneWForOpenStrings = (chordOpenBoxW != null && chordOpenBoxW > 1e-8)
? chordOpenBoxW
: openNoteLaneBoxW(ch.t);
const hsHintFrame = chordHandShapeArpeggioHint(ch, bundle.handShapes, bundle.chordTemplates);
const hsTimeWinFrame = hsHintFrame.hs
? { tLo: hsStart(hsHintFrame.hs) - 0.06, tHi: hsEnd(hsHintFrame.hs) + 0.06 }
: null;
// chordShapeCoveredByStandaloneNotes is now cached per
// chord (see _arpCoverCache), so a direct call from the
// deferChordGems short-circuit chain is both lazy
// (skipped for branches that don't need it) AND O(1)
// when re-hit later in the same frame. The previous
// per-chord IIFE memo is therefore redundant — drop it
// to avoid the per-chord closure allocation in dense
// PM/FH passages.
const inferredArpPattern = (!hsHintFrame.hs
|| handShapeChartSpanSec(hsHintFrame.hs) >= ARP_INFER_MIN_HAND_SHAPE_SPAN_S)
&& inferArpeggioFromNotePattern(
ch, chShape, notes, hsTimeWinFrame, bundle.handShapes);
// Only suppress the chord gems when standalone notes really
// cover the arpeggio shape; otherwise explicit/synth hand
// shapes can produce an empty lavender frame with no notes
// inside (e.g. template-marked `-arp` chord rows).
// Lazy wrapper so the note-stream scan is skipped when
// neither branch needs it (short-circuit evaluation).
const noteStreamCoversArpShape = () => chordShapeCoveredByStandaloneNotes(ch, chShape, notes);
const deferChordGems = (ch.h3dSynth && noteStreamCoversArpShape())
|| inferredArpPattern
|| (hsHintFrame.explicit && hsHintFrame.covered && noteStreamCoversArpShape());
/**
* Lavender chord frame + purple highway rails: authored
* arpeggio metadata only. RS ``highDensity`` marks gallops /
* repeated strums on the same voicing (e.g. Frantic ~2:46) —
* not arpeggio; keep ``hd`` for sustain-ribbon width via
* ``chordSusTrailMatchArpFrame``.
*
* Only the chord that INITIATES the handshape span gets
* the lavender treatment — subsequent strums of the same
* voicing within the same handshape window are repeats and
* render as ordinary chord frames. Proximity to
* hsStart() (≤ 100 ms) identifies the initiating chord
* regardless of how wide the span is.
*/
const _hsStartT = hsHintFrame.hs ? hsStart(hsHintFrame.hs) : NaN;
const chordHighwayLavenderArpVisual = hsHintFrame.explicit
&& !isNaN(_hsStartT) && Math.abs(ch.t - _hsStartT) <= 0.1;
const chordSusTrailMatchArpFrame = chordWireHighDensity(ch)
|| chordHighwayLavenderArpVisual;
// Onset in window OR chord started before the window
// but is still sustaining right now. Gate sustain
// carry-over against the current frame time so camera
// framing releases as soon as the chord is no longer
// rendered on screen.
const chOnsetInWin = ch.t >= camT0 && ch.t <= camT1;
const chSusActive = ch.t < camT0 && ch.t + maxSus >= now;
const chWindowed = chOnsetInWin || chSusActive;
// Symmetric decay — see matching comment in the
// single-note branch. The chord-wide chW uses
// ch.t (not per-note onset) since chord notes
// share a strum time.
const chW = chWindowed ? Math.exp(-Math.abs(ch.t - now) / camTau) : 0;
// Next-chord tail: same voicing (``highDensity`` gallop) keeps full linger + optional
// fade suppression inside [holdfade, hold]; a voicing change clips the tail to the
// chart gap so D5→D#5 (~185 ms) does not stack two cyan frames (Frantic ~2:47).
let cjNext = null;
for (let j = ci + 1; j < chords.length; j++) {
const cj = chords[j];
if (!cj?.notes) continue;
if (filterValidNotes(cj.notes).length === 0) continue;
cjNext = cj;
break;
}
// Nearest following event (chord OR single note) — used by
// chordTailMul so the framebox vanishes the moment any next
// event is played, not just when the next chord arrives.
// Pull from the same sorted scalar scratch used by drawNote
// — the per-string Math.min walk became O(log N) over the
// shared 2*nStr buffer.
const _chFirstEventAfter = _firstEventTimeGreaterThan(ch.t + 1e-6);
const _chNextEventT = cjNext != null
? Math.min(cjNext.t, _chFirstEventAfter)
: _chFirstEventAfter;
let chordTailHoldS = CHORD_HWY_LINGER_S;
let chordNextSoon = false;
if (cjNext && cjNext.t > ch.t + 1e-6) {
// Clip the hold tail to the gap for both same-voicing (repeat)
// and different-voicing chords. The chordTailMul instant-cut
// check handles the precise zero at onset; the clipped holdS
// prevents the outer gate and hwyPostHitTailFadeMul from
// lingering past that point.
chordTailHoldS = Math.min(CHORD_HWY_LINGER_S, Math.max(cjNext.t - ch.t, 1e-3));
}
// slopsmith#254 — engine verdicts land ~0.4 s after the
// chord crosses; on a fast different-voicing sequence
// the clip above can shrink the rim's draw life below
// that, so the green/red latch is set but the rim isn't
// drawn anymore. When a verdict provider is attached,
// floor the hold at NOTEDETECT_GEM_VERDICT_WINDOW so
// the tinted rim is actually visible.
//
// This deliberately overrides the "voicing-change clip
// prevents two stacked cyan frames" behavior documented
// above (the D5→D#5 / Frantic ~2:47 case): the post-hit
// z clamp (Math.min(0, dZ(chDt)) below) pins extended
// frames at z=0, so the two frames do overlap in plane
// — they're distinguished by their now-tinted rim
// colors (green/red verdict vs teal default) rather
// than perspective depth. In detect mode that's the
// right trade: verdict visibility beats the cleaner
// approach silhouette. Without detect mode the
// original clip still applies.
if (_ndHasProvider && chordTailHoldS < NOTEDETECT_GEM_VERDICT_WINDOW) {
chordTailHoldS = NOTEDETECT_GEM_VERDICT_WINDOW;
}
const chordTailFadeS = Math.min(CHORD_HWY_FADE_S, chordTailHoldS);
// ── Approaching-arpeggio first-note identification ──────────────────
// When an authored arpeggio chord frame is still approaching (not yet
// at the hit line), only the first note to be played is shown as a gem.
// All others are suppressed until chDtEarly <= 0 so the frame doesn't
// flood the player's view with simultaneous gems before they arrive.
// The first note is the earliest match in the note stream within the
// handshape window. If no note-stream note matches the chord shape
// within the handshape (i.e. there is no sequential arpeggio pattern),
// _arpApproachFirstNote stays null and ALL chord gems are shown — this
// handles chords that are played simultaneously even when tagged as arp.
let _arpApproachFirstNote = null;
if (chordHighwayLavenderArpVisual && !deferChordGems
&& chDtEarly > 0 && hsHintFrame.hs) {
const _aHsLo = hsStart(hsHintFrame.hs);
const _aHsHi = hsEnd(hsHintFrame.hs);
let _aFirstT = Infinity;
const _aNLo = lowerBoundT(notes, _aHsLo - 0.08);
for (let _ani = _aNLo; _ani < notes.length; _ani++) {
const _an = notes[_ani];
if (_an.t > _aHsHi + 0.08) break;
if (!validString(_an.s)) continue;
for (const _acn of chordNotes) {
if (_acn.s === _an.s && _acn.f === _an.f && _an.t < _aFirstT) {
_aFirstT = _an.t;
_arpApproachFirstNote = _acn;
break;
}
}
}
// No fallback to chordNotes[0]: if the note stream has no sequential
// notes matching this shape, the chord is played simultaneously and
// all gems must be shown.
}
// ── Deferred-arpeggio gem fallback ─────────────────────────────────
// When gems are deferred to the note stream (deferChordGems=true) but
// no individual note matching the chord shape falls within the chord's
// onset cluster window, the frame box has no gems at its Z position.
// Show all chord gems as a preview so the frame box isn't empty.
// Uses the same onset window as chordShapeCoveredByStandaloneNotes so
// the fallback deactivates precisely when the stream truly covers the
// onset. Inlined (not an IIFE) to skip the per-chord closure allocation.
let _deferFallback = false;
if (deferChordGems && chDtEarly > 0) {
_deferFallback = true;
const _fLo = ch.t - ARP_FRAME_ONSET_PAD_S;
const _fHi = ch.t + ARP_FRAME_ONSET_CLUSTER_S;
let _fi = lowerBoundT(notes, _fLo - 0.02);
for (; _fi < notes.length; _fi++) {
const _fn = notes[_fi];
if (_fn.t > _fHi) break;
if (_fn.t < _fLo) continue;
const _fef = chShape.get(_fn.s);
if (_fef !== undefined && _fef === _fn.f) { _deferFallback = false; break; }
}
}
// Suppress gems AND frame for hand-shape-synthesized chords whose
// notes are already rendered individually via the note stream. Showing
// chord gems or a framebox for a synth chord that duplicates the note
// stream looks like phantom notes/chords. Check: any standalone note
// matching any shape string in the onset window → player is already
// guided by the note stream. Weaker than chordShapeCoveredByStandaloneNotes
// (all strings covered) to handle patterns where one shape string only
// appears well after the onset cluster (e.g. Walk intro, string 5 at
// +0.7 s outside the 0.26 s window). Inlined for the same reason as
// _deferFallback above.
let suppressSynthChord = false;
if (ch.h3dSynth && notes && chShape.size > 0) {
const _sLo = ch.t - ARP_FRAME_ONSET_PAD_S;
const _sHi = ch.t + ARP_FRAME_ONSET_CLUSTER_S;
let _si = lowerBoundT(notes, _sLo - 0.02);
for (; _si < notes.length; _si++) {
const _sn = notes[_si];
if (_sn.t > _sHi) break;
if (_sn.t < _sLo) continue;
if (chShape.get(_sn.s) === _sn.f) { suppressSynthChord = true; break; }
}
}
// suppressSynthChord: skip gems + frame but still call drawNote with
// skipBody=true so the board projection (fret ghost on fretboard) renders
// for all shape strings — shows the hand position like a chord would.
// chordLinksSlide: true when any chord note has a direct sl/slu marker,
// OR when the chord's sustain connects (via case-2 linkNext) to a note
// in bundle.notes that has a slide. Repeated chords matching either
// condition are treated as normal chords so the player sees the gem.
let chordLinksSlide = chordNotes.some(cn =>
(Number.isFinite(cn.sl) && cn.sl >= 0) ||
(Number.isFinite(cn.slu) && cn.slu >= 0));
if (!chordLinksSlide && isRepeat && maxSus > 0 && notes) {
const _EPS = NEXT_ON_STRING_T_EPS;
outer: for (const cn of chordNotes) {
if (!(cn.sus > 0)) continue;
const _endT = ch.t + cn.sus;
let _ji = lowerBoundT(notes, _endT - _EPS);
for (; _ji < notes.length; _ji++) {
const _q = notes[_ji];
if (_q.t > _endT + _EPS) break;
if (_q.s !== cn.s || Math.abs(_q.t - _endT) >= _EPS) continue;
if ((Number.isFinite(_q.sl) && _q.sl >= 0) ||
(Number.isFinite(_q.slu) && _q.slu >= 0)) {
chordLinksSlide = true; break outer;
}
}
}
}
if (!deferChordGems || _deferFallback || suppressSynthChord) {
for (const cn of chordNotes) {
// Suppress non-first gems while an authored arpeggio frame
// approaches — but not for the deferred fallback path, where
// all chord gems serve as the only visual preview.
// _arpApproachFirstNote is null when no sequential note-stream
// pattern was found, so simultaneous chords are unaffected.
// suppressSynthChord: show all shape strings for the projection.
if (!_deferFallback && !suppressSynthChord && _arpApproachFirstNote !== null && cn !== _arpApproachFirstNote) continue;
// Only suppress labels on repeated chord shapes (not on first-in-run);
// removed the lastFretForString check — same fix as single notes above.
const skipLabel = !firstInShapeRun;
// Reuse _scrChordNote scratch instead of `{ ...cn }` spread
// (avoids per-chord-note object allocation every frame).
Object.assign(_scrChordNote, cn);
_scrChordNote.t = ch.t;
_scrChordNote.sus = cn.sus || 0;
// `fhm` is omit-when-false in the wire format (unlike `mt`/`pm`
// which are always emitted). Before 5913129, chord-level
// fretHandMute was folded into `mt` (always-emitted), so
// Object.assign would overwrite any stale value. After that
// commit fhm is its own field — absent on non-muted notes —
// so Object.assign leaves a stale `true` from a previous
// muted chord note untouched. Reset it explicitly here.
_scrChordNote.fhm = cn.fhm || false;
drawNote(
_scrChordNote,
now,
cn.f === 0 ? chordCX : undefined,
skipLabel,
(isRepeat && !chordLinksSlide) || suppressSynthChord,
chordTailHoldS,
cn.f === 0 ? laneWForOpenStrings : undefined,
true,
ch.id,
chordSusTrailMatchArpFrame,
null,
_ghostPrevBuf.get(Math.round(ch.t * 1e4) * 10 + cn.s) ?? -Infinity,
chordHighwayLavenderArpVisual || suppressSynthChord || chordWireHighDensity(ch),
);
lastFretForString[cn.s] = cn.f;
// gate by THIS note's own sustain against the
// current render time — drawNote has already
// dropped short-sustain notes whose ringing has
// ended, so they should not keep pulling the
// camera frame wider than the notes actually
// still on screen (chord-wide maxSus would
// over-pullback for mixed-sustain chords).
if (!(cameraMode === 'lookahead')) {
const cnSustainOk = chOnsetInWin || (chSusActive && ch.t + (cn.sus || 0) >= now);
if (cn.f > 0 && cnSustainOk) {
camWX += xFretMid(cn.f) * chW;
camWSum += chW;
if (cn.f < camDistMin) camDistMin = cn.f;
if (cn.f > camDistMax) camDistMax = cn.f;
camDistGot = true;
}
}
}
}
// ── Arpeggio note brackets [ ] ────────────────────────
// Drawn only for explicitly authored arpeggio frames
// (chordHighwayLavenderArpVisual = explicit handshape arp mark).
// Covers both paths: gems shown directly from the chord
// (!deferChordGems) and the deferred-fallback preview path
// (_deferFallback). The inferred-arpeggio path (inferredArpPattern
// only, no explicit mark) intentionally does NOT draw brackets —
// the inference heuristic can false-positive on fast strummed
// chords, and brackets on non-arp chords confuse players.
// Note-stream arpeggios draw their own brackets in the notes[]
// loop above (for notes already in AHEAD). The chord loop covers
// any strings whose notes haven't entered AHEAD yet — _nsBrackets
// prevents duplicates for strings already handled by notes[].
if (chordHighwayLavenderArpVisual) {
const _arpBracketDt = ch.t - now;
if (_arpBracketDt < AHEAD) {
const _arpEnd = (hsHintFrame.hs && !isNaN(hsEnd(hsHintFrame.hs)))
? hsEnd(hsHintFrame.hs)
: ch.t + maxSus + CHORD_HWY_LINGER_S;
// The notes[] loop already drew brackets for any note-stream
// note that entered AHEAD, recording (chordId:occurrenceStart → strings)
// in _noteStreamBracketStrings. Use the same composite key (template id +
// handshape start time) so two arp occurrences sharing a chord template
// ID are treated as distinct occurrences — not one suppressing the other.
const _nsBracketsKey = ch.id + ':' + _hsStartT;
const _nsBrackets = _noteStreamBracketStrings.get(_nsBracketsKey);
// Open-string bracket X: anchor at handshape start so
// position stays fixed even when chordCX drifts with now.
const _arpChBrktAncB = !isNaN(_hsStartT)
? anchorLaneBoundsAt(anchors, _hsStartT)
: null;
const _arpChBrktOpenX = _arpChBrktAncB
? (xFret(_arpChBrktAncB.dMin) + xFret(_arpChBrktAncB.dMax)) / 2
: (chordCX ?? curX);
const _arpChBrktOpenW = (() => {
if (_arpChBrktAncB) {
const _xl = xFret(_arpChBrktAncB.dMin), _xr = xFret(_arpChBrktAncB.dMax);
if (_xr > _xl) return _xr - _xl + NW * 0.4 * 2;
}
return laneWForOpenStrings;
})();
for (const cn of chordNotes) {
if (!validString(cn.s)) continue;
if (_nsBrackets && _nsBrackets.has(cn.s)) continue;
const _bx = cn.f === 0
? _arpChBrktOpenX
: xFretMid(cn.f);
const _openHalfW = (cn.f === 0 && _arpChBrktOpenW != null)
? Math.max(0.22, _arpChBrktOpenW * 0.96 / (40 * K)) * 20 * K
: null;
drawArpBrackets(_bx, sY(cn.s), _arpBracketDt, _arpEnd, now, cn.s, cn.f === 0, _openHalfW);
}
}
}
// Chord frame-box: rim bars + interior fill gradient.
const chDt = chDtEarly; // already computed above for anchor selection
const chordTailMul = (() => {
// When a next event (chord OR single note) has already crossed
// the hit line, hide this frame immediately — no fadeout overlap
// when another event is already playing.
if (chDt < 0 && _chNextEventT < Infinity && now >= _chNextEventT) {
return 0;
}
return hwyPostHitTailFadeMul(chDt, chordTailHoldS, chordNextSoon, chordTailFadeS);
})();
if (chShape.size > 1 && chDt > -chordTailHoldS && chDt < AHEAD && chordOpenBoxW != null
&& (!suppressSynthChord || chordTemplateMarkedArpeggio(ch.id, bundle.chordTemplates))
) {
const z = Math.min(0, dZ(chDt));
const width = chordOpenBoxW;
const xLeft = chordFrameXL;
const xRight = chordFrameXR;
const cx = (xLeft + xRight) * 0.5;
const yA = sY(0), yB = sY(nStr - 1);
const yMinF = Math.min(yA, yB) - S_GAP * 0.8;
const yMaxF = Math.max(yA, yB) + S_GAP * 0.8;
const fullChordBoxH = yMaxF - yMinF;
let height = fullChordBoxH;
if (isRepeat) height *= 0.5;
// Repeat frames use half height but anchor at yMinF (board
// level) rather than centering in the string range. With the
// camera tilted downward, a centered half-height frame puts
// its bottom bar mid-strings — far above the board — causing
// perspective-induced apparent X-misalignment with the lane
// tiles (which sit at board level). Anchoring at yMinF keeps
// the bottom bar near the board so both frame and lane tile
// edges share the same projected screen X.
const yBot = yMinF;
const yTop = yMinF + height;
const cY = (yBot + yTop) * 0.5;
const fade = Math.max(0, 1 - chDt / AHEAD);
const chordAccent = chordNotes.some(cn => cn.ac);
// Rim thickness from full vertical span — repeat halves inner height only,
// not bar thickness vs first chord — see CHORD_FRAME_RIM_* tuning.
let ft = Math.max(CHORD_FRAME_RIM_MIN * K, fullChordBoxH * CHORD_FRAME_RIM_FRAC_H);
if (chordAccent) ft *= 1.22;
// Lavender frame: authored arpeggio marker only.
// RS ``highDensity`` is kept out — it tags gallops & repeated
// strums (Frantic ~2:46), not arpeggio.
const isArpeggioFrame = chordHighwayLavenderArpVisual;
const ftSide = isArpeggioFrame ? ft * 1.55 : ft;
let rimHex = isArpeggioFrame ? ARPEGGIO_RIM_BLUE_HEX : CHORD_BOX_TEAL_HEX;
// Capture the neutral frame color before any verdict overwrite.
// Used for the mute X lines so hit/miss feedback only shows on
// the outer borders of the framebox, not inside the X pattern.
const baseRimHex = rimHex;
// slopsmith#254 — once the chord crosses the hit
// line, tint the teal frame by the note-state
// provider verdict: green on a clean grab, red on a
// miss. The verdict is async (the engine verifier
// reports ~0.4 s after the line), so the frame stays
// teal while the verdict is still pending — it must
// not flash red before the verdict lands. The green/
// red verdict is latched in _chordVerdicts so it
// can't flicker as constituent glows decay.
// Only engages when a scorer is attached. Arpeggio
// frames keep their blue identity.
// Per-occurrence key — ch.id is the template id
// (reused across same-shape chord occurrences) so
// composing it with ch.t gives one entry per
// physical onset in the chart.
const verdictKey = _encodeChordVerdictKey(ch);
// Evict any stale latch the next time the chord
// re-enters the pre-hit window (rewinds, section
// loops, full restarts). Bounds Map growth too.
if (chDt > 0 && _chordVerdicts.has(verdictKey)) {
_chordVerdicts.delete(verdictKey);
}
// The verdict scan no longer skips authored-handshape
// frames — power chords sometimes carry an explicit
// handshape (RS authoring quirk), which previously
// dropped them into the `isArpeggioFrame` path and
// left them lavender-blue regardless of hit/miss.
// A true arpeggio (handshape over a real sweeping
// note run) is unaffected: its constituents are
// standalone notes judged at their own times, so the
// scan's query at `ch.t` finds nothing for them and
// the frame keeps its lavender default.
if (chDt <= 0 && _ndHasProvider && !isArpeggioFrame) {
const latched = _chordVerdicts.get(verdictKey);
if (latched === 'green') {
rimHex = CHORD_BOX_HIT_BRIGHT_HEX;
} else if (latched === 'red') {
rimHex = CHORD_BOX_MISS_DARK_HEX;
} else if (latched === 'unmatched') {
// The first scan past the verdict window
// came up empty (no constituent ever had a
// state — most often a true arpeggio frame
// whose actual notes are judged at their
// own times, not at ch.t). Skip the
// per-frame provider scan and keep the
// frame's default identity (lavender for
// arpeggios, teal for chords). See the
// unmatched-latch below.
} else {
// Latch both green AND red:
// - any constituent 'miss' → red latched.
// One decisive miss verdict means the
// chord can't be all-hit; without
// latching, the rim would fall back to
// teal once noteStateFor's miss-wash
// window (~0.6 s TTL) expires and the
// state returns null again.
// - all hit/active → green latched.
// - else (no miss yet, some constituents
// still null) → keep teal default. A
// partial state must not flash red on
// a chord whose verdicts arrive
// incrementally.
let allHit = chordNotes.length > 0;
let anyMiss = false;
let anyState = false; // true if any constituent had a non-null state this scan
for (const cn of chordNotes) {
let cs = null;
try { cs = _ndGetNoteState(cn, ch.t); } catch (e) { cs = null; }
const st = (cs && typeof cs === 'object') ? cs.state : cs;
if (st === 'hit' || st === 'active') {
anyState = true;
} else if (st === 'miss') {
// First miss decides the chord — no
// point querying the rest of the
// constituents this frame; the rim
// is about to be red-latched below.
// Short-circuits provider calls in
// chord-dense passages.
allHit = false;
anyMiss = true;
anyState = true;
break;
} else {
// null — undecided yet
allHit = false;
}
}
if (anyMiss) {
_chordVerdicts.set(verdictKey, 'red');
rimHex = CHORD_BOX_MISS_DARK_HEX;
} else if (allHit) {
_chordVerdicts.set(verdictKey, 'green');
rimHex = CHORD_BOX_HIT_BRIGHT_HEX;
} else if (chDt < -_ND_UNMATCHED_LATCH_AFTER && !anyState) {
// The engine verdict typically lands
// ~0.4 s after the chord crosses the
// line, so after the
// _ND_UNMATCHED_LATCH_AFTER threshold
// we've already waited well past the
// verdict-arrival window. If no
// constituent ever returned a non-
// null state by then, there's no
// verdict coming for this chord
// (true arpeggio frames: their actual
// notes are judged at their own
// times, never at ch.t — the scan
// at ch.t finds nothing forever).
//
// Latch 'unmatched' so subsequent
// frames skip the provider scan
// entirely. The threshold must be
// INSIDE the chord frame's visible
// draw window — `chordTailHoldS` is
// floored to NOTEDETECT_GEM_VERDICT_
// WINDOW (0.75 s) in detect mode, so
// chord frames stop drawing at
// `chDt < -0.75`; a latch threshold
// at `-NOTEDETECT_GEM_VERDICT_WINDOW`
// (i.e. exactly -0.75) is unreachable
// because the draw gate kicks the
// frame out of the loop first. Place
// the threshold ~0.55 s past line so
// it fires for ~0.2 s of the remaining
// visible window — enough frames to
// catch and skip future re-scans.
//
// The !anyState guard keeps the
// partial-resolve case (one cn 'hit',
// another still null) scanning until
// anyMiss / allHit commits it.
_chordVerdicts.set(verdictKey, 'unmatched');
}
// else: no verdict yet → leave teal default
}
}
if (chDt > 0) { // framebox only on highway, not on the fretboard
const repDim = isRepeat ? 0.78 : 1;
const edgeOp = fade * chordTailMul;
const thickZ = Math.max(CHORD_FRAME_RIM_Z_MIN * K, ft * CHORD_FRAME_RIM_Z_SCAL);
// Per-depth layer stack: chord frames, gems, technique markers,
// and fret labels all derive from RENDER_ORDER_LAYER_STACK so new layers
// have one vocabulary instead of ad hoc arithmetic at call sites.
// Sub-increments of 0.0001 for intra-chord ordering; safe for any
// chord gap >= 0.001 s.
const chordFrameRenderOrder = renderOrderForLayerAtZ(z, 'CHORD_FRAME');
const drawFrameBox = (px, py, sx, sy, ord, hex = rimHex, op = edgeOp) => {
const b = pChordBox.get();
b.renderOrder = ord;
b.material.color.setHex(hex);
b.position.set(px, py, z);
b.scale.set(sx, sy, thickZ);
b.rotation.set(0, 0, 0);
b.material.opacity = op;
};
const sideHex = isArpeggioFrame ? rimHex : 0x163137;
const innerW = Math.max(width - 2 * ftSide, width * 0.45);
const innerH = Math.max(height - 2 * ft, height * 0.3);
const fill = pChordFrameFill.get();
fill.renderOrder = renderOrderForLayerAtZ(z, 'CHORD_FILL');
fill.rotation.set(0, 0, 0);
fill.position.set(cx, cY, z - 0.004 * K);
fill.scale.set(innerW, innerH, 1);
fill.material.opacity = fade * repDim * chordTailMul;
// Swapping `map` between two non-null gradient textures
// doesn't change shader-defining state, so no needsUpdate
// — that flag would otherwise force a recompile per frame.
fill.material.map = isArpeggioFrame ? chordFrameGradTexArp : chordFrameGradTex;
fill.material.color.setRGB(1, 1, 1);
const withTopFrame = !isRepeat;
// Non-repeat tapers the upper side bars + draws a thin top bar;
// hoisted out so ySideHi can match the actual top-bar thickness
// (using ft would leave a visible gap between the thin top bar
// and the side bars meeting it).
const ftThin = ftSide * 0.22;
const ySideLo = yBot + ft;
const ySideHi = withTopFrame ? yTop - ftThin : yTop - ft * 0.15;
const sideH = Math.max(ySideHi - ySideLo, ft * 1.25);
const sideCy = ySideLo + sideH * 0.5;
// Bottom bar: thin teal (like top bar) + dark corners on top.
{
const botCW = Math.min(sideH * (isRepeat ? 0.5 : 0.25), width * 0.4);
drawFrameBox(cx, yBot + ftThin * 0.5, width, ftThin, chordFrameRenderOrder);
drawFrameBox(cx + width * 0.5 - botCW * 0.5, yBot + ft * 0.5, botCW, ft, chordFrameRenderOrder + 0.0001, sideHex);
drawFrameBox(cx - width * 0.5 + botCW * 0.5, yBot + ft * 0.5, botCW, ft, chordFrameRenderOrder + 0.0002, sideHex);
}
if (isRepeat) {
// Lower 30%: thick dark segment
const repLoH = sideH * 0.3;
const repLoCy = ySideLo + repLoH * 0.5;
drawFrameBox(cx - width * 0.5 + ftSide * 0.5, repLoCy, ftSide, repLoH, chordFrameRenderOrder + 0.0001, sideHex);
drawFrameBox(cx + width * 0.5 - ftSide * 0.5, repLoCy, ftSide, repLoH, chordFrameRenderOrder + 0.0001, sideHex);
// Upper 70%: thin teal segment (same style as non-repeat upper)
const repHiH = sideH - repLoH;
const repHiCy = ySideLo + repLoH + repHiH * 0.5;
drawFrameBox(cx - width * 0.5 + ftThin * 0.5, repHiCy, ftThin, repHiH, chordFrameRenderOrder + 0.0001);
drawFrameBox(cx + width * 0.5 - ftThin * 0.5, repHiCy, ftThin, repHiH, chordFrameRenderOrder + 0.0001);
} else {
// Non-repeat: thick sides up to repeat-frame height, then taper to thin above.
const threshY = yBot + fullChordBoxH * 0.5; // top of what a repeat frame would be
// Lower thick segment (ySideLo → threshY)
const loSideH = Math.max(Math.min(threshY, ySideHi) - ySideLo, 0);
if (loSideH > 0) {
const loCy = ySideLo + loSideH * 0.5;
drawFrameBox(cx - width * 0.5 + ftSide * 0.5, loCy, ftSide, loSideH, chordFrameRenderOrder + 0.0001, sideHex);
drawFrameBox(cx + width * 0.5 - ftSide * 0.5, loCy, ftSide, loSideH, chordFrameRenderOrder + 0.0001, sideHex);
}
// Upper thin segment (threshY → ySideHi)
const hiSideH = Math.max(ySideHi - threshY, 0);
if (hiSideH > 0) {
const hiCy = threshY + hiSideH * 0.5;
drawFrameBox(cx - width * 0.5 + ftThin * 0.5, hiCy, ftThin, hiSideH, chordFrameRenderOrder + 0.0001);
drawFrameBox(cx + width * 0.5 - ftThin * 0.5, hiCy, ftThin, hiSideH, chordFrameRenderOrder + 0.0001);
}
// Top bar: thin
drawFrameBox(cx, yTop - ftThin * 0.5, width, ftThin, chordFrameRenderOrder);
}
// Accent bloom on frame edges: 4 additive shells with
// Gaussian-style falloff. Each border expands only in its
// perpendicular axis so bloom never leaves the frame boundary:
// horizontal bars (top/bottom) → expand Y only
// vertical bars (left/right) → expand X only
if (chordAccent && pHaloBar) {
// Bloom only on the teal (thin) parts of the frame — the dark
// "#163137" L-corners are deliberately left without bloom so they
// remain visibly dark (same appearance as non-accent chords).
const haloHex = isArpeggioFrame ? ARPEGGIO_RIM_BLUE_HEX : CHORD_BOX_TEAL_HEX;
const EXPAND_MAX = 1.45;
const dynamicOp = fade * chordTailMul;
const drawHaloBar = (px, py, scaleX, scaleY, rotZ) => {
const b = pHaloBar.get();
b.material.color.setHex(haloHex);
b.material.opacity = dynamicOp;
b.renderOrder = renderOrderForLayerAtZ(z, 'CHORD_EDGE_GLOW');
b.position.set(px, py, z - 0.001 * K);
b.scale.set(scaleX, scaleY * EXPAND_MAX * 0.5, thickZ * 2.0);
b.rotation.set(0, 0, rotZ);
};
// Bottom: center-only bloom (skip dark corner areas)
const _bCW = Math.min(sideH * (isRepeat ? 0.5 : 0.25), width * 0.4);
const centerBotW = width - 2 * _bCW;
if (centerBotW > 0)
drawHaloBar(cx, yBot + ft * 0.5, centerBotW * 0.5, ft, 0);
// Top bar
if (withTopFrame)
drawHaloBar(cx, yTop - ftThin * 0.5, width * 0.5, ftThin, 0);
// Lateral: bloom only on the upper thin-teal segment (skip dark lower segment)
if (isRepeat) {
const repLoH = sideH * 0.3;
const repHiH = sideH - repLoH;
if (repHiH > 0) {
const repHiCy = ySideLo + repLoH + repHiH * 0.5;
drawHaloBar(cx - width * 0.5 + ftSide * 0.5, repHiCy, repHiH * 0.5, ftSide, Math.PI * 0.5);
drawHaloBar(cx + width * 0.5 - ftSide * 0.5, repHiCy, repHiH * 0.5, ftSide, Math.PI * 0.5);
}
} else {
const threshY = yBot + fullChordBoxH * 0.5;
const hiSideH = Math.max(ySideHi - threshY, 0);
if (hiSideH > 0) {
const hiCy = threshY + hiSideH * 0.5;
drawHaloBar(cx - width * 0.5 + ftSide * 0.5, hiCy, hiSideH * 0.5, ftSide, Math.PI * 0.5);
drawHaloBar(cx + width * 0.5 - ftSide * 0.5, hiCy, hiSideH * 0.5, ftSide, Math.PI * 0.5);
}
}
}
const chordName = chordTemplateLabel(bundle.chordTemplates?.[ch.id]);
if (chordName && firstInShapeRun && !chordWireHighDensity(ch)) {
const lblW = 28 * K, lblH = 9 * K;
const lbl = pChordLbl.get();
const mat = txtMat(chordName, '#e8d080', true, 'chord');
if (lbl.material.map !== mat.map) { lbl.material.map = mat.map; lbl.material.needsUpdate = true; }
lbl.material.opacity = Math.min(1, 0.3 + fade * 0.7) * chordTailMul;
// Gold chord name: slight +X shift from flush-left so it sits farther right.
const lblWS = lblW * _textSizeMul;
const lblHS = lblH * _textSizeMul;
const frameLeft = cx - width / 2;
const nameShiftX = NW * 0.94;
const nameVertTuck = NH * 0.02;
lbl.position.set(
frameLeft - lblWS / 2 + nameShiftX,
yMaxF + lblHS / 2 - nameVertTuck,
z);
lbl.scale.set(lblWS, lblHS, 1);
}
// Shape-based barre detection for the 3D indicator.
// Drives off chord notes alone — independent of label
// availability, so charts whose chordTemplates lack a
// .name still show the barre line.
// Matches drawChordDiagram PATH A + PATH B so the highway
// line and overlay bracket always agree on the same shapes:
// PATH A: 2+ adjacent strings at the minimum fret.
// PATH B: outer-edge full-span barre (e.g. B major x24442)
// where the two outer strings are at the minimum fret,
// every intermediate string is fretted (f>0), and no
// intermediate string also sits at the minimum fret.
// Scattered voicings like "1 3 1 3 1 0" (strings 0,2,4 at
// fret 1 but no two adjacent, and string 2 sits at min fret)
// correctly produce no indicator.
{
let bFret = Infinity;
for (const [, f] of chShape) {
if (f > 0) bFret = Math.min(bFret, f);
}
// Collect strings at minimum fret into scratch array (no allocation)
_scrAtMinFretLen = 0;
if (bFret < Infinity) {
for (const [s, f] of chShape) {
if (f === bFret) _scrAtMinFretArr[_scrAtMinFretLen++] = s;
}
// insertion sort — array is ≤8 elements
for (let _ii = 1; _ii < _scrAtMinFretLen; _ii++) {
const _v = _scrAtMinFretArr[_ii];
let _jj = _ii - 1;
while (_jj >= 0 && _scrAtMinFretArr[_jj] > _v) {
_scrAtMinFretArr[_jj + 1] = _scrAtMinFretArr[_jj]; _jj--;
}
_scrAtMinFretArr[_jj + 1] = _v;
}
}
// Inline longestConsecutiveRun (no array allocation)
let _barreRunStart = -1, _barreRunLen = 0;
{
let _curStart = -1, _curLen = 0;
for (let _ri = 0; _ri < _scrAtMinFretLen; _ri++) {
const _rv = _scrAtMinFretArr[_ri];
if (_curLen === 0 || _rv === _scrAtMinFretArr[_ri - 1] + 1) {
if (_curLen === 0) _curStart = _rv;
_curLen++;
} else {
if (_curLen > _barreRunLen) { _barreRunLen = _curLen; _barreRunStart = _curStart; }
_curStart = _rv; _curLen = 1;
}
}
if (_curLen > _barreRunLen) { _barreRunLen = _curLen; _barreRunStart = _curStart; }
}
let is3dBarre = _barreRunLen >= 2; // PATH A
let barreMinStr3d = is3dBarre ? _barreRunStart : -1;
let barreMaxStr3d = is3dBarre ? _barreRunStart + _barreRunLen - 1 : -1;
// PATH B: outer-edge full-span barre
const MIN_BARRE_SPAN_3D = Math.min(nStr - 1, 4);
if (_scrAtMinFretLen >= 2) {
const minS = _scrAtMinFretArr[0];
const maxS = _scrAtMinFretArr[_scrAtMinFretLen - 1];
if (maxS - minS >= MIN_BARRE_SPAN_3D) {
// chShape is already a Map<s, f> — query it directly
// instead of building a transient Set<s> every frame.
let allFretted = true;
for (let si = minS; si <= maxS; si++) {
if (!chShape.has(si) || chShape.get(si) <= 0) { allFretted = false; break; }
}
if (allFretted) {
if (is3dBarre) {
// PATH A fired: extend to full outer span.
barreMinStr3d = minS; barreMaxStr3d = maxS;
} else {
// PATH A did not fire: only draw if no inner
// string also sits at the minimum fret.
let innerAtMinFret = false;
for (let _ai = 1; _ai < _scrAtMinFretLen - 1; _ai++) {
const _as = _scrAtMinFretArr[_ai];
if (_as > minS && _as < maxS) { innerAtMinFret = true; break; }
}
if (!innerAtMinFret) {
is3dBarre = true;
barreMinStr3d = minS; barreMaxStr3d = maxS;
}
}
}
}
}
if (is3dBarre && chDt <= 0) {
const bx = xFretMid(bFret);
const yTop = Math.max(sY(barreMinStr3d), sY(barreMaxStr3d));
const yBot = Math.min(sY(barreMinStr3d), sY(barreMaxStr3d));
const lineH = yTop - yBot;
const bl = pBarreLine.get();
bl.position.set(bx, (yTop + yBot) / 2, 0.05 * K);
bl.scale.set(0.5 * K, lineH, 0.5 * K);
bl.material.opacity = 0.8 * chordTailMul;
}
}
// ── Chord fret numbers at the base of the highway ───────────────
// Show fret number per unique fretted position for non-repeated
// chords so the player can read the shape at a glance.
if (!isRepeat) {
const _chFretLblAlpha = Math.min(1.0, (AHEAD - chDt) / 0.35) * chordTailMul;
const _seenChordFrets = new Set();
for (const [, f] of chShape) {
if (f <= 0 || _seenChordFrets.has(f)) continue;
_seenChordFrets.add(f);
const lbl = pNoteFretLabel.get();
const mat = txtMat(f, FRET_LABEL_GOLD_HEX, false, 'noteFret');
if (lbl.material.map !== mat.map) {
lbl.material.map = mat.map;
lbl.material.needsUpdate = true;
}
lbl.position.set(xFretMid(f), yMinF, z);
lbl.renderOrder = renderOrderForLayerAtZ(z, 'CHORD_FRET_LABEL');
const _flS = 7.0 * K * (1 + 0.4 * chDt / AHEAD) * _textSizeMul * fretLabelScaleForFret(f);
lbl.scale.set(_flS, _flS, 1);
lbl.material.opacity = _chFretLblAlpha;
}
}
// ── Palm-mute strum indicator — pool (fill + lines) ──────────────
// Per-chord Z-proportional renderOrder: muted fill/lines and
// frame edges all use the named layer offsets above.
if (isRepeat && chordNotes.some(cn => cn.pm)) {
if (pPMXFill) {
const xf = pPMXFill.get();
xf.renderOrder = renderOrderForLayerAtZ(z, 'CHORD_STRUM_FILL');
xf.material.opacity = edgeOp * CHORD_BOX_EDGE_ALPHA;
xf.position.set(cx, cY, z - 0.0045 * K);
xf.scale.set(innerW * 0.5, -innerH * 0.5, 1);
xf.rotation.set(0, 0, 0);
}
if (pMuteXLines) {
const xl = pMuteXLines.get();
xl.renderOrder = renderOrderForLayerAtZ(z, 'CHORD_STRUM_LINE');
xl.material.opacity = edgeOp * 0.85;
xl.material.color.setHex(baseRimHex);
xl.position.set(cx, cY, z - 0.005 * K);
xl.scale.set(innerW * 0.5, -innerH * 0.5, thickZ * 0.5);
xl.rotation.set(0, 0, 0);
}
}
// ── Frethand-mute strum indicator — pool (fill + lines) ───────────
if (isRepeat && chordNotes.some(cn => cn.mt || cn.fhm)) {
if (pFHXFill) {
const xf = pFHXFill.get();
xf.renderOrder = renderOrderForLayerAtZ(z, 'CHORD_STRUM_FILL');
xf.material.opacity = edgeOp * CHORD_BOX_EDGE_ALPHA;
xf.position.set(cx, cY, z - 0.0045 * K);
xf.scale.set(innerW * 0.5, -innerH * 0.5, 1);
xf.rotation.set(0, 0, 0);
}
if (pFHXLines) {
const xl = pFHXLines.get();
xl.renderOrder = renderOrderForLayerAtZ(z, 'CHORD_STRUM_LINE');
xl.material.opacity = edgeOp * 0.85;
xl.material.color.setHex(baseRimHex);
xl.position.set(cx, cY, z - 0.005 * K);
xl.scale.set(innerW * 0.5, -innerH * 0.5, thickZ * 0.5);
xl.rotation.set(0, 0, 0);
}
}
} // end if (chDt > 0) — framebox + PM/FH mute only on highway
}
// ── Chord sustain length indicator — 3D plane rails ─────────────
// Left + right rail as plane meshes (PlaneGeometry +
// MeshBasicMaterial) in the WebGL scene so they respect
// renderOrder (16) and never occlude note gems (20/21).
// isRepeat chords also draw their rail: each repeat shows a
// segment from its own onset to the next chord's onset (or the
// handshape end, whichever is shorter), chaining together to
// cover the full handshape duration visually.
if (chShape.size > 1 && chordOpenBoxW != null && chDt < AHEAD) {
// Cap handshape-derived sustain at the gap to the next chord.
// Each chord (including repeats) only extends to the next
// chord's onset, so the rail never lingers past the anchor
// region of the current chord.
const _nextChordGap = (ci + 1 < chords.length)
? chords[ci + 1].t - ch.t
: Infinity;
// Use the time remaining in the handshape from this chord's
// onset (hsEnd - ch.t), NOT the full handshape span. When
// multiple chords share the same handshape window (e.g. A5
// at 63.527 and again at 64.137 both fall inside the same
// handshape start=63.527 end=64.239), each chord after the
// first starts mid-handshape. Using the full span (0.712s)
// for the mid-handshape chord gives a rail that extends
// 0.611s — far past the handshape end — causing the
// "elongated border" that visually swallows subsequent
// single notes. Clamping to (hsEnd - ch.t) gives 0.102s,
// which correctly terminates at the handshape boundary.
const _hsSus = (maxSus === 0 && !deferChordGems && hsHintFrame && hsHintFrame.hs)
? Math.min(Math.max(0, hsEnd(hsHintFrame.hs) - ch.t), _nextChordGap)
: 0;
// "Chord hold": suppressed non-arp synth chord where deferChordGems
// zeroed _hsSus. Use h3dSynthEnd (= handshape end_time) instead.
const _synthSus = (suppressSynthChord && ch.h3dSynth
&& !chordTemplateMarkedArpeggio(ch.id, bundle.chordTemplates)
&& ch.h3dSynthEnd != null)
? Math.max(0, ch.h3dSynthEnd - ch.t)
: 0;
const _rawSus = maxSus > 0 ? maxSus : Math.max(_hsSus, _synthSus);
// Apply the 0.4 s visual-minimum only to chords with an
// explicit note sustain (maxSus > 0). Handshape-derived
// sustain (_hsSus, already capped at _nextChordGap) must
// not be inflated — that would undo the gallop cap and
// cause the rail to reappear at the old anchor position.
const _effSus = maxSus > 0
? Math.max(_rawSus, 0.4)
: _rawSus;
const _dtSusEnd = chDt + _effSus;
if (_dtSusEnd > 0) {
// Clip the rail at the next anchor boundary so it doesn't
// extend into a different fret zone. The lane (pLane) slices
// correctly per-anchor; a single-segment rail at fixed X would
// visually "invade" the neighbouring region when anchors change
// within the sustain window.
let _dtSusEndRail = _dtSusEnd;
if (anchors && anchors.length) {
const _susAbsT = chDt > 0 ? ch.t : now;
if (getChartAnchorAt(anchors, _susAbsT) !==
getChartAnchorAt(anchors, now + _dtSusEnd)) {
// Binary search: first anchor starting strictly after _susAbsT.
let _lo = 0, _hi = anchors.length;
while (_lo < _hi) {
const _mid = (_lo + _hi) >>> 1;
if (anchors[_mid].time <= _susAbsT) _lo = _mid + 1;
else _hi = _mid;
}
if (_lo < anchors.length)
_dtSusEndRail = anchors[_lo].time - now;
}
}
const _zNear = chDt > 0 ? dZ(chDt) : 0;
const _zFar = dZ(Math.min(_dtSusEndRail, AHEAD));
const _railLen = _zNear - _zFar;
if (_railLen > 0.001) {
const _yA = sY(0), _yB = sY(nStr - 1);
const _yBot = Math.min(_yA, _yB) - S_GAP * 0.8;
const _fadeAhead = chDt > 0 ? Math.max(0, 1 - chDt / AHEAD) : 1;
const _fadeSus = Math.min(1, _dtSusEnd / 0.25);
const _op = _fadeAhead * _fadeSus * 0.9;
const _hex = chordHighwayLavenderArpVisual ? ARPEGGIO_RIM_BLUE_HEX : CHORD_BOX_TEAL_HEX;
const _railW = 1.875 * K; // visual width of each rail strip
const _zMid = _zNear - _railLen * 0.5; // centre in Z
for (const [_rx, _inDir] of [[chordFrameXL, -1], [chordFrameXR, 1]]) {
const _rxIn = _rx + _inDir * _railW * 0.5;
// Core rail
const rl = pSusRail.get();
rl.material.color.setHex(_hex);
rl.material.opacity = _op;
rl.position.set(_rxIn, _yBot, _zMid);
rl.scale.set(_railW, 1, _railLen);
// Bloom glow — wider gaussian plane, additive blending
if (!_leanSus) {
const bl = pSusRailBloom.get();
bl.material.color.setHex(_hex);
bl.material.opacity = _op * 0.8;
bl.position.set(_rxIn, _yBot + 0.001, _zMid);
bl.scale.set(3 * K, 1, _railLen);
}
}
}
}
}
}
}
// Fret span of the dynamic highway lane (wire dMin .. dMax). Reused
// so fret-column measure markers stay inside the same horizontal
// band as the blue track — previously markers used every inlay
// fret and stuck out past the lane whenever the camera narrowed.
let hwyLaneFretClipMin = null, hwyLaneFretClipMax = null;
const handShapesRails = bundle.handShapes;
hwyLaneArpOuterDividers = !!(handShapesRails && handShapesRails.length && laneRailArpHsFlags
&& arpeggioLaneOuterRailAtChartTime(
now, handShapesRails, laneRailBoundLo, laneRailBoundHi, laneRailArpHsFlags,
));
const arpLaneRimAccentMul = hwyLaneArpOuterDividers && laneRailArpHsFlags && handShapesRails
? arpeggioLaneDividerFrameAccentMul(
now, handShapesRails, chords, laneRailBoundLo, laneRailBoundHi, laneRailArpHsFlags,
)
: 1;
const arpLaneS = hwyLaneArpOuterDividers
? arpeggioLaneDividerXYScaleMatchFrameRim(arpLaneRimAccentMul)
: 1;
// ── Dynamic highway lane ──────────────────────────────────────
// Chart <anchor> tags drive the lane whenever they exist — do not
// require nearby notes (activeFrets) or camera-driven activity.
const hasChartAnchors = anchors && anchors.length;
if (hasChartAnchors || activeFrets.size > 0) {
// Lane tint: one translucent quad per playable fret column, exact
// wire→wire span (no horizontal pad) — see HWY_LANE_STRIPE_*.
const boardY = S_BASE - NH / 2 - 2 * K;
if (hasChartAnchors) {
const nearB = laneBoundsFromAnchor(getChartAnchorAt(anchors, now));
if (nearB) {
hwyLaneFretClipMin = nearB.dMin;
hwyLaneFretClipMax = nearB.dMax;
}
// Span the full (AHEAD + BEHIND) window so the lane's far edge
// lands at dZ(AHEAD) = -AHEAD*TS, aligned with the note horizon.
// Using just AHEAD here made the far edge stop at -TS*(AHEAD-BEHIND),
// leaving the last BEHIND seconds of notes without a lane underneath.
const sliceDt = (AHEAD + BEHIND) / HWY_LANE_TIME_SLICES;
// Single-pass build-and-merge into the parallel-array
// scratch buffers. Consecutive slices that resolve to the
// same anchor bounds collapse into one segment by extending
// its z1; otherwise a new entry appends. No per-frame array
// or {b,z0,z1} object allocations.
_laneSegLen = 0;
for (let k = 0; k < HWY_LANE_TIME_SLICES; k++) {
const dt0 = k * sliceDt;
const dt1 = (k + 1) * sliceDt;
const tC = now + (dt0 + dt1) * 0.5 - BEHIND;
const b = laneBoundsFromAnchor(getChartAnchorAt(anchors, tC));
if (!b) continue;
const z0 = dZ(dt0) + TS * BEHIND;
const z1 = dZ(dt1) + TS * BEHIND;
const arpSlice = (laneRailArpHsFlags && handShapesRails && handShapesRails.length)
? arpeggioLaneOuterRailLaneSlice(
dt0, dt1, now,
handShapesRails, laneRailBoundLo, laneRailBoundHi, laneRailArpHsFlags,
)
: false;
if (_laneSegLen > 0
&& _laneSegDMin[_laneSegLen - 1] === b.dMin
&& _laneSegDMax[_laneSegLen - 1] === b.dMax
&& arpSlice === _laneSegArp[_laneSegLen - 1]) {
_laneSegZ1[_laneSegLen - 1] = z1;
_laneSegTHi[_laneSegLen - 1] = tC;
} else if (_laneSegLen > 0
&& _laneSegDMin[_laneSegLen - 1] === b.dMin
&& _laneSegDMax[_laneSegLen - 1] === b.dMax
&& arpSlice !== _laneSegArp[_laneSegLen - 1]) {
_laneSegDMin[_laneSegLen] = b.dMin;
_laneSegDMax[_laneSegLen] = b.dMax;
_laneSegZ0[_laneSegLen] = z0;
_laneSegZ1[_laneSegLen] = z1;
_laneSegTLo[_laneSegLen] = tC;
_laneSegTHi[_laneSegLen] = tC;
_laneSegArp[_laneSegLen] = arpSlice;
_laneSegLen++;
} else {
_laneSegDMin[_laneSegLen] = b.dMin;
_laneSegDMax[_laneSegLen] = b.dMax;
_laneSegZ0[_laneSegLen] = z0;
_laneSegZ1[_laneSegLen] = z1;
_laneSegTLo[_laneSegLen] = tC;
_laneSegTHi[_laneSegLen] = tC;
_laneSegArp[_laneSegLen] = arpSlice;
_laneSegLen++;
}
}
{
const laneOp = (HWY_LANE_STRIPE_OP_BASE + highwayIntensity * HWY_LANE_STRIPE_OP_INT)
* (_venueSceneOverride ? VENUE_LANE_OP_BOOST : 1);
// 2 shared materials (odd/even); opacity travels via the
// material so set it once per frame, not per mesh.
mLaneOdd.opacity = laneOp;
mLaneEven.opacity = laneOp;
for (let s = 0; s < _laneSegLen; s++) {
const segZ0 = _laneSegZ0[s];
const segZ1 = _laneSegZ1[s];
const stripLen = Math.max(Math.abs(segZ1 - segZ0), 1e-6);
const zc = (segZ0 + segZ1) * 0.5;
const fLow = _laneSegDMin[s] + 1;
const fHi = _laneSegDMax[s];
for (let f = fLow; f <= fHi; f++) {
const xl = xFret(f - 1), xr = xFret(f);
const laneW = Math.abs(xr - xl);
const lane = pLane.get();
lane.position.set((xl + xr) * 0.5, boardY + 0.02 * K, zc);
lane.rotation.x = -Math.PI / 2;
lane.scale.set(laneW, stripLen, 1);
const odd = ((f - fLow) & 1) === 0;
lane.material = odd ? mLaneOdd : mLaneEven;
lane.renderOrder = 1;
}
}
}
{
const yPos = boardY + 0.03 * K;
const divOpArp = Math.min(0.92, 0.16 + highwayIntensity * 0.42);
if (mLaneDividerArp) {
mLaneDividerArp.opacity = divOpArp;
}
for (let s = 0; s < _laneSegLen; s++) {
const segZ0 = _laneSegZ0[s];
const segZ1 = _laneSegZ1[s];
const dz = Math.max(Math.abs(segZ1 - segZ0), 1e-6);
const zMid = (segZ0 + segZ1) * 0.5;
const dMinSeg = _laneSegDMin[s];
const dMaxSeg = _laneSegDMax[s];
const fDiv0 = Math.floor(dMinSeg);
const fDiv1 = Math.ceil(dMaxSeg);
for (let f = fDiv0; f <= fDiv1; f++) {
if (_laneSegArp[s] && (f === fDiv0 || f === fDiv1)) continue;
const div = pLaneDivider.get();
div.position.set(xFret(f), yPos, zMid);
div.material = mLaneDivider;
div.scale.set(1, 1, dz);
div.renderOrder = 2;
}
}
for (let s = 0; s < _laneSegLen; s++) {
if (!_laneSegArp[s]) continue;
const dMinSeg = _laneSegDMin[s];
const dMaxSeg = _laneSegDMax[s];
const fL = Math.floor(dMinSeg);
const fR = Math.ceil(dMaxSeg);
const segZ0 = _laneSegZ0[s];
const segZ1 = _laneSegZ1[s];
const arpRailLen = Math.max(Math.abs(segZ1 - segZ0), 1e-6);
const zArpMid = (segZ0 + segZ1) * 0.5;
const tMidSeg = (_laneSegTLo[s] + _laneSegTHi[s]) * 0.5;
const arpMulSeg = (laneRailArpHsFlags && handShapesRails && handShapesRails.length)
? arpeggioLaneDividerFrameAccentMul(
tMidSeg, handShapesRails, chords,
laneRailBoundLo, laneRailBoundHi, laneRailArpHsFlags,
)
: 1;
const arpSSeg = arpeggioLaneDividerXYScaleMatchFrameRim(arpMulSeg);
for (const xf of [fL, fR]) {
const div = pLaneDivider.get();
div.position.set(xFret(xf), yPos, zArpMid);
div.material = mLaneDividerArp;
div.scale.set(arpSSeg, arpSSeg, arpRailLen);
div.renderOrder = 2;
}
}
}
} else {
let dMin, dMax;
let divMin, divMax;
let minF = 99, maxF = 0;
activeFrets.forEach(f => { if (f > 0) { minF = Math.min(minF, f); maxF = Math.max(maxF, f); } });
dMin = minF - 1;
dMax = maxF;
const HWY_LANE_SPAN = 4;
let span = dMax - dMin;
if (span > HWY_LANE_SPAN) {
dMin = Math.round((dMin + dMax - HWY_LANE_SPAN) / 2);
dMax = dMin + HWY_LANE_SPAN;
if (dMax > NFRETS) {
dMax = NFRETS;
dMin = dMax - HWY_LANE_SPAN;
}
if (dMin < 0) {
dMin = 0;
dMax = HWY_LANE_SPAN;
}
} else if (span < HWY_LANE_SPAN) {
const need = HWY_LANE_SPAN - span;
dMax = Math.min(NFRETS, dMax + need);
if (dMax - dMin < HWY_LANE_SPAN) {
dMin = Math.max(0, dMin - (HWY_LANE_SPAN - (dMax - dMin)));
}
}
if (dMax < dMin) dMax = dMin;
hwyLaneFretClipMin = dMin;
hwyLaneFretClipMax = dMax;
divMin = dMin;
divMax = dMax;
// Same fix: extend to AHEAD+BEHIND so far edge = -AHEAD*TS.
const laneLen = TS * (AHEAD + BEHIND);
const zLane = -laneLen / 2 + TS * BEHIND;
const laneOp = (HWY_LANE_STRIPE_OP_BASE + highwayIntensity * HWY_LANE_STRIPE_OP_INT)
* (_venueSceneOverride ? VENUE_LANE_OP_BOOST : 1);
mLaneOdd.opacity = laneOp;
mLaneEven.opacity = laneOp;
const fLow = dMin + 1;
const fHi = dMax;
for (let f = fLow; f <= fHi; f++) {
const xl = xFret(f - 1), xr = xFret(f);
const laneWStrip = Math.abs(xr - xl);
const lane = pLane.get();
lane.position.set((xl + xr) / 2, boardY + 0.02 * K, zLane);
lane.rotation.x = -Math.PI / 2;
lane.scale.set(laneWStrip, laneLen, 1);
const odd = ((f - fLow) & 1) === 0;
lane.material = odd ? mLaneOdd : mLaneEven;
lane.renderOrder = 1;
}
if (highwayIntensity > 0.05) {
const divLen = TS * (AHEAD + BEHIND);
const yPos = boardY + 0.03 * K;
const divOp2 = 0.02 + highwayIntensity * 0.1;
const divOpArp2 = Math.min(0.92, 0.16 + highwayIntensity * 0.42);
if (mLaneDivider && mLaneDividerArp) {
mLaneDivider.opacity = divOp2;
mLaneDividerArp.opacity = divOpArp2;
}
const fDivA = Math.floor(divMin);
const fDivB = Math.ceil(divMax);
for (let f = fDivA; f <= fDivB; f++) {
if (hwyLaneArpOuterDividers && (f === fDivA || f === fDivB)) continue;
const div = pLaneDivider.get();
div.position.set(xFret(f), yPos, dZ(0) - divLen * 0.5 + TS * BEHIND);
div.material = mLaneDivider;
div.scale.set(1, 1, divLen);
div.renderOrder = 2;
}
if (hwyLaneArpOuterDividers) {
for (const xf of [fDivA, fDivB]) {
const div = pLaneDivider.get();
div.position.set(xFret(xf), yPos, zLane);
div.material = mLaneDividerArp;
div.scale.set(arpLaneS, arpLaneS, laneLen);
div.renderOrder = 2;
}
}
}
}
// ── Fret boundary extension lines ─────────────────────────
if (mLaneDividerExt && fretDividersVisible) {
const extLaneLen = TS * (AHEAD + BEHIND);
const extZMid = -extLaneLen / 2 + TS * BEHIND;
const extYPos = boardY + 0.03 * K;
mLaneDividerExt.opacity = Math.max(0.3, 0.3 + highwayIntensity * 0.15);
for (let f = 0; f <= NFRETS; f++) {
const div = pLaneDivider.get();
div.position.set(xFret(f), extYPos, extZMid);
div.material = mLaneDividerExt;
div.scale.set(1, 1, extLaneLen);
div.renderOrder = 2;
}
}
}
// ── Dynamic fret number row (heat-coloured) ───────────────────
// Two-part fix for issue #35:
// 1. renderOrder = 1000 forces these sprites to the end of
// the transparent queue so they always paint on top of
// notes, sustain trails, lane plane, etc. depthTest is
// already disabled by txtMat(), but `depthTest: false`
// only exempts the sprite from depth comparison — it
// doesn't pin draw order. Without an explicit
// renderOrder, a note rendered after the label in the
// transparent pass would still overdraw it. Match the
// pattern already used for lane and dividers.
// 2. Y-offset bumped from S_GAP * 0.6 to S_GAP * 1.4 so the
// label band sits clearly below the lowest string in
// screen space, even at the largest active scale
// (intensity-driven, up to ~5.7 * K vertical extent).
// This buys a real visual gap between notes-on-the-
// lowest-string and the row, on top of the renderOrder
// guarantee — labels never share screen with what's
// happening on the playing strings just above them.
{
const yBottom = Math.min(sY(0), sY(nStr - 1));
// anchorSpan: [f0, f1] = [anchor.fret, anchor.fret + width - 1]
// e.g. { fret:3, width:4 } → f0=3, f1=6 → frets 3,4,5,6 gold.
const anchorSpan = anchorPlayedFretSpanAt(anchors, now);
for (let f = 1; f <= NFRETS; f++) {
const isInAnchor = anchorSpan
&& f >= anchorSpan.f0 && f <= anchorSpan.f1;
const isMainFret = DOTS.includes(f);
// Rule 1: show gray label only on main frets (dot positions).
// Rule 2: show gold label on any fret inside the anchor range.
// Non-main frets outside the anchor range are hidden entirely.
if (!isInAnchor && !isMainFret) continue;
const lb = pFretLbl.get();
lb.material = txtMat(f,
isInAnchor ? FRET_LABEL_GOLD_HEX : FRET_LABEL_IDLE_HEX,
false, 'fretRow');
lb.position.set(xFretMid(f), yBottom - S_GAP * 1.4, 0.5 * K);
lb.material.opacity = isInAnchor ? 1.0 : 0.55;
const scale = 5.95 * _textSizeMul * fretLabelScaleForFret(f);
lb.scale.set(scale * K, scale * K, 1);
lb.renderOrder = 1000;
}
}
// ── Beat lines ────────────────────────────────────────────────
if (beats) {
const board = boardSpanX();
const bw2 = board.width + 4 * K;
let lastM = -1;
for (const b of beats) {
const meas = b.measure !== lastM; lastM = b.measure;
if (b.time < t0 || b.time > t1) continue;
const bl2 = pBeat.get();
bl2.material = meas ? mBeatM : mBeatQ;
bl2.scale.set(bw2, 1, 1);
bl2.position.set(board.min - 2 * K, S_BASE - NH / 2 - 1.5 * K, dZ(b.time - now));
}
}
// ── Section labels ────────────────────────────────────────────
// Gated on sectionLabelsOnHighway (advanced setting, default off).
// The HUD card (drawSectionHud, called from the lyricsCtx block in
// draw()) is the primary surface for section info; the on-highway
// sprites are kept as an opt-in for users who want the in-scene cue.
if (sections && sectionLabelsOnHighway) {
const labelY = Math.max(sY(0), sY(nStr - 1)) + 8 * K;
for (const s of sections) {
if (s.time < t0 || s.time > t1) continue;
const sp = pSec.get();
sp.material = txtMat(s.name, '#00cccc', true, 'section');
sp.scale.set(20 * K * _textSizeMul, 5 * K * _textSizeMul, 1);
sp.position.set(xFret(12), labelY, dZ(s.time - now));
}
}
// ── Fret-column reference markers ─────────────────────────────
// Every Nth measure, spawn a row of fret-number sprites on the
// board floor that scroll toward the hit line and vanish at Z=0.
// With a chart <anchor>, which frets appear follows the inlay
// cadence (DOTS) centred on the snapped anchor fret (~2 positions
// back and ~3 forward in that list, e.g. anchor 7 → 3,5,7,9,12,15).
// Without anchors, all DOTS positions are candidates; octave + lane
// clipping apply. With <anchor>, the cadence row ignores both so
// frets before/after the lane still show as reference. Light grey
// when that fret is in the active set, dark grey otherwise.
//
// Per-wave gate cache: hasLow/hasHigh/fretList snapshotted at first
// sight of a wave so the render decision stays stable through
// the wave's full flight. Without this, activeFrets shifting
// mid-song would drop markers mid-flight (user-reported bug:
// "numbers disappear before they get all the way towards me").
if (beats && fretColumnMarkerCadence > 0) {
// Prune stale wave cache entries (wave already past now).
if (_fretMarkerWaveCache.size > 0) {
for (const k of _fretMarkerWaveCache.keys()) {
if (k < now) _fretMarkerWaveCache.delete(k);
}
}
const minStringY = Math.min(sY(0), sY(nStr - 1));
const labelY = minStringY - S_GAP * 0.8;
for (const b of beats) {
// Non-downbeats are encoded as measure=-1; only actual
// measure starts (measure >= 0) can spawn marker waves.
if (b.measure < 0) continue;
if (b.time < 0 || b.time > t1) continue;
if (b.time <= now) continue;
if ((b.measure | 0) % fretColumnMarkerCadence !== 0) continue;
// Snapshot the active-range gate at first sight of this
// wave. We scan notes/chords in a 2s window starting at
// b.time rather than activeFrets, because activeFrets only
// covers now+2s and waves first become visible up to
// AHEAD (3s) ahead — using activeFrets would cache
// {hasLow:false, hasHigh:false} for far waves and suppress
// the entire row for its full flight.
let cached = _fretMarkerWaveCache.get(b.time);
if (!cached) {
let hasLow = false, hasHigh = false;
const wT0 = b.time, wT1 = b.time + 2;
// notes and chords are time-sorted; binary-search to the
// first entry >= wT0, then break once past wT1 to avoid
// O(song_length) work per newly-seen wave.
if (notes) {
const startI = lowerBoundT(notes, wT0);
for (let i = startI; i < notes.length; i++) {
const n = notes[i];
if (n.t > wT1) break;
if (!validString(n.s) || n.f <= 0) continue;
if (n.f <= 12) hasLow = true; else hasHigh = true;
if (hasLow && hasHigh) break;
}
}
if ((!hasLow || !hasHigh) && chords) {
const startI = lowerBoundT(chords, wT0);
outer: for (let i = startI; i < chords.length; i++) {
const ch = chords[i];
if (ch.t > wT1) break outer;
if (!ch.notes) continue;
for (const cn of ch.notes) {
if (!validString(cn.s) || cn.f <= 0) continue;
if (cn.f <= 12) hasLow = true; else hasHigh = true;
if (hasLow && hasHigh) break outer;
}
}
}
const anc = anchors && anchors.length ? getChartAnchorAt(anchors, b.time) : null;
const anchorFret = anc != null ? Number(anc.fret) : NaN;
const anchorKeyed = Number.isFinite(anchorFret) && anchorFret >= 0;
const fretList = anchorKeyed
? fretColumnMarkersForAnchor(anchorFret, DOTS)
: DOTS.slice();
cached = { hasLow, hasHigh, fretList, anchorKeyed };
_fretMarkerWaveCache.set(b.time, cached);
}
if (!cached.hasLow && !cached.hasHigh && !cached.anchorKeyed) continue;
const dt = b.time - now;
// Fade in over 0.35 s from the maximum lookahead distance so
// the label appears at its correct final size the moment it
// becomes visible, rather than gradually emerging as a tiny
// dim spec through scene fog (fog is disabled on the sprite
// material; this manual ramp replaces it).
const _colFadeIn = Math.min(1.0, (AHEAD - dt) / 0.35);
if (_colFadeIn <= 0) continue;
const z = dZ(dt);
// Sprite scale matches the per-note connector fret label
// (drawNote → pNoteFretLabel) so cadence markers read the
// same size at a given Z — no extra world-scale boost.
const clipMin = hwyLaneFretClipMin;
const clipMax = hwyLaneFretClipMax;
const fretList = cached.fretList || DOTS;
const anchorKeyedRow = !!cached.anchorKeyed;
for (const f of fretList) {
let show;
if (anchorKeyedRow) {
show = true;
} else {
if (f === 12) show = cached.hasLow || cached.hasHigh;
else if (f < 12) show = cached.hasLow;
else show = cached.hasHigh;
}
if (!show) continue;
if (!anchorKeyedRow && clipMin != null && (f <= clipMin || f > clipMax)) continue;
// Fixed colour — avoids mid-flight colour flips caused by
// activeFrets only covering now+2s while waves appear at
// AHEAD (3s), which made each marker start dark then
// suddenly jump to light when its note entered the 2s window.
const color = '#888888';
const sp = pFretColMarker.get();
const m = txtMat(f, color, false, 'noteFret');
if (sp.material.map !== m.map) {
sp.material.map = m.map;
sp.material.needsUpdate = true;
}
sp.material.opacity = 0.85 * _colFadeIn;
sp.position.set(xFretMid(f), labelY, z);
// Z-proportional: sits between chord frame and note gem
// at the same depth, so chord frames never overdraw the
// marker and the marker never overdraws gems.
sp.renderOrder = renderOrderForLayerAtZ(z, 'FRET_COLUMN');
// Scale grows linearly from 2× at max lookahead to 1× at hit line,
// combined with natural perspective attenuation the label starts
// visibly larger and converges to the row-label size at z=0.
const sz = 7.0 * K * (1 + 0.4 * dt / AHEAD) * _textSizeMul * fretLabelScaleForFret(f);
sp.scale.set(sz, sz, 1);
}
}
}
// ── Camera target ─────────────────────────────────────────────
let lockActive;
if (!(cameraMode === 'lookahead')) {
lockActive = _applyNoteCamTargets(
camWX, camWSum, camDistMin, camDistMax, camDistGot,
camHystF, camDistHystF, /* skipDistHyst= */ false);
prevLockActive = lockActive;
} else {
const lookaheadMaxF = lookaheadBoundsNow ? lookaheadBoundsNow.maxF : 0;
const lookaheadHasBounds = lookaheadBoundsNow != null;
let dtSec = 1 / 120;
if (_lookaheadCamPrevNow !== null) {
const rawDt = _frameNow - _lookaheadCamPrevNow;
if (rawDt > -1 && rawDt < 2) dtSec = Math.min(0.2, Math.max(1 / 960, rawDt));
}
_lookaheadCamPrevNow = _frameNow;
const dBlend = Math.min(0.2, Math.max(1e-4, dtSec));
const lowBlendFs = 1 - Math.pow(1 - CAM_FOCUS_BLEND_RATE, dBlend);
if (!lookaheadHasBounds || lookaheadMaxF <= LOOKAHEAD_LOCK_ENGAGE_MAXF)
_lookaheadHiNeckLatch = false;
else if (lookaheadMaxF >= LOOKAHEAD_LOCK_RELEASE_MAXF)
_lookaheadHiNeckLatch = true;
const lookaheadLockLowEligible = cameraLockLow
&& (!lookaheadHasBounds
|| (!_lookaheadHiNeckLatch && lookaheadMaxF <= 12));
let rawLowBU;
if (lookaheadLockLowEligible) {
rawLowBU = camLowFretPullbackU(1);
} else if (lookaheadBoundsNow) {
rawLowBU = camLowFretPullbackU(lookaheadBoundsNow.minF);
} else {
rawLowBU = camLowFretPullbackU(CAM_LOCK_CENTER_FRET);
}
_lookaheadLowBonusU = rawLowBU * lowBlendFs + _lookaheadLowBonusU * (1 - lowBlendFs);
if (lookaheadLockLowEligible) {
const lockedBaseU = camBaseDistU(12);
const lockZoomMul = CAM_LOCK_ZOOM_MIN +
(CAM_LOCK_ZOOM_MAX - CAM_LOCK_ZOOM_MIN) * cameraLockZoom;
lookaheadSmoothCamStep(dtSec, xFretMid(CAM_LOCK_CENTER_FRET), 12);
tgtX = _lookaheadCamX;
tgtDist = (lockedBaseU + _lookaheadLowBonusU) * K * lockZoomMul;
prevLowFretBonus = _lookaheadLowBonusU;
lockActive = true;
} else {
if (lookaheadBoundsNow) {
const tgtWX = lookaheadTargetWorldX(
lookaheadBoundsNow.minF, lookaheadBoundsNow.maxF);
const tgtSpanInt = Math.max(
1, lookaheadBoundsNow.maxF - lookaheadBoundsNow.minF + 1);
lookaheadSmoothCamStep(dtSec, tgtWX, tgtSpanInt);
tgtDist = (camBaseDistU(_lookaheadFretSpan) + _lookaheadLowBonusU) * K;
prevLowFretBonus = _lookaheadLowBonusU;
} else {
lookaheadSmoothCamStep(dtSec, _lookaheadCamX, _lookaheadFretSpan);
tgtDist = (camBaseDistU(_lookaheadFretSpan) + _lookaheadLowBonusU) * K;
prevLowFretBonus = _lookaheadLowBonusU;
}
tgtX = _lookaheadCamX;
lockActive = false;
}
prevLockActive = lockActive;
}
// ── Chord diagram: track chord, drive entrance + crossfade animations ─
{
let newChord = null;
if (chords) {
// Only chords in (now - DIAG_LINGER_S, now] — use binary search
// to skip past old chords, break once we pass `now`.
const _dlo = lowerBoundT(chords, now - DIAG_LINGER_S);
for (let _di = _dlo; _di < chords.length; _di++) {
const ch = chords[_di];
const chDt = ch.t - now;
if (chDt > 0) break;
if (!ch.notes) continue;
const tmpl = bundle.chordTemplates?.[ch.id];
const lbl = chordTemplateLabel(tmpl);
// Last valid chord (highest t ≤ now) naturally wins since array is sorted.
if (lbl && tmpl?.frets) {
newChord = { name: lbl, frets: tmpl.frets, t: ch.t, t0: ch.t, chDt, nStr };
}
}
}
// Include frets in the key so two templates sharing a display name but
// differing in fingering each trigger a fresh crossfade/entrance.
const newKey = newChord ? newChord.name + '|' + newChord.frets.join(',') : null;
if (newKey !== _diagLastKey) {
if (_diagChord && newKey !== null) {
// Recompute outgoing alpha from stored event time rather than the
// stale per-frame chDt; after dropped frames or seeks this prevents
// the overlay jumping to a stale brightness before the crossfade.
const freshChDt = _diagChord.t !== undefined ? _diagChord.t - now : _diagChord.chDt;
const prevOpacity = Math.max(0, Math.min(1, 1 + freshChDt / DIAG_LINGER_S));
// Only crossfade when the outgoing chord is actually visible at now.
// freshChDt > 0 means the old chord is in the future (backward seek
// crossed the chord boundary). In that case _diagChord is stale, so
// recompute the outgoing diagram from the chart — find the most recent
// named chord that ends just before newChord.t and use it as _diagPrev
// so that seeking into a historical chord transition fades correctly
// rather than snapping straight to the new chord.
if (freshChDt <= 0 && prevOpacity > 0) {
// Use the string count the outgoing chord was captured with, not the
// current nStr — an arrangement switch during a 150 ms crossfade
// must not remap the outgoing diagram onto the new layout.
_diagPrev = { name: _diagChord.name, frets: _diagChord.frets, nStr: _diagChord.nStr ?? nStr, t: _diagChord.t0 ?? _diagChord.t ?? now };
_diagPrevStartOpacity = prevOpacity;
_diagPrevOpacity = prevOpacity;
_diagPrevStartT = now;
// entranceT for the outgoing diagram is computed live from _diagPrev.t
// each frame (see draw path), so it rewinds correctly on backward seeks
// within the crossfade window — no separate snapped state needed here.
} else if (freshChDt > 0) {
// Backward seek: _diagChord is now in the future.
// Look up the chart chord immediately before newChord.t to provide
// the correct historical outgoing diagram for the crossfade.
let histPrev = null;
if (chords && newChord) {
// Find the most recent named chord before newChord.t.
// Chords are sorted ascending, so all matches are before
// lowerBoundT(chords, newChord.t); iterate in order and
// take the last valid one.
const _hpHi = lowerBoundT(chords, newChord.t);
for (let _hpi = 0; _hpi < _hpHi; _hpi++) {
const ch = chords[_hpi];
if (!ch.notes) continue;
const tmpl = bundle.chordTemplates?.[ch.id];
const lbl = chordTemplateLabel(tmpl);
if (lbl && tmpl?.frets) {
histPrev = { name: lbl, frets: tmpl.frets, t: ch.t, t0: ch.t, nStr };
}
}
}
// Only start a crossfade if we are still within DIAG_CROSSFADE_S of
// newChord.t; seeking further into the chord skips the crossfade.
// Also skip if histPrev was no longer visible when newChord started
// (gap longer than DIAG_LINGER_S), so only genuinely adjacent chord
// transitions produce a crossfade — not seeks to just after any new
// chord that happens to have an older chord somewhere earlier in the song.
const elapsed = newChord ? now - newChord.t : Infinity;
const histPrevVisible = histPrev && (newChord.t - histPrev.t) < DIAG_LINGER_S;
if (histPrevVisible && elapsed >= 0 && elapsed < DIAG_CROSSFADE_S) {
// Start at the linger opacity the outgoing chord would have had at
// newChord.t during forward playback, not always 1. This prevents
// a chord that was mostly faded from appearing brighter on a seek.
const histStartOpacity = Math.max(0, Math.min(1,
1 - (newChord.t - histPrev.t) / DIAG_LINGER_S));
_diagPrev = histPrev;
_diagPrevStartOpacity = histStartOpacity;
_diagPrevOpacity = Math.max(0, histStartOpacity * (1 - elapsed / DIAG_CROSSFADE_S));
_diagPrevStartT = newChord.t;
} else {
_diagPrev = null; _diagPrevOpacity = 0; _diagPrevStartOpacity = 0;
_diagPrevStartT = null;
}
} else {
// prevOpacity <= 0: old chord already fully faded, no crossfade needed.
_diagPrev = null; _diagPrevOpacity = 0; _diagPrevStartOpacity = 0;
_diagPrevStartT = null;
}
} else {
_diagPrev = null; _diagPrevOpacity = 0; _diagPrevStartOpacity = 0;
_diagPrevStartT = null;
}
_diagLastKey = newKey;
// Only update _diagChord when the chord key actually changes so that a
// lingering chord's original nStr is preserved on subsequent frames.
// (newChord is rebuilt every frame with the live nStr; unconditionally
// assigning here would stomp the captured nStr if the arrangement switches
// while the same chord is still in its linger window.)
_diagChord = newChord;
} else if (newKey !== null && newChord && _diagChord) {
// Same chord re-seen. Update linger expiry (t) when the event time changes.
// Forward restrum (newChord.t > _diagChord.t): extend the linger window
// but preserve t0 so the entrance animation is NOT replayed — avoids the
// overlay jumping back to its 0.85× scale on every strum of the same chord.
// Backward seek to earlier occurrence (newChord.t < _diagChord.t): update
// both t and t0 to restart the entrance animation from the earlier position.
if (newChord.t !== _diagChord.t) {
_diagChord = newChord.t < _diagChord.t
? { ..._diagChord, t: newChord.t, t0: newChord.t } // backward seek
: { ..._diagChord, t: newChord.t }; // forward restrum
}
}
// Guard for backward seeks within the same chord (same key, no branch above).
// If _diagPrevStartT is in the future relative to now, the crossfade was set up
// during a later playback position that has since been seeked past. Clear it so
// the stale outgoing diagram does not stay fully visible at the seek target.
if (_diagPrev && _diagPrevStartT !== null && _diagPrevStartT > now) {
_diagPrev = null; _diagPrevOpacity = 0; _diagPrevStartOpacity = 0;
_diagPrevStartT = null;
}
// Entrance: derived from t0 (the original appearance time, not updated on
// forward restrums) so repeated hits of the same chord do not replay the
// 0.85→1.0 scale animation. On backward seeks t0 is updated alongside t,
// so the animation still rewinds correctly to the earlier position.
const _entranceAnchor = _diagChord && (_diagChord.t0 ?? _diagChord.t);
_diagEntranceT = (_diagChord && _entranceAnchor !== undefined)
? Math.min(1.0, Math.max(0, (now - _entranceAnchor) / DIAG_ENTRANCE_S))
: 1.0;
// Crossfade: derived from absolute start time so backward seeks within the
// crossfade window correctly rewind the fade. _diagPrev is kept alive (at
// opacity 0) until the next key change rather than destroyed here, so that a
// backward seek that re-enters the crossfade window can recompute a positive
// opacity. Seeks before _diagPrevStartT are handled by the guard above.
if (_diagPrev && _diagPrevStartT !== null) {
const fadedT = Math.max(0, now - _diagPrevStartT);
_diagPrevOpacity = Math.max(0, _diagPrevStartOpacity * (1 - fadedT / DIAG_CROSSFADE_S));
}
}
// ── Finalise InstancedMesh batches ────────────────────────────────
// Flush all 6 IMs: set visible instance count and mark buffers dirty.
// Must run after all drawNote() / chord-loop writes are done.
if (imPMTech) {
imPMTech.count = _imPMTechCount;
if (_imPMTechCount > 0) {
imPMTech.instanceMatrix.needsUpdate = true;
imPMTech.geometry.getAttribute('instanceAlpha').needsUpdate = true;
}
}
if (imFHTech) {
imFHTech.count = _imFHTechCount;
if (_imFHTechCount > 0) {
imFHTech.instanceMatrix.needsUpdate = true;
imFHTech.geometry.getAttribute('instanceAlpha').needsUpdate = true;
}
}
// These IMs are kept alive but always empty (count=0) — rendering
// is now handled by the pPMXFill / pMuteXLines / pFHXFill / pFHXLines
// pools which support per-chord Z-proportional renderOrder.
if (imPMXFill) imPMXFill.count = 0;
if (imPMXLines) imPMXLines.count = 0;
if (imFHXFill) imFHXFill.count = 0;
if (imFHXLines) imFHXLines.count = 0;
pbEnd(5);
pbEnd(0);
pbReportTick();
}
/**
* Indexed sustain ribbon (~SLIDE_RIBBON_SAMPLES longitudinal slices)
* for slides, bends, vibrato and tremolo — smooth contour vs stacked
* BoxGeometry segments.
*/
function slideRibbonUpdatePositions(geom, strandBaseX, tw, th, y, sliceDur, susStart, now, n, slideSt) {
const pa = geom.attributes.position.array;
const S = SLIDE_RIBBON_SAMPLES;
// slideOffsetWorldX is defined at module scope so it returns a
// right-handed delta (built from non-lefty fretMid). strandBaseX is
// already lefty-mirrored via xFretMid at the call site, so the
// delta needs the same sign flip to keep the slide tracking the
// mirrored fretboard direction.
const dirMul = _leftyCached ? -1 : 1;
let v = 0;
for (let k = 0; k <= S; k++) {
const Tk = susStart + (k / S) * sliceDur;
const zk = dZ(Tk - now);
const xc = strandBaseX
+ dirMul * slideOffsetWorldX(n, Tk, slideSt)
+ tremoloOffsetWorldX(n, Tk, tw);
const yc = y + techniqueYOffsetWorld(n, Tk);
pa[v++] = xc - tw * 0.5; pa[v++] = yc - th * 0.5; pa[v++] = zk;
pa[v++] = xc + tw * 0.5; pa[v++] = yc - th * 0.5; pa[v++] = zk;
pa[v++] = xc + tw * 0.5; pa[v++] = yc + th * 0.5; pa[v++] = zk;
pa[v++] = xc - tw * 0.5; pa[v++] = yc + th * 0.5; pa[v++] = zk;
}
geom.attributes.position.needsUpdate = true;
// Normals are pre-baked at geometry creation (see mkSlideRibbonGeo);
// axis-aligned cross-section means they don't need per-frame recompute.
}
function noteHasVibrato(n) {
return !!(n && (n.vb || n.vibrato));
}
function bendVisualDirY(stringIdx) {
if (!Number.isFinite(stringIdx) || nStr <= 1) return 1;
const visualIdx = _invertedCached ? stringIdx : (nStr - 1 - stringIdx);
return visualIdx >= (nStr - 1) * 0.5 ? -1 : 1;
}
function bendSemisAtTime(n, chartTime) {
const bn = Number(n?.bn) || 0;
if (!(bn > 0) || !(n?.sus > 0)) return 0;
const p = Math.max(0, Math.min(1, (chartTime - n.t) / Math.max(n.sus, 1e-6)));
// rise → hold → release: ramp up over the first ~35 %, hold, then
// release back down over the last ~30 %. Depicts the bend gesture
// (up and back down) rather than a monotone climb that only ever
// showed the bend going up. Drives both the sustain ribbon's Y
// contour and the gem's techniqueYNow offset.
const RISE = BEND_ENV_RISE_FRAC, REL = BEND_ENV_RELEASE_FRAC;
let env;
if (p < RISE) env = p / RISE;
else if (p < 1 - REL) env = 1;
else env = (1 - p) / REL;
return bn * Math.max(0, Math.min(1, env));
}
function vibratoSemisAtTime(n, chartTime) {
if (!noteHasVibrato(n) || !(n?.sus > 0)) return 0;
const elapsed = Math.max(0, chartTime - n.t);
return Math.sin(elapsed * Math.PI / VIBRATO_HALF_WAVE_S);
}
function techniqueYOffsetWorld(n, chartTime) {
if (!(n?.sus > 0)) return 0;
const bendSemi = bendSemisAtTime(n, chartTime);
const vibratoSemi = vibratoSemisAtTime(n, chartTime);
if (bendSemi === 0 && vibratoSemi === 0) return 0;
return bendVisualDirY(n.s) * BEND_HALFSTEP_WORLD_Y * (bendSemi + vibratoSemi);
}
function tremoloOffsetWorldX(n, chartTime, trailW) {
if (!(n?.tr) || !(n?.sus > 0)) return 0;
const elapsed = Math.max(0, chartTime - n.t);
const phase = (elapsed % TREMOLO_BUMP_S) / TREMOLO_BUMP_S;
const tri = (Math.abs(phase - 0.5) - 0.25) * 3;
return trailW * 0.5 * tri;
}
/* ── Note renderer ───────────────────────────────────────────────── */
// Chart-format <chordTemplates> frets: -1 = unused, 0 = open, n>0 = fret.
// Ghost digit for chord notes uses the template row when present so it
// matches the XML diagram, not a divergent chordNote.f if any.
function _templateFretForChordGhost(chordId, stringIdx, noteFret) {
if (chordId == null) return noteFret;
// Coerce: some upstream paths (e.g. hs.chord_id from sloppaks)
// hand us string ids like "12". Cf. `templates[cid] ?? templates[Number(cid)]`
// earlier in this file.
const cid = typeof chordId === 'number' ? chordId : Number(chordId);
if (!Number.isFinite(cid)) return noteFret;
const fr = _drawChordTemplates?.[cid]?.frets;
if (!Array.isArray(fr) || stringIdx < 0 || stringIdx >= fr.length) return noteFret;
const tf = fr[stringIdx];
if (typeof tf !== 'number' || tf < 0) return noteFret;
return tf;
}
// Chart-format-style ghost digit: finger position (1=index … 4=pinky) from
// the chord template. Returns null when no finger data is available
// (GP imports emit all -1; open strings have finger 0 which we skip too
// since "0" is already shown via the open-string note path).
function _templateFingerForChordGhost(chordId, stringIdx) {
if (chordId == null) return null;
const cid = typeof chordId === 'number' ? chordId : Number(chordId);
if (!Number.isFinite(cid)) return null;
const fi = _drawChordTemplates?.[cid]?.fingers;
if (!Array.isArray(fi) || stringIdx < 0 || stringIdx >= fi.length) return null;
const tf = fi[stringIdx];
// -1 = unused / no data (GP imports); 0 = open string (skip — gem path handles it)
if (typeof tf !== 'number' || tf <= 0) return null;
return tf; // 14
}
/**
* Renders one board-ghost fret digit onto a pooled label mesh, flat
* in the board XY plane at (x, y, 0) — a Mesh (not a Sprite
* billboard) so perspective + projRim match the 3D ghost frame.
* Shared by drawNote()'s Primary ghost slot (chord/arp-aware caller
* resolves fretDisplay/alpha/fretForScale itself) and its Upcoming
* slots (always plain lead notes, where those collapse to
* n.f/projFactor/n.f).
*/
function drawGhostFretLabel(x, y, projRim, fretDisplay, alpha, growScale, fretForScale) {
const lb = pGhostFretLbl.get();
const sprMat = txtMat(fretDisplay, '#ffffff', false, 'ghostFret');
const baseGhostMat = _meshMatForGhostFretDigit(sprMat);
let instMat = lb.userData.h3dGhostFretLblInstMat;
if (!instMat || instMat.map !== baseGhostMat.map) {
if (instMat) {
try { instMat.dispose(); } catch (_) { /* idempotent */ }
}
instMat = baseGhostMat.clone();
instMat.transparent = true;
instMat.needsUpdate = true;
lb.userData.h3dGhostFretLblInstMat = instMat;
}
instMat.opacity = alpha;
instMat.depthTest = false;
lb.material = instMat;
lb.renderOrder = 1000;
const ghostOuterL = Math.max(NW * 1.1, NH * 1.1);
const ghostLblS = 0.7 * ghostOuterL * _textSizeMul * fretLabelScaleForFret(fretForScale);
const ghostLblScaled = ghostLblS * growScale;
lb.scale.set(ghostLblScaled, ghostLblScaled, 1);
// Z=0 matches the projection frame plane exactly — avoids parallax
// horizontal drift that appears when the camera is offset from the
// fret centre (camera sits at curX+20*K and looks toward curX, so
// any positive-Z offset on this label projects leftward vs the frame).
lb.position.set(x, y, 0);
lb.rotation.set(0, 0, projRim);
}
// skipLabel: don't draw per-note connector label (repeated fret)
// skipBody: don't draw the 3D note mesh (repeat chord — still shows projection)
// showDropLine: draw a white vertical drop line from note to below board (arpeggio / synth chord notes)
function drawNote(n, now, openX, skipLabel, skipBody, linger = 0.10, openChordBoxWidth, fromChord = false, chordId, susTrailMatchArpFrame = false, arpBounds = null, prevOnsetT = -Infinity, showDropLine = false) {
const s = n.s;
// Belt + suspenders: callers already gate via validString(),
// but drawNote is also entered through { ...cn } chord-note
// spreads, so re-check here before indexing material arrays.
if (!validString(s)) return;
const nxFrame = _drawNextByString && _drawNextByString[s];
const dt = n.t - now;
const ghostHold = fromChord ? linger : GHOST_HOLD_AFTER_ONSET;
const nextTAligned = nxFrame != null && Math.abs(nxFrame.t - n.t) < NEXT_ON_STRING_T_EPS;
const ghostPastHold = dt <= 0 && dt > -ghostHold
&& (nxFrame == null || nxFrame.t > n.t - 1e-6);
const isNextOnString = nextTAligned || ghostPastHold;
const y = sY(s);
const susEnd = n.t + (n.sus || 0);
const hasSus = n.sus > 0;
// Nearest event time across ALL strings strictly after this note —
// sourced from the sorted union of next/recent event times built
// once per frame in update() (see _scrEventTimes). _drawRecentByString
// is folded in so that once an event passes `now` (and leaves
// _drawNextByString) the deadline still holds: without this the
// next future event would reset _nextAnyT and old gems would
// linger after the new chord/note is already playing.
const _nextAnyT = _firstEventTimeGreaterThan(n.t + 1e-6);
// Deadline: absolute time after which the gem is culled.
// Sustain long (sus >= linger): die immediately at susEnd — no tail.
// Sustain short (sus < linger): tail = linger - sus after susEnd,
// capped by gap to next note (any string) so the gem disappears
// when the next note arrives if it comes before the tail runs out.
// No sustain: linger from onset, same gap-cap rule.
let _lingerDeadline;
if (hasSus) {
const extraLinger = Math.max(0, linger - (n.sus || 0));
// _nextAnyT cap only applies to the post-sustain linger tail.
// For long sustains (extraLinger = 0) the deadline is exactly
// susEnd — notes on other strings must not cut the held sustain
// short, which would hide the gem and trail mid-play.
_lingerDeadline = extraLinger > 0
? Math.min(susEnd + extraLinger, _nextAnyT)
: susEnd;
} else {
const _gap = _nextAnyT - n.t;
_lingerDeadline = n.t + (_gap < linger ? _gap : linger);
}
const _overLinger = now > _lingerDeadline;
// For arp-persisted notes past their time: bypass the early exit so
// the board projection (fretboard ghost + fret labels) keeps rendering
// until arpBounds.end. The gem/sustain blocks are gated by arpGhostOnlyMode.
const arpGhostShouldRun = arpBounds != null
&& (arpBounds.start - now) < 0.6 // same as PROJ_WIN
&& now <= arpBounds.end + 0.05;
const arpGhostOnlyMode = arpGhostShouldRun
&& _overLinger && (!hasSus || now > susEnd);
// Smart cull: keep the gem alive only if a note-state verdict is
// available to display. Probe result cached in _ndProbed/_ndProbedState
// so the later getNoteState query reuses it (avoids two provider calls).
// arpGhostShouldRun takes precedence — arp ghosts stay alive
// regardless of verdict state so their board projections persist.
let _ndProbed = false;
let _ndProbedState = null;
if (_overLinger && (!hasSus || now > susEnd) && !arpGhostShouldRun) {
// Prune the sustain-verdict latch before any cull-path return.
// The matching delete inside the sustain-render block (line ~9262)
// only runs when the note isn't culled — without this prune, a
// sustained note that crosses susEnd on the same frame as
// _overLinger goes true would leak its latch entry until a
// teardown/seek clears the Map. !hasSus notes never wrote to
// the latch, so Map.delete is a harmless no-op there.
if (hasSus) _susVerdictLatch.delete(Math.round(n.t * 1e4) * 10 + n.s);
if (!_ndHasProvider || dt < -NOTEDETECT_GEM_VERDICT_WINDOW) return;
let _ndProbe = null;
try { _ndProbe = _ndGetNoteState(n, n.t); } catch (e) { _ndProbe = null; }
_ndProbed = true;
_ndProbedState = _ndProbe;
const _probeSt = (_ndProbe && typeof _ndProbe === 'object') ? _ndProbe.state : _ndProbe;
if (_probeSt !== 'hit' && _probeSt !== 'active' && _probeSt !== 'miss') return;
}
const sustained = dt < 0 && hasSus && now <= susEnd;
const hitDist = Math.abs(dt);
const hit = hitDist < 0.15 || sustained || (_ndHasProvider && dt < 0);
const hitFade = sustained ? 0.7 : (hitDist < 0.15 ? 1 - hitDist / 0.15 : 0);
// skipBody (slide-target gem suppression) only applies to this
// note's own pre-hit approach — it exists so the destination's
// approaching gem doesn't duplicate the source note's gem, which
// is already sliding toward this fret. Once this note is hit,
// its gem must render normally so a chained slide (this note
// sliding on to a further fret) keeps following.
const effSkipBody = skipBody && dt > 0;
const hasTechniqueVibrato = noteHasVibrato(n);
const techniqueYNow = sustained ? techniqueYOffsetWorld(n, now) : 0;
const noteZ = sustained ? 0 : Math.min(0, dZ(dt));
// Per-note Z-based renderOrder: far notes get a low value (render
// first, get overdrawn by close geometry), close notes get a high
// value (render last, appear on top). RENDER_ORDER_LAYER_STACK decides
// the local stack for outline, core, technique symbols, and fret labels.
const xBase = n.f === 0 ? (openX !== undefined ? openX : curX) : xFretMid(n.f);
// Slide-in-progress: glide the gem (and everything anchored to it —
// outline, core, halo, technique markers) from its starting fret
// toward the slide's end fret over the sustain, the same way
// techniqueYOffsetWorld already offsets the gem in Y for bends.
const slideSt = slideTrailEnd(n);
// Once hit, keep gliding (and then holding at the end fret) for
// as long as the gem stays on screen — not just while `sustained`
// (now <= susEnd). Otherwise, during the brief extra moment a
// short note lingers after its sustain ends (see _lingerDeadline/
// extraLinger above), `sustained` flips false and the gem would
// snap back to its starting fret. slideOffsetWorldX's `p` clamps
// to 1 once now > susEnd, so this naturally holds at the slide's
// end position during that tail.
const slideXNow = (dt < 0 && hasSus && slideSt)
? (_leftyCached ? -1 : 1) * slideOffsetWorldX(n, now, slideSt)
: 0;
const x = xBase + slideXNow;
const isHarm = n.hm || n.hp;
// Open chord notes: wide default mesh is capped to chord frame width.
const OPEN_NOTE_WORLD_W = 40 * K;
let openWScale = 1;
if (n.f === 0 && openChordBoxWidth != null && openChordBoxWidth > 1e-8) {
openWScale = Math.max(0.22, (openChordBoxWidth * 0.96) / OPEN_NOTE_WORLD_W);
}
// Hoisted so both !skipBody blocks (gem and technique labels) and
// the unconditional sustain trail all share one declaration.
// For skipBody=true (slide targets), defaults are safe no-ops.
const openSlabThickMul = n.f === 0 ? 1.5 : 1;
const approachRot = n.f > 0 ? Math.max(0, Math.min(1, dt / AHEAD)) * Math.PI / 2 : 0;
// Ghost preview window: capped to the gap from the previous note
// on this string so dense passages don't show the preview 0.6 s
// ahead with no visible gem. Minimum 0.05 s so the ghost isn't
// completely suppressed even in very tight passages.
const _rawGap = n.t - prevOnsetT;
const effectiveProjWin = _rawGap > 0 ? Math.min(0.6, Math.max(0.05, _rawGap)) : 0.6;
const projFactorG = Math.max(0, Math.min(1, 1 - Math.max(dt, 0) / effectiveProjWin));
const inGhostWin = n.f > 0 && isNextOnString && dt > -ghostHold && dt < effectiveProjWin && projFactorG > 0.001;
// slopsmith#254 — query the provider once per note, before both !skipBody
// blocks, so _showHit can be a const and _ndGood is available for the
// sustain trail (which renders even when skipBody=true for slide targets).
let _ndGood = false; // true when provider confirms hit/active
let _ndState = null; // 'hit'|'active'|'miss'|null; null → fall back to proximity heuristic
let _ndCs = null; // raw provider response — truthy when provider returned a verdict
let _ndCsIsObj = false; // typeof _ndCs === 'object'
let _ndFaceMat = null; // [mat×4, transparent×2] array for lateral face fill, or null
if (_ndGetNoteState) {
// Reuse the smart-cull probe result if we already called
// _ndGetNoteState for this gem above; otherwise probe now.
let _raw = null;
if (_ndProbed) {
_raw = _ndProbedState;
} else {
try { _raw = _ndGetNoteState(n, n.t); } catch (e) { _raw = null; }
}
if (_raw) {
_ndCsIsObj = typeof _raw === 'object';
const _st = _ndCsIsObj ? _raw.state : _raw;
if (_st === 'miss') {
_ndState = 'miss';
_ndCs = _raw;
} else if (_st === 'hit' || _st === 'active') {
_ndState = _st;
_ndGood = true;
_ndCs = _raw;
}
}
}
// [verdict glow] feed the provider alpha into the per-frame max so
// update()'s top scales the verdict-glow brightness by live level
// (note_detect returns alpha = live input level for held sustains,
// and a time-fade for fresh strikes). Only object responses carry an
// alpha; the latch/legacy string responses leave brightness at full.
if (_ndGood && _ndCsIsObj && _ndCs && typeof _ndCs.alpha === 'number') {
_ndVerdictSawAlpha = true;
if (_ndCs.alpha > _ndVerdictMaxAlpha) _ndVerdictMaxAlpha = _ndCs.alpha;
}
// ── Sustain verdict latch ────────────────────────────────────
// note_detect's hitGlowDuration (~0.5 s) and the legacy mark
// TTL (500 ms) both expire before a long chart sustain ends.
// For any sustained note, latch the verdict from EITHER source
// (provider _ndState OR legacy _ndHitMarks / _ndMissMarks) and
// re-inject it so hit/miss color persists for the full hold.
// Works with both the modern provider path and the legacy event
// path — vibrato and other long-sustain notes benefit equally.
if (hasSus) {
const _sk = Math.round(n.t * 1e4) * 10 + n.s;
// Resolve current verdict: provider takes priority, then
// fall back to scanning the legacy mark arrays so a hit or
// miss event that arrived this frame can seed the latch.
let _lv_cur = _ndState;
let _lv_good = _ndGood;
if (!_lv_cur) {
for (let _mi = 0; _mi < _ndHitMarks.length; _mi++) {
const _mm = _ndHitMarks[_mi];
if (_mm.s === n.s && _mm.f === n.f && Math.abs(_mm.noteTime - n.t) < _ND_TIME_EPS) {
_lv_cur = 'hit'; _lv_good = true; break;
}
}
if (!_lv_cur) {
for (let _mi = 0; _mi < _ndMissMarks.length; _mi++) {
const _mm = _ndMissMarks[_mi];
if (_mm.s === n.s && _mm.f === n.f && Math.abs(_mm.noteTime - n.t) < _ND_TIME_EPS) {
_lv_cur = 'miss'; _lv_good = false; break;
}
}
}
}
if (_lv_cur) {
// Fresh verdict (provider or legacy mark) — save to latch.
// Distinguish a *live* provider hit (note_detect tags its
// ring-tracking 'active' responses with live:true) from a
// legacy/brief one: a live hit must NOT be re-injected once
// the provider goes silent (that kept muted sustains lit),
// whereas the legacy event path still needs the latch to
// bridge its ~0.5 s mark TTL across a long hold.
const _live = _ndCsIsObj && _ndCs && _ndCs.live === true;
_susVerdictLatch.set(_sk, _lv_good ? (_live ? 'hit-live' : 'hit') : 'miss');
// If the verdict came from a legacy mark (provider was
// silent — _ndState was null before the scan), propagate
// it to _ndState/_ndGood/_ndCs now so the sustain trail
// picks up the same colour this frame instead of waiting
// until the mark expires and the latch re-injects it.
if (!_ndState) {
if (_lv_good) {
_ndState = 'active'; _ndGood = true;
_ndCs = 'active'; _ndCsIsObj = false;
} else {
_ndState = 'miss';
_ndCs = 'miss'; _ndCsIsObj = false;
}
}
} else if (dt < 0 && now < susEnd) {
// No current verdict — reuse latch if available.
// dt < 0 guards against coloring notes that haven't
// crossed the hit line yet (approaching notes must never
// inherit a latch from a previous play-through).
const _lv = _susVerdictLatch.get(_sk);
if (_lv === 'hit') {
// Legacy/brief provider or event-path hit: bridge the
// gap across the hold (the mark/glow TTL expires before a
// long chart sustain ends).
_ndState = 'active'; _ndGood = true;
_ndCs = 'active'; _ndCsIsObj = false;
} else if (_lv === 'hit-live') {
// Live provider is authoritative for the active glow: it
// returns 'active' only while the string is actually
// ringing, and null once muted / decayed. Don't re-inject
// a stale 'active' (that kept a muted sustain lit) — leave
// the gem un-lit when the provider is silent; a re-strike
// relights it via the provider on the next frame.
} else if (_lv === 'miss') {
_ndState = 'miss';
_ndCs = 'miss'; _ndCsIsObj = false;
}
}
if (now >= susEnd) _susVerdictLatch.delete(_sk);
}
const _showHit = (_ndState === 'miss') ? false
: (_ndState ? _ndGood
: (hit || (n.f > 0 && inGhostWin)));
if (!effSkipBody && !arpGhostOnlyMode && !_overLinger) {
// ── Outline (slightly larger, bright emissive) ────────────
// Notedetect feedback (#9): if a recent hit/miss event
// matches this note's (s, f, t), swap the outline tint.
// Linear scan over a small bounded array — typical
// queues are 0-5 entries, expired marks pruned by the
// listener. Hit takes precedence over miss so the user
// sees the more positive feedback if both happen
// (shouldn't, but cheap guard).
let _ndOutline = (n.f > 0 && mStrHitOutline[s]) ? mStrHitOutline[s] : mWhiteOutline;
// update() prunes expired marks once per frame and
// caches performance.now() in _ndFrameNowMs so the hot
// path here just does the bounded match — no extra
// now() / filter() per note. After update()'s prune,
// every entry in the arrays has expiresAt > _ndFrameNowMs,
// so we don't re-validate inside the loop.
let _ndMatchedMark = null;
let _ndHadHitMark = false;
if (_ndHitMarks.length) {
for (let i = 0; i < _ndHitMarks.length; i++) {
const m = _ndHitMarks[i];
if (m.s === n.s && m.f === n.f && Math.abs(m.noteTime - n.t) < _ND_TIME_EPS) {
_ndOutline = mHitBright[s] ?? mGlow[s]; _ndFaceMat = mHitBrightArrays[s] ?? null; _ndMatchedMark = m; _ndHadHitMark = true; break;
}
}
}
if (!_ndHadHitMark && _ndMissMarks.length) {
for (let i = 0; i < _ndMissMarks.length; i++) {
const m = _ndMissMarks[i];
if (m.s === n.s && m.f === n.f && Math.abs(m.noteTime - n.t) < _ND_TIME_EPS) {
_ndOutline = mMissOutline; _ndFaceMat = mMissEdgeArrays; _ndMatchedMark = m; break;
}
}
}
if (_ndMatchedMark && _ndMatchedMark.labels && _ndMatchedMark.labels.length) {
_ndLabels.push({
x,
y: y + NH * 1.7,
z: noteZ + 0.02,
labels: _ndMatchedMark.labels,
});
}
// (approachRot / PROJ_WIN_G / projFactorG / inGhostWin hoisted above)
const rimXY = n.ac ? ACCENT_RIM_XY_SCALE_MUL : 1;
const rimZ = n.ac ? ACCENT_RIM_Z_SCALE_MUL : 1;
// slopsmith#254 — apply outline + lateral face-fill overrides from provider verdict.
// hit/active → green outline (mHitBright[s]) + green lateral faces;
// miss → magenta-red outline (mMissOutline) + dark lateral faces; front/back stay transparent.
if (_ndCs) {
if (_ndState === 'miss') {
_ndOutline = mMissOutline;
_ndFaceMat = mMissEdgeArrays;
} else if (_ndGood) {
_ndOutline = mHitBright[s] ?? mGlow[s];
_ndFaceMat = mHitBrightArrays[s] ?? null;
}
}
// Score pop: the first frame a gem's verdict carries points
// (notedetect ≥1.13 object verdicts), float a "+N" above it.
// popKey dedupes — chord members all hand back the chord-
// level key, so a chord pops once, not once per gem; the
// _fxSeen TTL also stops a sustain's long-lived verdict from
// re-popping every frame.
if (_ndGood && _ndCsIsObj
&& _ndCs.points !== undefined && _ndCs.popKey != null) {
_fxSpawnPop(_ndCs.popKey, _ndCs.points, _ndCs.mult,
x, y + NH * 2.2, noteZ + 0.02);
}
// Accent: soft neon outer glow (reference: diffused halo fading out).
// Three additive shells drawn behind outline/core; colour = string hue.
// Suppressed on a provider miss verdict — a bright accent halo
// around a missed gem muddies the dark-core-plus-red-rim fail
// signal, matching the same miss-over-accent priority the
// gem core material applies below.
if (n.ac && _ndState !== 'miss' && mAccentHaloNear[s]) {
const rZ = approachRot;
const accentShells = _accentShellsByString[s];
for (let hi = 0; hi < accentShells.length; hi++) {
const sh = accentShells[hi];
const glow = pAccentHalo.get();
glow.material = sh.mat;
glow.rotation.z = rZ;
glow.position.set(x, y + techniqueYNow, noteZ - sh.zK * K);
if (n.f === 0) {
// Inside a chord/arpeggio frame: bloom only vertically so the
// halo doesn't burst past the chord box edges horizontally.
// Outside a frame: modest horizontal cap (1.4×) so it doesn't
// overflow into adjacent lane visuals.
const openIxy = fromChord ? 1.0 : Math.min(sh.ixy, 1.4);
const slabPuff = Math.max(1.4, sh.ixy);
glow.scale.set(
(40 * K / NW) * rimXY * openIxy * openWScale,
0.1 * openSlabThickMul * slabPuff,
0.6 * rimZ * sh.iz,
);
} else {
glow.scale.set(rimXY * sh.ixy, rimXY * sh.ixy, 2.5 * rimZ * sh.iz);
}
}
}
const outline = pNote.get();
// Verdict beats accent on the outline so hit/miss feedback isn't
// hidden by mAccentOutline. Mirrors the same miss-over-accent
// priority the accent halo guard above already applies.
const _ndVerdict = (_ndCs && (_ndState === 'miss' || _ndGood))
|| !!_ndMatchedMark;
outline.material = (n.ac && !_ndVerdict) ? mAccentOutline[s] : _ndOutline;
// outline + core share the pNote pool, so set geometry explicitly
// each frame (a recycled mesh may carry a gradient geometry from a
// prior core use). Outline always uses the plain box.
outline.geometry = gNote;
outline.renderOrder = renderOrderForLayerAtZ(noteZ, 'NOTE_OUTLINE');
outline.position.set(x, y + techniqueYNow, noteZ);
outline.rotation.z = approachRot;
const ndRim = 1.1;
if (n.f === 0) {
outline.scale.set(
(35 * K / NW) * ndRim * rimXY * openWScale,
0.1 * ndRim * openSlabThickMul,
0.6 * ndRim * rimZ,
);
} else {
outline.scale.set(ndRim * rimXY, ndRim * rimXY, 2.8 * rimZ);
}
// ── Lateral face fill (top / bottom / left / right only) ─────
// Material array: groups 0-3 (±X ±Y) get the verdict colour;
// groups 4-5 (+Z front / -Z back) are transparent so the large
// front face shows only the core body's string colour beneath.
if (_ndFaceMat) {
const edges = pNoteEdge.get();
edges.material = _ndFaceMat;
edges.renderOrder = renderOrderForLayerAtZ(noteZ, 'TECHNIQUE_MARKER');
edges.position.set(x, y + techniqueYNow, noteZ + 0.001);
edges.rotation.z = approachRot;
if (n.f === 0) {
edges.scale.set(
(40 * K / NW) * rimXY * openWScale * 1.02,
0.1 * openSlabThickMul * 1.02,
0.6 * rimZ * 1.02,
);
} else {
edges.scale.set(rimXY * 1.02, rimXY * 1.02, 2.5 * rimZ * 1.02);
}
}
// ── Core (filled note body) ───────────────────────────────
const core = pNote.get();
// Body always keeps the string colour. Verdict feedback is
// carried by the outline shell and lateral face fill.
core.material = n.ac ? mAccentCore[s] : mStr[s];
// Gradient gem body for strings 0..5; flat box otherwise.
core.geometry = (!n.ac && gNoteGrad[s]) ? gNoteGrad[s] : gNote;
core.renderOrder = renderOrderForLayerAtZ(noteZ, 'NOTE_CORE');
core.position.set(x, y + techniqueYNow, noteZ + 0.001);
core.rotation.z = approachRot;
if (n.f === 0) {
core.scale.set(
(40 * K / NW) * rimXY * openWScale,
0.1 * openSlabThickMul,
0.6 * rimZ,
);
} else {
core.scale.set(rimXY, rimXY, 2.5 * rimZ);
}
// Fret digits on fretted (n.f > 0) flying notes deliberately
// omitted: the showFretOnNote setting and its UI helper text
// promise digits on the fretboard ghost only, never on the
// gems coming down the highway. The ghost path is at
// pGhostFretLbl below.
} // end gem block — technique labels reopen !skipBody below
// ── Sustain trail ─────────────────────────────────────────────
// Rendered for ALL notes with sustain, including skipBody=true
// slide-target notes (e.g. linkNext hold→slide: gem suppressed,
// slide trail stays visible as the continuation of the sustain).
// _ndGetNoteState is queried for every note (skipBody slide
// targets included), so the trail picks bright mGlow[s] when the
// provider confirms hit/active and dim mSus[s] otherwise — a
// slide-target trail is not forced dim.
// Chord-member open strings (fromChord && f === 0) skip the
// sustain trail entirely — fretted constituents already carry
// the chord's sustains; an extra ribbon under the wide open
// body looked like clutter. The note BODY still draws above.
if (hasSus && !_overLinger && !(fromChord && n.f === 0)) {
const susStart = Math.max(n.t, now);
const remSus = susEnd - susStart;
if (remSus > 0.01) {
const sliceDur = Math.min(remSus, AHEAD);
let tw = NW * 0.85 * (n.f === 0 ? openWScale : 1);
let th = NH * 0.12 * (n.f === 0 ? openWScale : 1) * openSlabThickMul;
if (susTrailMatchArpFrame) {
const yA = sY(0), yB = sY(nStr - 1);
const yMinF = Math.min(yA, yB) - S_GAP * 0.8;
const yMaxF = Math.max(yA, yB) + S_GAP * 0.8;
const fullChordBoxH = yMaxF - yMinF;
const ft = Math.max(CHORD_FRAME_RIM_MIN * K, fullChordBoxH * CHORD_FRAME_RIM_FRAC_H);
const ftSide = ft * 1.55;
if (n.f > 0) {
th = ftSide;
} else {
th = Math.max(th, ftSide * openSlabThickMul);
}
tw = Math.max(tw, ftSide * 1.05);
}
// Standalone open strings get two parallel trails
// offset along X — visually echoes the wide flat
// open-note body. Fretted notes keep the
// single-trail path. Offsets are scaled by
// `openWScale` (the same body-width scale
// computed at line 7367) so the trails stay
// underneath the body's edges no matter how wide
// the anchor lane is. Chord-member open strings
// can't reach here (guarded at the `hasSus`
// check above).
//
// openTrailOff is always > 0 because openWScale
// is clamped >= 0.22 at line 7368 (or defaults
// to 1 when there's no openChordBoxWidth), so
// openTrailOff >= NW * 3 * 0.22 = 3.3 * K.
// No degenerate-small-offset fallback needed.
const offsets = (n.f === 0)
? [-(NW * 3 * openWScale), NW * 3 * openWScale]
: SINGLE_SUS_OFFSETS;
const ribbonSusTrail = !!(
(slideSt && n.f > 0 && (n.sus || 0) > 1e-4)
|| (Number(n.bn) > 0)
|| n.tr
|| hasTechniqueVibrato
);
// Outline material for the sustain trail border — uses the
// same materials as the gem border so hit/miss colours are
// perceptually identical across gem outline, lateral faces,
// and sustain trail rim.
const _susOlMat = _ndState === 'miss' ? mMissOutline
: _ndGood ? (mHitBright[s] ?? mHitSusOutline)
: mSusOutline;
const emitSusStrip = (xCenter, segLen, zCenter) => {
// Same depth-bucket scheme as chord frames, using the
// ordered sustain-trail layer so same-depth frames win
// while closer trail segments still beat farther frames.
const trailRenderOrder = renderOrderForLayerAtZ(Math.min(0, zCenter), 'SUSTAIN_TRAIL');
for (let i = 0; i < offsets.length; i++) {
const xOff = xCenter + offsets[i];
const trOut = pSusOutline.get();
trOut.material = _susOlMat;
trOut.renderOrder = trailRenderOrder;
trOut.position.set(xOff, y, zCenter);
trOut.scale.set(tw + 0.4 * K, th + 0.4 * K, segLen);
const tr = pSus.get();
tr.material = _ndState ? mGlow[s] : mSus[s];
tr.renderOrder = trailRenderOrder + 0.0005;
tr.position.set(xOff, y, zCenter);
tr.scale.set(tw, th, segLen);
}
};
if (!ribbonSusTrail) {
const len = sliceDur * TS;
const zPos = dZ(susStart - now) - len / 2;
emitSusStrip(x, len, zPos);
} else {
// Same depth-based renderOrder as box trail — ribbon center
// at susStart + sliceDur/2, using TS so it matches dZ().
const _ribDt = Math.max(0, susStart + sliceDur / 2 - now);
const ribbonRenderOrder = renderOrderForLayerAtZ(-_ribDt * TS, 'SUSTAIN_TRAIL');
for (let si = 0; si < offsets.length; si++) {
const strandX = xBase + offsets[si];
const olMesh = pSusRibbonOl.get();
olMesh.renderOrder = ribbonRenderOrder;
olMesh.scale.set(1, 1, 1);
olMesh.rotation.set(0, 0, 0);
olMesh.position.set(0, 0, 0);
olMesh.material = _susOlMat;
slideRibbonUpdatePositions(
olMesh.geometry, strandX,
tw + 0.4 * K, th + 0.4 * K,
y, sliceDur, susStart, now, n, slideSt,
);
const body = pSusRibbon.get();
body.renderOrder = ribbonRenderOrder + 0.0005;
body.scale.set(1, 1, 1);
body.rotation.set(0, 0, 0);
body.position.set(0, 0, 0);
body.material = _ndState ? mGlow[s] : mSus[s];
slideRibbonUpdatePositions(
body.geometry, strandX, tw, th, y,
sliceDur, susStart, now, n, slideSt,
);
}
}
}
}
// Shared by both slide-arrow blocks so the neck-preview arrow
// below can match the on-note arrow's size once the note
// arrives (the technique-labels block further down also reads
// this for its own label scaling).
const LBL_MULT = 1.6;
// Fade-in window for the neck-preview slide arrow below.
const GHOST_UPCOMING_WIN = 0.6;
// ── Slide direction arrow (neck preview) ─────────────────────
// A standalone preview of the same arrow, sitting flat on
// the neck (Z=0) at the note's resting fret. Full size from
// the moment it appears (matches the on-note arrow's size at
// dt=0) — just a linear fade-in over the same GHOST_UPCOMING_WIN
// window the board-projection ghost note uses, so the two
// previews feel like part of the same effect. No grow effect:
// tried and found too hard to see. Stops at dt <= 0, handing
// off to the on-note arrow.
//
// Placed outside the !effSkipBody block (unlike the on-note
// arrow above) so a note whose own gem is suppressed because
// it's the destination of a previous slide (skipBody) can
// still preview ITS OWN outgoing slide ahead of time — i.e.
// multi-leg/chained slides — when slideArrowChainPreviewVisible
// is on. For a normal note (skipBody === false) this behaves
// exactly as before.
if (slideArrowNeckVisible && dt > 0 && slideSt && validString(s) && !arpGhostOnlyMode && !_overLinger
&& (!skipBody || slideArrowChainPreviewVisible)) {
const slideDirN = Math.sign(fretMid(slideSt.endFret) - fretMid(n.f)) * (_leftyCached ? -1 : 1);
if (slideDirN !== 0) {
const neckAlpha = Math.max(0, Math.min(1, 1 - dt / GHOST_UPCOMING_WIN));
if (neckAlpha > 0.001) {
const arrowHexN = darkenHex(activePalette[s], 0.55);
const arrowSmN = slideArrowMat(slideDirN > 0, arrowHexN);
const arrowN = pTechPlane.get();
arrowN.material = _spriteMat2MeshMat(arrowN, arrowSmN);
const arrowScaleN = NH * 1.1 * LBL_MULT * _textSizeMul;
arrowN.scale.set(arrowScaleN, arrowScaleN, 1);
arrowN.position.set(x + slideDirN * NW * 1.15, y, 0);
arrowN.rotation.z = 0;
arrowN.renderOrder = renderOrderForLayerAtZ(0, 'TECHNIQUE_MARKER');
arrowN.material.opacity = neckAlpha;
}
}
}
if (!effSkipBody && !arpGhostOnlyMode && !_overLinger) {
// ── Technique labels ──────────────────────────────────────
// Label scale = base × LBL_MULT × distFactor (LBL_MULT
// declared above, shared with the neck-preview slide arrow).
// distFactor compensates for perspective shrink so a
// label far from the camera (note approaching at dt≈AHEAD)
// doesn't collapse to a single dim pixel. LBL_MULT bumps
// every base scale uniformly. Issues #21-25 track proper
// visual upgrades (3D arrows, ribbons, glows); this is
// the cheap legibility win in the meantime.
//
// Offsets scale with sLbl too. The labels grow in world
// units to compensate for perspective; if the offsets
// didn't grow, stacked labels would overlap each other
// and the first label would overlap the note at the
// AHEAD edge. In screen space the offset stays roughly
// constant — labels appear anchored to the note even
// though the world-space distance grows.
const distFactor = 1 + Math.max(0, Math.min(1, dt / AHEAD)) * 1.5;
// Fold the user's text-size multiplier into sLbl so technique
// labels (bend, H/P/T arrows, tremolo) plus on-body markers
// such as palm mute and the pinch-harmonic icon.
// all scale alongside the rest (`ac` accent → brighter body via mGlow).
const sLbl = LBL_MULT * distFactor * _textSizeMul;
// txtMat(..., 'technique') disables depthTest; without a high
// renderOrder the transparent note core (mStr) can still paint
// afterward and hide H/P/T, PM X, bends, etc. Same contract as
// fret-row labels (issue #35, CLAUDE pitfall #7 corollary).
const techniqueMarkerRenderOrder = renderOrderForLayerAtZ(noteZ, 'TECHNIQUE_MARKER');
let yo = y + techniqueYNow + NH * 0.8 * sLbl;
const specialMarkerScale = n.f === 0
? NH * 1.5 * sLbl * openWScale
: NH * 1.5 * sLbl;
// ── Slide direction arrow (on the note/gem) ─────────────────
// A small / chevron beside the note pointing toward the
// slide's destination fret. Visible for the note's whole
// time on screen — approaching (rides the incoming note at
// noteZ+K) and sustained (rides the gem as it glides via
// slideXNow, Edit A) — at a constant full opacity, no
// fade or grow.
if (slideArrowApproachVisible && slideSt && validString(s)) {
const slideDir = Math.sign(fretMid(slideSt.endFret) - fretMid(n.f)) * (_leftyCached ? -1 : 1);
if (slideDir !== 0) {
const arrowHex = darkenHex(activePalette[s], 0.55);
const arrowSm = slideArrowMat(slideDir > 0, arrowHex);
const arrow = pTechPlane.get();
arrow.material = _spriteMat2MeshMat(arrow, arrowSm);
const arrowScale = NH * 1.1 * sLbl;
arrow.scale.set(arrowScale, arrowScale, 1);
arrow.position.set(x + slideDir * NW * 1.15, y + techniqueYNow, noteZ + K);
// No rotation, ever — notes themselves rotate in
// (approachRot) as they approach, but that would
// tilt the / chevron and make its left/right
// direction ambiguous. Always flat.
arrow.rotation.z = 0;
arrow.renderOrder = techniqueMarkerRenderOrder;
arrow.material.opacity = 1;
}
}
if (n.bn > 0) {
// Bend chevron stack — PlaneGeometry mesh so it tilts with
// the gem (approachRot). Fixed world size so it perspective-
// shrinks naturally without distFactor compensation.
const steps = Math.max(1, Math.min(4, Math.round(n.bn)));
const bendSm = bendChevronMat(steps, activePalette[s] || 0xffffff);
const l = pTechPlane.get();
l.material = _spriteMat2MeshMat(l, bendSm);
const cs = NH * 2.4;
l.scale.set(cs, cs, 1);
l.position.set(x, y + techniqueYNow + NH * 1.1, noteZ + K);
l.rotation.z = approachRot;
l.renderOrder = techniqueMarkerRenderOrder;
// Reserve stack space above the chevron.
yo = Math.max(yo, y + techniqueYNow + NH * 2.5);
}
if (n.ho || n.po || n.tp) {
if (n.ho || n.po) {
// Hammer-on / pull-off: ▲/▼ triangle — PlaneGeometry mesh
// so it tilts with the gem instead of billboarding.
const triSm = triMat(!!n.po, activePalette[s] || 0xffffff);
const tri = pTechPlane.get();
tri.material = _spriteMat2MeshMat(tri, triSm);
tri.scale.set(NH * 1.8, NH * 1.60, 1);
tri.position.set(x, y + techniqueYNow, noteZ + K);
tri.rotation.z = approachRot;
tri.renderOrder = techniqueMarkerRenderOrder;
// Reserve stack space above the triangle for stacked labels.
yo = Math.max(yo, y + techniqueYNow + NH * 1.0);
} else {
const chevron = pTapChevron.get();
const chevronScale = NH * 0.8 * sLbl;
chevron.position.set(x, y + techniqueYNow, noteZ + 1.1 * K);
chevron.rotation.z = approachRot;
chevron.scale.set(chevronScale, chevronScale, 1);
chevron.renderOrder = techniqueMarkerRenderOrder;
}
}
// Tremolo label ('~~~') removed — trail shape already conveys it visually.
if (n.pm || n.mt || n.fhm) {
// Muted notes: pool-based plane with per-note Z-proportional
// renderOrder (techniqueMarkerRenderOrder). The previous InstancedMesh
// approach used a fixed renderOrder (702/700), which made PM/FH markers from
// far notes overdraw chord frames and gems of nearer chords.
// PM = black X / white border; FH/MT = inverse.
const _fhm = !!(n.mt || n.fhm);
const _pmSprite = _fhm ? fretHandMuteXSpriteMat() : palmMuteXSpriteMat();
const _pmMark = pTechPlane.get();
_pmMark.material = _spriteMat2MeshMat(_pmMark, _pmSprite);
_pmMark.material.opacity = _showHit ? 1.0 : 0.8;
// Arms cover 62.5% of canvas → scale 1/0.625 = 1.60.
const _sx = n.f === 0 ? NW * 1.60 * openWScale : NW * 1.60;
_pmMark.scale.set(_sx, NH * 1.60, 1);
_pmMark.position.set(x, y + techniqueYNow, noteZ + K);
_pmMark.rotation.z = approachRot;
_pmMark.renderOrder = techniqueMarkerRenderOrder;
}
// hm / hp — PlaneGeometry overlay sized like the palm-mute X,
// so the symbol only appears on the front face and matches
// the palm-mute marker proportions.
if (n.hm || n.hp) {
const harmSprite = n.hm ? naturalHarmonicMat() : pinchHarmonicMat(activePalette[s]);
const harmMark = pTechPlane.get();
harmMark.material = _spriteMat2MeshMat(harmMark, harmSprite);
harmMark.material.opacity = _showHit ? 1.0 : 0.85;
const harmScaleX = n.f === 0 ? NW * 1.90 * openWScale : NW * 1.90;
harmMark.scale.set(harmScaleX, NH * 2.0, 1);
harmMark.position.set(x, y + techniqueYNow, noteZ + K);
harmMark.rotation.z = approachRot;
harmMark.renderOrder = techniqueMarkerRenderOrder;
}
// ── Per-note fret connector label ─────────────────────────
if (n.f > 0 && !skipLabel) {
const minStringY = Math.min(sY(0), sY(nStr - 1));
const labelY = minStringY - S_GAP * 0.8;
// Fade in over 0.35s at the far end; stay at full opacity until
// the note hits (dt = 0). No pre-hit fade-out — removing it
// eliminates the "label disappears before the note arrives" artifact.
const alpha = dt >= 0 ? Math.min(1.0, (AHEAD - dt) / 0.35) : 0;
// Arpeggio notes use the arpeggio fret-label layer at the same
// depth so they stay above non-arp connector lines of the same chord.
const _isArpNote = arpBounds !== null;
// String-coloured connector line — standalone notes only.
// Arpeggio and chord notes already have a drop line to the board.
if (!fromChord) {
const line = pConnectorLine.get();
line.position.set(x, labelY, noteZ);
// Half-length (50% shorter), anchored at the fret-label end.
line.scale.set(1, (y - labelY) * 0.5, 1);
// Tint the line with the incoming note's string colour.
// mStr is white for gradient strings, so use the canonical
// flat palette colour instead of the material's .color.
if (activePalette[s] != null) line.material.color.setHex(activePalette[s]);
line.renderOrder = renderOrderForLayerAtZ(noteZ,
_isArpNote
? 'ARP_CONNECTOR_LINE'
: 'CONNECTOR_LINE'
);
line.material.opacity = alpha * 0.8;
}
// Regular chord notes (fromChord=true, arpBounds=null) never show
// fret labels — only standalone notes and arpeggio note-stream notes
// (fromChord=true with arpBounds!=null) show labels.
// Key uses 40 ms buckets (same formula as _buildFretLabelSet) so
// the lookup matches exactly, even if the note's time drifts ±20 ms.
// Frame-dedup prevents multiple strings at the same onset/fret from
// stacking duplicate labels.
const _flFrameKey = Math.round(n.t * 25) * 100 + n.f;
const _showNum = (!fromChord || arpBounds !== null)
&& _fretLabelAllowed.has(_flFrameKey)
&& !_frameLabeledKeys.has(_flFrameKey);
if (_showNum) {
_frameLabeledKeys.add(_flFrameKey);
const fretLabel = pNoteFretLabel.get();
const cachedMat = txtMat(n.f, FRET_LABEL_GOLD_HEX, false, 'noteFret');
if (fretLabel.material.map !== cachedMat.map) {
fretLabel.material.map = cachedMat.map;
fretLabel.material.needsUpdate = true;
}
fretLabel.position.set(x, labelY, noteZ);
fretLabel.renderOrder = renderOrderForLayerAtZ(noteZ,
_isArpNote
? 'ARP_NOTE_FRET_LABEL'
: 'NOTE_FRET_LABEL'
);
// Same scale ramp as fret column markers: 2× base at max lookahead,
// converging to 1× at hit line. Final size matches row labels.
const flS = 7.0 * K * (1 + 0.4 * Math.max(0, dt) / AHEAD) * _textSizeMul * fretLabelScaleForFret(n.f);
fretLabel.scale.set(flS, flS, 1);
fretLabel.material.opacity = alpha;
}
}
}
// ── Fret number for synthetic chord notes rendered with skipBody=true ──
// suppressSynthChord paths skip the !skipBody block entirely, so their
// fret label never fires from inside it. Handle them here, outside the
// gate. Applies the same measure-based + frame-dedup rules so that if
// the corresponding standalone arpeggio note already showed a label this
// frame, we don't duplicate it.
if (skipBody && fromChord && !skipLabel && n.f > 0 && dt >= 0) {
const _fl2FrameKey = Math.round(n.t * 25) * 100 + n.f;
if (_fretLabelAllowed.has(_fl2FrameKey)
&& !_frameLabeledKeys.has(_fl2FrameKey)) {
_frameLabeledKeys.add(_fl2FrameKey);
const _minStrY2 = Math.min(sY(0), sY(nStr - 1));
const _labelY2 = _minStrY2 - S_GAP * 0.8;
const _alpha2 = Math.min(1.0, (AHEAD - dt) / 0.35);
const _isArp2 = arpBounds !== null;
const fl2 = pNoteFretLabel.get();
const cm2 = txtMat(n.f, FRET_LABEL_GOLD_HEX, false, 'noteFret');
if (fl2.material.map !== cm2.map) {
fl2.material.map = cm2.map;
fl2.material.needsUpdate = true;
}
fl2.position.set(x, _labelY2, noteZ);
fl2.renderOrder = renderOrderForLayerAtZ(noteZ,
_isArp2
? 'ARP_NOTE_FRET_LABEL'
: 'NOTE_FRET_LABEL'
);
const _flS2 = 7.0 * K * (1 + 0.4 * dt / AHEAD) * _textSizeMul * fretLabelScaleForFret(n.f);
fl2.scale.set(_flS2, _flS2, 1);
fl2.material.opacity = _alpha2;
}
}
// ── Drop line for chord / arpeggio notes ──────────────────────────
// Styled to match the standalone single-note connector: tinted with
// the incoming note's string colour and 50% length (anchored at the
// fret-label end), instead of a full-height white line to the board.
const _wantDropLine = pDropLine && n.f > 0 && dt >= 0 && fromChord && showDropLine && !skipBody;
if (_wantDropLine) {
const _minStrY = Math.min(sY(0), sY(nStr - 1));
const _dropY = _minStrY - S_GAP * 0.8;
const _alpha = Math.min(1.0, (AHEAD - dt) / 0.35);
const dl = pDropLine.get();
dl.position.set(x, _dropY, noteZ);
// Half-length, anchored at the label end (matches single notes).
dl.scale.set(1, (y - _dropY) * 0.5, 1);
// String-colour tint (palette is canonical; mStr is white for
// gradient strings).
if (activePalette[s] != null) dl.material.color.setHex(activePalette[s]);
dl.renderOrder = renderOrderForLayerAtZ(noteZ, 'CONNECTOR_LINE');
dl.material.depthTest = false;
dl.material.opacity = _alpha * 0.8;
}
// ── Board ghost: filled rim at Z=0 (up to 3 slots/string) ────
// Lead notes (!fromChord, so !arpGhostActive too — see arpBounds
// below) use a fixed GHOST_UPCOMING_WIN pre-impact ramp instead
// of the gap-capped effectiveProjWin, so fast same-string runs
// don't pop in at full size right before impact. Chords/arps
// keep effectiveProjWin exactly as before — effectiveProjWin /
// projFactorG / inGhostWin (computed above, for the separate gem
// pre-glow) are untouched. Arp ghosts always use the full 0.6 s
// window — their timing is authored, not gap-driven.
const _PROJ_WIN_ARP = 0.6;
const ghostWin = fromChord ? effectiveProjWin : GHOST_UPCOMING_WIN;
const projFactor = Math.max(0, Math.min(1, 1 - Math.max(dt, 0) / ghostWin));
// isBlocked suppresses the pre-impact ghost in a note's last 150ms
// so it doesn't peek out from under the incoming note body. Scoped
// to chord notes only (#843): for plain lead notes this previously
// suppressed the board-ghost frame for every sustained note's last
// 150ms, making it vanish right before impact and reappear during
// the post-hit linger ("ghosts disappearing" in dense runs). Also
// excludes slide notes (`!slideSt`, #862/#257): a sliding gem
// glides off the start fret via slideXNow, so the body no longer
// covers the ghost and the slide's own ghost preview should show.
// Gate on dt > 0 so the post-hit linger (dt ≤ 0) keeps the ghost.
const isBlocked = fromChord && dt > 0 && dt < 0.15 && n.sus > 0 && !slideSt;
// For arpeggio notes: ALL notes show their ghost simultaneously
// the moment the FIRST note enters _PROJ_WIN_ARP — exactly when
// isNextOnString would reveal the first note's ghost on its own.
// Using arpBounds.start as the shared reference makes every note
// in the arpeggio fade in together, keyed off that single anchor.
const arpDtToStart = arpBounds != null ? arpBounds.start - now : Infinity;
const arpProjFactor = Math.max(0, Math.min(1, 1 - Math.max(arpDtToStart, 0) / _PROJ_WIN_ARP));
const arpGhostActive = arpBounds != null
&& arpDtToStart < _PROJ_WIN_ARP
&& now <= arpBounds.end + 0.05;
// Ghost stays at final "on the board" orientation — not the
// incoming approachRot sweep — so it nests with the note at
// impact. projRim/projScale/bodyDim are pure functions of
// effSkipBody/_vibrancyProjOp; hoisted here so both the Primary
// ghost (below) and the Upcoming-slot ghosts (sibling block
// further down) can use them.
const projRim = 0; // harmonic gems now land horizontal (no diamond offset)
// _vibrancyProjOp (0.15..0.5) is the vibrancy-scaled idle floor;
// scale the whole opacity by (_vibrancyProjOp / 0.15) so the slider
// affects the projection the same way it affects note bodies.
const projScale = _vibrancyProjOp / 0.15;
// effSkipBody (= skipBody && dt > 0, #862): a slide-destination note
// whose own gem is suppressed pre-hit isn't dimmed once it's hit.
const bodyDim = effSkipBody ? 0.38 : 1;
if (n.f > 0 && projectionVisible && (
(!_overLinger && isNextOnString && dt > -ghostHold && dt < ghostWin && projFactor > 0.001 && !isBlocked)
|| arpGhostActive
)) {
const proj = projMeshArr[s][0];
// Arp ghost: uniform opacity keyed off arpBounds.start so all
// notes fade in/out together regardless of individual dt values.
let arpGhostAlpha = 1;
if (arpGhostActive) {
const remainingP = arpBounds.end - now;
const fadeOutP = remainingP < 0.25 ? Math.max(0, remainingP / 0.25) : 1;
arpGhostAlpha = Math.min(arpProjFactor, fadeOutP);
}
// Chart-format-style ghost: a single 0→1 progress value drives both the
// fade-in (opacity/emissive, below) and the grow-from-small scale
// on the frame mesh and fret-digit label. Reuses the existing
// per-path progress signals — arp ghosts use arpGhostAlpha
// (already 0→1, incl. fade-out tail), non-arp ghosts use
// projFactor (0→1 across ghostWin).
const ghostProgress = arpGhostActive ? arpGhostAlpha : projFactor;
const projGrowScale = PROJ_GROW_MIN + (1 - PROJ_GROW_MIN) * ghostProgress;
const rimSolid = arpGhostActive
? 0.75 * arpGhostAlpha
: projFactor * 0.94;
proj.material.opacity = Math.min(0.96,
projScale * rimSolid * (0.5 + 0.5 * glowMul) * bodyDim);
proj.material.emissiveIntensity = arpGhostActive
? 0.35 * arpGhostAlpha * glowMul * bodyDim
: projFactor * 0.55 * glowMul * bodyDim;
proj.position.set(x, y, 0);
proj.scale.set(projGrowScale, projGrowScale, projGrowScale);
proj.rotation.z = projRim;
proj.visible = true;
const ghostFretOk = showFretOnNote && (
arpGhostActive ||
fretNumberGhostScope === 'all' ||
(fretNumberGhostScope === 'chords' && fromChord)
);
if (ghostFretOk && pGhostFretLbl) {
// chord-hand style → show finger number (14) from the chord
// template; fall back to fret number when no finger data exists
// (GP imports, open strings, non-chord notes).
const ghostFretDisplay = fromChord && fretNumberGhostScope === 'chords'
? (_templateFingerForChordGhost(chordId, n.s) ?? _templateFretForChordGhost(chordId, n.s, n.f))
: fromChord
? _templateFretForChordGhost(chordId, n.s, n.f)
: n.f;
let ghostFretLblAlpha = 1;
if (arpGhostActive) {
// Reuse arpGhostAlpha — already encodes fade-in (keyed
// off arpBounds.start via arpProjFactor) + fade-out.
ghostFretLblAlpha = arpGhostAlpha;
} else if (ghostPastHold) {
const nextSoon = nxFrame != null && nxFrame.t > n.t + 1e-6
&& (nxFrame.t - now) <= GHOST_FRET_LBL_FADE_S;
const ghostFadeS = Math.min(GHOST_FRET_LBL_FADE_S, ghostHold);
ghostFretLblAlpha = hwyPostHitTailFadeMul(dt, ghostHold, nextSoon, ghostFadeS);
} else {
// Pre-impact approach: fade the digit in alongside the
// frame (projFactor), instead of popping in at full alpha.
ghostFretLblAlpha = projFactor;
}
const _ghostFretForScale = fromChord && fretNumberGhostScope === 'chords'
? n.f
: ghostFretDisplay;
drawGhostFretLabel(x, y, projRim, ghostFretDisplay, ghostFretLblAlpha, projGrowScale, _ghostFretForScale);
}
}
// ── Upcoming board ghosts (slots 1/2): up to 2 additional,
// independent pre-impact previews per string for plain lead
// notes, chart-format-style. Primary (slot 0, above) already shows the
// very next note on this string; this sibling block shows the
// 1-2 notes after that, each on its own mesh with its own
// fixed-GHOST_UPCOMING_WIN ramp — fixes fast same-string runs
// where every note after the first had almost no ramp time
// (capped at the gap to the *previous* note on that string).
// !nextTAligned excludes exactly the note Primary is already
// showing, so a note is never drawn in two slots at once.
if (n.f > 0 && projectionVisible && !fromChord && !nextTAligned
&& dt > 0 && dt < GHOST_UPCOMING_WIN) {
const slotIdx = 1 + _scrGhostUpcomingCount[s];
if (slotIdx <= 2) {
_scrGhostUpcomingCount[s]++;
// Same formula as Primary's projFactor for !fromChord
// notes (ghostWin === GHOST_UPCOMING_WIN there too) —
// keeps the size/brightness curve continuous as a note's
// rank improves frame-to-frame (slot 2 → 1 → 0).
const upcomingProgress = projFactor;
const proj = projMeshArr[s][slotIdx];
const rimSolid = upcomingProgress * 0.94;
proj.material.opacity = Math.min(0.96,
projScale * rimSolid * (0.5 + 0.5 * glowMul) * bodyDim);
proj.material.emissiveIntensity = upcomingProgress * 0.55 * glowMul * bodyDim;
const growScale = PROJ_GROW_MIN + (1 - PROJ_GROW_MIN) * upcomingProgress;
proj.position.set(x, y, 0);
proj.scale.set(growScale, growScale, growScale);
proj.rotation.z = projRim;
proj.visible = true;
if (showFretOnNote && fretNumberGhostScope === 'all' && pGhostFretLbl) {
drawGhostFretLabel(x, y, projRim, n.f, upcomingProgress, growScale, n.f);
}
}
}
}
/**
* Draw [ GEM ] bracket pair for an arpeggio note.
*
* While the note is approaching (bracketDt > 0) the brackets travel at the
* same Z as the gem. Once the note hits the line (bracketDt <= 0) the
* brackets sit at Z = 0 (the fretboard plane) and persist until arpEnd.
* They fade in with approach and fade out in the last 0.25 s of the arpeggio.
*
* Fretted notes: `[ ]` — 3 BoxGeometry bars per side (vertical + 2 caps).
* Open strings: `< >` — 2 diagonal arms per side, tips at note edges.
*
* openHalfW (optional) — half-width of the open note body; when supplied,
* the < > tips are placed at the actual edges of the note rather than a
* fixed offset.
*/
function drawArpBrackets(x, y, bracketDt, arpEnd, now, s, isOpen = false, openHalfW = null) {
if (bracketDt >= AHEAD) return;
if (bracketDt < 0 && now > arpEnd + 0.05) return;
if (!pArpBracket) return;
let alpha;
if (bracketDt > 0) {
// Match the chord frame box visibility: full opacity throughout the
// entire AHEAD window so brackets appear the moment the frame enters
// view, not after a slow linear fade from alpha≈0 at 3 s out.
alpha = 1;
} else {
const remaining = arpEnd - now;
alpha = remaining > 0.25 ? 1 : Math.max(0, remaining / 0.25);
}
if (alpha < 0.01) return;
const bracketZ = bracketDt > 0 ? Math.min(0, dZ(bracketDt)) : 0;
const col = activePalette[s % activePalette.length];
const barThick = NW * 0.09;
const bracketH = NH * 1.05;
const capLen = NW * 0.42;
const xOff = (isOpen && openHalfW != null) ? openHalfW : NW * 0.95;
const zOff = 0.006 * K;
const ord = 18;
if (isOpen) {
// < > chevron — 2 diagonal arms per side.
// Arm goes from tip outward; angle from positive-X axis via atan2.
const armLen = Math.sqrt(capLen * capLen + (bracketH * 0.5) * (bracketH * 0.5));
const ang = Math.atan2(bracketH * 0.5, capLen); // upper-right arm angle
const diagBar = (px, py, rz) => {
const b = pArpBracket.get();
b.material.color.setHex(col);
b.material.opacity = alpha;
b.renderOrder = ord;
b.position.set(px, py, bracketZ + zOff);
b.rotation.set(0, 0, rz);
b.scale.set(armLen, barThick, barThick);
};
// < tip at (x - xOff), arms open to the right
diagBar(x - xOff + capLen * 0.5, y + bracketH * 0.25, ang);
diagBar(x - xOff + capLen * 0.5, y - bracketH * 0.25, -ang);
// > tip at (x + xOff), arms open to the left
diagBar(x + xOff - capLen * 0.5, y + bracketH * 0.25, Math.PI - ang);
diagBar(x + xOff - capLen * 0.5, y - bracketH * 0.25, -Math.PI + ang);
} else {
const bar = (px, py, sw, sh) => {
const b = pArpBracket.get();
b.material.color.setHex(col);
b.material.opacity = alpha;
b.renderOrder = ord;
b.position.set(px, py, bracketZ + zOff);
b.rotation.set(0, 0, 0);
b.scale.set(sw, sh, barThick);
};
// Left bracket [ vertical bar then caps opening to the right
bar(x - xOff, y, barThick, bracketH);
bar(x - xOff + capLen * 0.5, y + bracketH * 0.5, capLen, barThick);
bar(x - xOff + capLen * 0.5, y - bracketH * 0.5, capLen, barThick);
// Right bracket ] vertical bar then caps opening to the left
bar(x + xOff, y, barThick, bracketH);
bar(x + xOff - capLen * 0.5, y + bracketH * 0.5, capLen, barThick);
bar(x + xOff - capLen * 0.5, y - bracketH * 0.5, capLen, barThick);
}
}
function drawNotedetectLabels(ctx, W, H) {
if (!_ndLabels.length || !cam || !_probe) return;
ctx.save();
ctx.font = 'bold 12px sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
for (const item of _ndLabels) {
_probe.set(item.x, item.y, item.z);
_probe.project(cam);
if (_probe.z < -1 || _probe.z > 1) continue;
const sx = (_probe.x * 0.5 + 0.5) * W;
const sy = (-_probe.y * 0.5 + 0.5) * H;
for (let i = 0; i < item.labels.length; i++) {
const label = item.labels[i];
const y = sy + (i - (item.labels.length - 1) / 2) * 15;
ctx.lineWidth = 4;
ctx.strokeStyle = 'rgba(0,0,0,0.8)';
ctx.strokeText(label.text, sx, y);
ctx.fillStyle = label.color;
ctx.fillText(label.text, sx, y);
}
}
ctx.restore();
}
// Score FX overlay pass — "+N" pops rising off their gems, milestone
// particle bursts / multiplier ring-pulses / streak-break flickers
// anchored on the strike line. Same overlay layer + projection
// pattern as drawNotedetectLabels; costs one early-out when nothing
// is active.
function drawScoreFx(ctx, W, H) {
if (!cam || !_probe) return;
const nowMs = _ndFrameNowMs || performance.now();
// TTL-prune the pop dedup keys (bounded: only notes hit in the
// last few seconds).
if (_fxSeen.size) {
for (const [k, exp] of _fxSeen) {
if (exp <= nowMs) _fxSeen.delete(k);
}
}
let anyPop = false;
for (let i = 0; i < _fxPops.length; i++) {
if (_fxPops[i].active) { anyPop = true; break; }
}
let anyBurst = false;
for (let i = 0; i < _fxBursts.length; i++) {
if (_fxBursts[i].active) { anyBurst = true; break; }
}
const ringAge = nowMs - _fxRingMs;
const breakAge = nowMs - _fxBreakMs;
if (!anyPop && !anyBurst && ringAge >= 600 && breakAge >= 350) return;
const pal = _fxPalette;
ctx.save();
// Streak-break flicker: brief red wash over the whole panel.
if (breakAge < 350) {
const a = 0.10 * (1 - breakAge / 350);
ctx.fillStyle = pal.miss;
ctx.globalAlpha = a;
ctx.fillRect(0, 0, W, H);
ctx.globalAlpha = 1;
}
// Strike-line center in screen px — anchor for bursts + pulses.
let cx = W / 2, cy = H * 0.72, centerOk = false;
{
const fretMidY = (sY(0) + sY(nStr - 1)) / 2;
_probe.set(curX, fretMidY, 0);
_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;
}
}
// Multiplier ring-pulse: one expanding ring on tier-up; the ×4
// tier pulses in the secondary accent like the HUD badge.
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 = _fxRingMult >= 4 ? pal.accent2 : pal.accent;
ctx.globalAlpha = 0.6 * (1 - t);
ctx.lineWidth = 3;
ctx.stroke();
ctx.globalAlpha = 1;
}
// Milestone bursts.
if (anyBurst && centerOk) {
for (let i = 0; i < _fxBursts.length; i++) {
const b = _fxBursts[i];
if (!b.active) continue;
const age = nowMs - b.bornMs;
if (age >= _FX_BURST_LIFE_MS) { b.active = false; continue; }
const t = age / _FX_BURST_LIFE_MS;
ctx.globalAlpha = 1 - t;
for (let j = 0; j < _FX_BURST_N; j++) {
b.px[j] += b.vx[j];
b.py[j] += b.vy[j];
b.vy[j] += 0.08;
ctx.fillStyle = (j & 1) ? pal.accent : pal.accent2;
ctx.fillRect(cx + b.px[j] - 2, cy + b.py[j] - 2, 4, 4);
}
ctx.globalAlpha = 1;
}
}
// "+N" pops: rise off the gem and fade over the back half.
if (anyPop) {
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
for (let i = 0; i < _fxPops.length; i++) {
const p = _fxPops[i];
if (!p.active) continue;
const age = nowMs - p.bornMs;
if (age >= _FX_POP_LIFE_MS) { p.active = false; continue; }
_probe.set(p.x, p.y, p.z);
_probe.project(cam);
if (_probe.z < -1 || _probe.z > 1) continue;
const t = age / _FX_POP_LIFE_MS;
const sx = (_probe.x * 0.5 + 0.5) * W;
const sy2 = (-_probe.y * 0.5 + 0.5) * H - t * 30;
ctx.globalAlpha = t < 0.4 ? 1 : 1 - (t - 0.4) / 0.6;
ctx.font = `bold ${13 + (p.mult - 1) * 2}px '${pal.font}', sans-serif`;
ctx.lineWidth = 4;
ctx.strokeStyle = 'rgba(0,0,0,0.8)';
ctx.strokeText(p.text, sx, sy2);
ctx.fillStyle = pal.accent;
ctx.fillText(p.text, sx, sy2);
}
ctx.globalAlpha = 1;
}
ctx.restore();
}
/* ── Camera smooth lerp ──────────────────────────────────────────── */
function camUpdate(bundle) {
const bpm = computeBPM(bundle.beats, bundle.currentTime);
const lerp = CAM_LERP_BASE * Math.max(bpm, 60) / 120;
curX += (tgtX - curX) * lerp;
curDist += (tgtDist - curDist) * lerp;
const dist = curDist * aspectScale;
const h = CAM_H_BASE * (dist / CAM_DIST_BASE);
// Zoom-interpolated framing multipliers: tight (NEAR) -> lower/closer;
// wide (FAR, fret 1<->20) -> higher/pulled back.
const _zt = Math.max(0, Math.min(1,
(dist - CAM_FRAME_DIST_NEAR) / (CAM_FRAME_DIST_FAR - CAM_FRAME_DIST_NEAR)));
const _hMul = CAM_FRAME_H_NEAR + (CAM_FRAME_H_FAR - CAM_FRAME_H_NEAR) * _zt;
const _dMul = CAM_FRAME_D_NEAR + (CAM_FRAME_D_FAR - CAM_FRAME_D_NEAR) * _zt;
const shoulderOffset = (_leftyCached ? -1 : 1) * 10 * K;
let _camX = curX + shoulderOffset, _camY = h * _hMul, _camZ = dist * _dMul;
// ── Free-camera user tweaks (orbit / height / zoom / pan) ──
// Driven by the Camera Director plugin via window.__h3dCamCtl.
// Layered ON TOP of the auto-framing so note tracking still works.
// The bridge is read once into _freeCam and reused for both the
// position and the look-at transforms; every field is coerced to a
// finite number before use so a malformed object can never feed NaN
// into cam.position / cam.lookAt.
const _freeCam = window.__h3dCamCtl;
const _lookAtZ = -FOCUS_D * 0.35;
if (_freeCam && _freeCam.enabled) {
const _distMul = Number.isFinite(_freeCam.distMul) ? _freeCam.distMul : 1;
const _heightMul = Number.isFinite(_freeCam.heightMul) ? _freeCam.heightMul : 1;
const _yaw = Number.isFinite(_freeCam.yaw) ? _freeCam.yaw : 0;
const _tx = curX, _ty = curLookY, _tz = _lookAtZ; // look target
let _vx = _camX - _tx, _vy = _camY - _ty, _vz = _camZ - _tz;
_vx *= _distMul; _vy *= _distMul; _vz *= _distMul; // zoom (dolly)
_vy *= _heightMul; // height
const _cy = Math.cos(_yaw), _sy = Math.sin(_yaw); // orbit around Y
const _rx = _vx * _cy - _vz * _sy, _rz = _vx * _sy + _vz * _cy;
_camX = _tx + _rx; _camY = _ty + _vy; _camZ = _tz + _rz;
}
cam.position.set(_camX, _camY, _camZ);
// Self-correcting look-at Y: project the fretboard's near-edge centre
// to NDC space. If it drifts toward the frame edge, nudge tgtLookY
// toward the fretboard centre so the camera tilts to re-frame it.
// This lets the camera adapt to any panel aspect ratio automatically.
const fretMidY = (sY(0) + sY(nStr - 1)) / 2;
_probe.set(curX, fretMidY, 0); // play-line fretboard centre
cam.lookAt(curX, curLookY, -FOCUS_D * 0.35); // tentative look — needed for project()
cam.updateMatrixWorld();
_probe.project(cam); // _probe.y → NDC in [-1, 1]
// Keep fretboard centre in the lower third of the screen (NDC ≈ -0.35).
// The deadband width and correction strength are both blended
// between Twitchy and Calm bounds by the user's tiltSmoothing
// setting — twitchy = re-frame aggressively (narrow band, strong
// nudge); calm = let small drift ride (wide band, weak nudge).
const DESIRED_NDC_Y = -0.35;
const tiltBand = CAM_TILT_BAND_T + (CAM_TILT_BAND_C - CAM_TILT_BAND_T) * tiltSmoothing;
const tiltStr = CAM_TILT_STR_T + (CAM_TILT_STR_C - CAM_TILT_STR_T) * tiltSmoothing;
if (_probe.y < DESIRED_NDC_Y - tiltBand || _probe.y > DESIRED_NDC_Y + tiltBand) {
// _probe.y too low → fretboard near bottom → tgtLookY decreases → camera tilts down → fretboard rises
// _probe.y too high → fretboard near top → tgtLookY increases → camera tilts up → fretboard drops
const correction = (DESIRED_NDC_Y - _probe.y) * fretMidY * tiltStr;
tgtLookY = Math.max(-fretMidY, Math.min(fretMidY, tgtLookY - correction));
}
curLookY += (tgtLookY - curLookY) * lerp;
// Final look-at with the corrected Y (overrides the tentative one above).
// User tilt (pitch) + pan offsets layer on top when the free-cam is
// enabled; each is coerced to a finite number to avoid a NaN look-at.
if (_freeCam && _freeCam.enabled) {
const _panX = Number.isFinite(_freeCam.panX) ? _freeCam.panX : 0;
const _panY = Number.isFinite(_freeCam.panY) ? _freeCam.panY : 0;
const _pitch = Number.isFinite(_freeCam.pitch) ? _freeCam.pitch : 0;
cam.lookAt(curX + _panX * K, curLookY + (_pitch + _panY) * K, _lookAtZ);
} else {
cam.lookAt(curX, curLookY, _lookAtZ);
}
}
/* ── Resize helper ───────────────────────────────────────────────── */
function applySize(w, h) {
if (!ren || !cam || !wrap) return;
if (!Number.isFinite(w) || !Number.isFinite(h) || w <= 0 || h <= 0) return;
const baseDPR = _ssActive() ? Math.min(devicePixelRatio, 1.25) : Math.min(devicePixelRatio, 2);
ren.setPixelRatio(_renderScale * baseDPR);
ren.setSize(w, h);
wrap.style.height = h + 'px';
if (lyricsCanvas) { lyricsCanvas.width = w; lyricsCanvas.height = h; }
_diagRenderCache.clear();
cam.aspect = w / h;
cam.updateProjectionMatrix();
aspectScale = Math.max(1, REF_ASPECT / Math.max(cam.aspect, 0.5));
_appliedW = w; _appliedH = h;
}
/* ── Teardown ────────────────────────────────────────────────────── */
function teardown() {
// Background animations (#13). Drop the listener first so any
// mid-teardown settings change doesn't try to rebuild a torn-
// down scene; then dispose the active style's resources.
if (_bgListener) { _bgUnsubscribe(_bgListener); _bgListener = null; }
// Notedetect listeners (issue #9). Remove on destroy so a
// panel that stops doesn't keep accumulating marks. Marks
// arrays are cleared too — they hold stale chart positions
// that next init() may reuse (drawNote keys on (s, f, t)).
if (_ndOnHit) { window.removeEventListener('notedetect:hit', _ndOnHit); _ndOnHit = null; }
if (_ndOnMiss) { window.removeEventListener('notedetect:miss', _ndOnMiss); _ndOnMiss = null; }
if (_fxOnFx) { window.removeEventListener('notedetect:fx', _fxOnFx); _fxOnFx = null; }
if (window.slopsmith && typeof window.slopsmith.off === 'function') {
if (_fxOnSkin) { try { window.slopsmith.off('notedetect:skin', _fxOnSkin); } catch (e) {} _fxOnSkin = null; }
if (_ndOnBusHit) window.slopsmith.off('note:hit', _ndOnBusHit);
if (_ndOnBusMiss) window.slopsmith.off('note:miss', _ndOnBusMiss);
if (_visibilityHandler) {
try { window.slopsmith.off('highway:visibility', _visibilityHandler); } catch (e) {}
}
if (_canvasReplacedHandler) {
try { window.slopsmith.off('highway:canvas-replaced', _canvasReplacedHandler); } catch (e) {}
}
}
_ndOnBusHit = _ndOnBusMiss = null;
_visibilityHandler = null;
_canvasReplacedHandler = null;
_ndHitMarks = [];
_ndMissMarks = [];
_ndLabels = [];
for (const p of _fxPops) p.active = false;
for (const b of _fxBursts) b.active = false;
_fxSeen.clear();
_fxGen++; // invalidate any pending deferred window-copy fallbacks
_fxLastFxDetail = null;
_fxElemSeen = new WeakSet();
_fxRingMs = _fxBreakMs = -1e9;
_chordVerdicts = new Map();
_bgUnmountStyle();
bgGroup = null; _bgLastT = 0;
_diagChord = null; _diagPrev = null; _diagPrevOpacity = 0; _diagPrevStartOpacity = 0; _diagPrevStartT = null;
_diagEntranceT = 1.0; _diagLastKey = null; _diagRenderCache.clear();
if (wrap) { wrap.remove(); wrap = null; }
_disposeOpenStringPitchSprites();
if (scene) {
// Don't dispose material.map textures here. Texture
// lifetime belongs to whoever allocated it; the bg
// styles' per-layer CanvasTextures (e.g. silhouettes'
// wrappers around the shared _silCanvas) are released
// in their own teardowns. txtCache textures are
// explicitly disposed below; mStr/mGlow/etc. don't have
// a .map. Disposing here would either double-free or
// yank a still-in-use texture out from under another
// mount.
scene.traverse((obj) => {
// fretTubeGeo is shared across all fret meshes — dispose it
// exactly once below, not once per mesh here.
if (obj.geometry !== fretTubeGeo) obj.geometry?.dispose?.();
if (obj.material) {
const mats = Array.isArray(obj.material) ? obj.material : [obj.material];
for (const m of mats) m?.dispose?.();
}
});
// Shared chord-frame fill gradient — not owned by txtCache;
// MeshBasicMaterial.dispose() does not release maps.
chordFrameGradTex?.dispose?.();
chordFrameGradTexArp?.dispose?.();
}
gNote?.dispose?.(); gSus?.dispose?.(); gBeat?.dispose?.(); gSusRail?.dispose?.(); gTapChevron?.dispose?.();
mSusRailBase?.dispose?.(); mSusRailBase = null; gSusRail = null; pSusRail = null;
gSusRailBloom?.dispose?.(); mSusRailBloomBase?.dispose?.(); _bloomGaussTex?.dispose?.();
gSusRailBloom = null; mSusRailBloomBase = null; _bloomGaussTex = null; pSusRailBloom = null;
gTechPlane?.dispose?.(); gTechPlane = null; pTechPlane = null;
// InstancedMesh disposal — .dispose() releases instanceMatrix / instanceColor
// GPU buffers. Geometry and material are disposed separately below.
imPMTech?.dispose?.(); imPMTech = null;
imFHTech?.dispose?.(); imFHTech = null;
imPMXFill?.dispose?.(); imPMXFill = null;
imPMXLines?.dispose?.(); imPMXLines = null;
imFHXFill?.dispose?.(); imFHXFill = null;
imFHXLines?.dispose?.(); imFHXLines = null;
// Geometry clones for PM/FH tech IMs (own instanceAlpha attribute).
_imGPMTech?.dispose?.(); _imGPMTech = null;
_imGFHTech?.dispose?.(); _imGFHTech = null;
// ShaderMaterials for all 6 IMs.
_imPMTechMat?.dispose?.(); _imPMTechMat = null;
_imFHTechMat?.dispose?.(); _imFHTechMat = null;
_imPMXFillMat?.dispose?.(); _imPMXFillMat = null;
_imPMXLinesMat?.dispose?.(); _imPMXLinesMat = null;
_imFHXFillMat?.dispose?.(); _imFHXFillMat = null;
_imFHXLinesMat?.dispose?.(); _imFHXLinesMat = null;
_imM4 = _imPos = _imSca = _imQ = _imAZ = _imColor = null;
gHaloBar?.dispose?.(); gHaloBar = null;
gArpBracket?.dispose?.(); gArpBracket = null;
for (const m of mStr) m?.dispose?.();
for (const m of mGlow) m?.dispose?.();
for (const m of mSus) m?.dispose?.();
for (const m of mStrHitOutline) m?.dispose?.();
for (const m of mAccentOutline) m?.dispose?.();
for (const m of mAccentCore) m?.dispose?.();
for (const m of mAccentHaloNear) m?.dispose?.();
for (const m of mAccentHaloMid) m?.dispose?.();
for (const m of mAccentHaloFar) m?.dispose?.();
mBeatM?.dispose?.(); mBeatQ?.dispose?.();
// Notedetect outline materials (#9). May not be reachable
// via scene.traverse if no event ever fired (never attached
// to a mesh), so dispose explicitly.
mMissOutline?.dispose?.();
mHitSusOutline?.dispose?.();
mEdgeTransparent?.dispose?.(); mEdgeTransparent = null;
for (const m of mHitBright) m?.dispose?.(); mHitBright = []; mHitBrightArrays = [];
for (const k in txtCache) {
const tm = txtCache[k];
tm.userData.h3dGhostFretMeshMat?.dispose?.();
tm.userData.h3dGhostFretMeshMat = null;
tm.userData.h3dTechMeshMat?.dispose?.();
tm.userData.h3dTechMeshMat = null;
tm.map?.dispose();
tm.dispose();
}
// Technique-marker sprite materials (triMat / bendChevronMat) —
// own numeric-keyed cache, not reachable via txtCache.
for (const tm of _techMatCache.values()) {
tm.map?.dispose();
tm.dispose();
}
_techMatCache.clear();
// Dispose per-sprite cloned materials (e.g. pmMark._pmMat).
// These aren't reachable via scene.traverse once the sprite
// gets reassigned a different material, so the array tracks
// them at allocation time.
for (const m of _ownedClonedMats) m?.dispose?.();
_ownedClonedMats.length = 0;
// Per-mesh technique-marker clones (from _spriteMat2MeshMat).
// The Set tracks the live clone for each pool mesh; dispose all
// on teardown so no GPU material leaks between init() cycles.
for (const m of _techMeshMatClones) m?.dispose?.();
_techMeshMatClones.clear();
// Shared pool-factory materials/geometries (mLaneOdd/Even, etc.) —
// see _ownedSharedMats comment near the declaration. Dispose is
// idempotent so the scene.traverse() pass above won't double-free.
for (const m of _ownedSharedMats) m?.dispose?.();
_ownedSharedMats.length = 0;
for (const g of _ownedSharedGeos) g?.dispose?.();
_ownedSharedGeos.length = 0;
txtCache = {};
if (ren) { ren.dispose(); ren = null; }
scene = cam = noteG = beatG = lblG = fretG = tuningLblG = null;
ambLight = dirLight = null;
mStr = []; mGlow = []; mSus = []; mStrHitOutline = []; mAccentOutline = []; mAccentCore = []; mAccentHaloNear = []; mAccentHaloMid = []; mAccentHaloFar = []; _accentShellsByString = []; mWhiteOutline = mSusOutline = null; mMissOutline = null; mHitSusOutline = null; stringLines = []; stringLineGlows = []; fretWireMats = []; fretTubeGeo?.dispose?.(); fretTubeGeo = null;
for (const m of _inlayMats) m?.dispose?.(); _inlayMats = []; _inlayLabels = [];
// mTapChevron: dispose explicitly — if no tap marker ever
// spawned a pooled mesh, the scene.traverse() pass above never
// reaches this material.
mTapChevron?.dispose?.();
mTapChevron = null;
// mBarre is a shared material that all pBarreLine pool meshes
// reference. If no barre chord ever appears, the pool factory
// is never called, so no mesh carries mBarre into the scene
// and scene.traverse() will miss it. Dispose explicitly here
// to avoid leaking the GPU resource across panel lifecycles.
// Three.js dispose() is idempotent, so calling it before or
// after scene.traverse() is safe in both the instantiated and
// uninstantiated cases.
mBarre?.dispose?.(); mBarre = null;
_paletteColorTmp = null;
lyricsCanvas = lyricsCtx = null;
projMeshArr = null;
_probe = null;
_drawNextByString = null; _drawRecentByString = null;
_susVerdictLatch.clear();
_drawChordTemplates = null;
_laneTargetColor = null;
_renderScale = 1;
mBeatM = mBeatQ = null;
pNote = pNoteEdge = pSus = pSusOutline = pSusRibbon = pSusRibbonOl = pLbl = pBeat = pSec = null;
pFretLbl = pLane = pLaneDivider = pGhostFretLbl = pChordBox = pChordFrameFill = pChordLbl = pBarreLine = pArpBracket = pNoteFretLabel = pConnectorLine = pDropLine = pTapChevron = pAccentHalo = pHaloBar = pPMXFill = pFHXFill = pMuteXLines = pFHXLines = null;
if (gPMXFill) { gPMXFill.dispose(); gPMXFill = null; }
if (gFHXFill) { gFHXFill.dispose(); gFHXFill = null; }
if (gPMXLines) { gPMXLines.dispose(); gPMXLines = null; }
if (gFHXLines) { gFHXLines.dispose(); gFHXLines = null; }
mLaneOdd = mLaneEven = mLaneDivider = mLaneDividerArp = gLanePlane = gGhostFretPlane = null;
chordFrameGradTex = chordFrameGradTexArp = null;
pFretColMarker = null;
_fretMarkerWaveCache.clear();
gNote = gSus = gBeat = gTapChevron = null;
tgtX = curX = xFretMid(CAM_LOCK_CENTER_FRET); tgtDist = curDist = CAM_DIST_BASE; tgtLookY = curLookY = 0; nStr = NSTR; _oobStringWarned = false;
_lookaheadCamX = xFretMid(CAM_LOCK_CENTER_FRET);
_lookaheadFretSpan = DEFAULT_LOOKAHEAD_FRET_SPAN;
_lookaheadCamPrevNow = null;
_lookaheadLowBonusU = 0;
_lookaheadHiNeckLatch = false;
_measureStarts = []; _measureStartsRef = null;
_clkAudioT = NaN; _clkPerf = NaN; _clkRate = 1; _frameNow = 0;
prevLowFretBonus = 0;
prevLockActive = false;
_camSnapped = false;
_camPreScanned = false;
_songKey = null;
_slideTargetSet = null;
_slideTargetNotesRef = null;
_slideTargetChordsRef = null;
}
function canvasSize(canvas) {
if (canvas) {
// If the canvas has zero bounds (hidden via any mechanism — inline style,
// CSS class, or hidden ancestor) fall back to the parent container
// (the splitscreen panelDiv) which is always visible and correctly sized.
const rect = canvas.getBoundingClientRect();
const target = (rect.width === 0 || rect.height === 0) && canvas.parentNode ? canvas.parentNode : canvas;
const sz = target === canvas ? rect : target.getBoundingClientRect();
if (sz.width > 0 && sz.height > 0) return { w: sz.width, h: sz.height };
}
// Reserve the full bottom area: #player-footer wraps the Section
// Practice bar + #player-controls. Fall back to #player-controls.
const ch = (document.getElementById('player-footer')
|| document.getElementById('player-controls'))?.offsetHeight || 50;
return { w: innerWidth, h: innerHeight - ch };
}
/* ── setRenderer contract ────────────────────────────────────────── */
return {
// Tells highway.js this renderer needs a webgl2-capable canvas.
// Browsers lock a <canvas> to the first context type acquired,
// so when this renderer is installed mid-session highway.js
// replaces the underlying <canvas> element so getContext('webgl2')
// can succeed (see static/highway.js _replaceCanvas).
contextType: 'webgl2',
init(canvas, bundle) {
_unsubscribeFocus();
if (wrap || ren) {
teardown();
}
_destroyed = _isReady = false;
_isFocused = true;
const myToken = ++_initToken;
highwayCanvas = canvas;
_invertedCached = !!(bundle && bundle.inverted);
_leftyCached = !!(bundle && bundle.lefty);
_renderScale = (bundle && bundle.renderScale) || 1;
// Per-render background opt-out. A plugin borrowing the highway as
// a visualization can set bundle.bgReactive === false to suppress
// the audio-reactive background for THIS instance only — without
// writing the shared h3d_bg_* settings (which would also change the
// host's own highway). Motivation: the reactive bg taps the core
// <audio> element, and when another consumer already holds it the
// setup throws + the cleanup AudioContext.close() is an audible
// click — which a borrower that never taps <audio> (e.g. a
// contained-playback practice plugin) inherits for no benefit.
// Default behavior is unchanged when the field is absent.
_bgReactiveOptOut = !!(bundle && bundle.bgReactive === false);
if (_ssActive()) {
window.slopsmithSplitscreen.onFocusChange(_onFocusChange);
_focusSubscribed = true;
}
// Async-ready contract (slopsmith#36 readyPromise). Resolves
// when Three.js loaded + scene initialised (_isReady = true).
// Rejects on any async failure so highway.js can revert.
let _resolveReady, _rejectReady;
this.readyPromise = new Promise((res, rej) => {
_resolveReady = res;
_rejectReady = rej;
});
// Shared rejection for superseded init cycles (destroy() or a
// newer init() started before this one completed). highway.js
// ignores the rejection when the renderer is no longer active.
const _rejectSuperseded = () => _rejectReady(new Error('superseded'));
loadThree().then(() => {
if (_destroyed || _initToken !== myToken) {
_rejectSuperseded();
return;
}
try {
nStr = resolveStringCount(bundle);
_invertedForBoard = _invertedCached;
_leftyForBoard = _leftyCached;
if (!initScene()) { _unsubscribeFocus(); _rejectReady(new Error('initScene failed')); return; }
const sz = canvasSize(highwayCanvas);
// Mark ready before RAF so any resize(w,h) calls that arrive
// in the meantime (e.g. from sizeCanvases()) are applied directly.
_isReady = true;
_resolveReady();
_updateFocusState();
if (sz.w > 0 && sz.h > 0) {
applySize(sz.w, sz.h);
} else {
// Panel container not yet laid out (sizeCanvases() runs after
// initPanel() in the setup sequence). Retry each frame until
// the panelDiv has real dimensions.
(function retrySize() {
if (_destroyed || !_isReady) return;
const s = canvasSize(highwayCanvas);
if (s.w > 0 && s.h > 0) applySize(s.w, s.h);
else requestAnimationFrame(retrySize);
})();
}
} catch (e) {
console.error('[3D-Hwy] init .then() threw:', e);
_isReady = false;
_unsubscribeFocus(); teardown();
_rejectReady(e);
}
}).catch(e => {
if (_initToken !== myToken || _destroyed) {
_rejectSuperseded();
return;
}
console.error('[3D-Hwy] Three.js unavailable:', e);
_unsubscribeFocus();
_rejectReady(e);
});
},
draw(bundle) {
if (!_isReady) return;
_invertedCached = !!bundle.inverted;
_leftyCached = !!bundle.lefty;
const newNStr = resolveStringCount(bundle);
const newScale = bundle.renderScale || 1;
const leftyChanged = _leftyCached !== _leftyForBoard;
if (_invertedCached !== _invertedForBoard || leftyChanged || newNStr !== nStr) {
if (newNStr !== nStr) {
_oobStringWarned = false;
// Drop chord caches computed under the old string count
// so extended-range notes (string 6+) aren't left
// filtered out of cached shapes.
_resetStringDependentCaches();
}
if (leftyChanged) {
curX = -curX;
tgtX = -tgtX;
_lookaheadCamX = -_lookaheadCamX;
}
nStr = newNStr;
buildBoard();
_invertedForBoard = _invertedCached;
_leftyForBoard = _leftyCached;
}
if (newScale !== _renderScale) {
_renderScale = newScale;
const s = canvasSize(highwayCanvas);
if (s.w > 0 && s.h > 0) applySize(s.w, s.h);
}
// Keep the render matched to the highway canvas's real box.
// Two independent drifts to catch each frame:
// 1. Backing store (canvas.width/height) changed out from under
// us — e.g. the splitscreen hw.resize override resizes the
// element but never calls renderer.resize(). Also re-sizes
// the lyrics overlay canvas via applySize().
// 2. The CSS box (canvasSize()) drifted while the backing store
// held. #highway is flex:1, so its rendered height changes as
// the player layout settles right after a song opens — with
// no backing-store change and no window 'resize' event, so the
// check above never fires. Without this the camera stays framed
// for the pre-settle (too-tall) size and crops the near strings
// / fret numbers until the user un/re-maximizes the window.
if (highwayCanvas) {
const box = canvasSize(highwayCanvas);
if (highwayCanvas.width !== _lastHwW || highwayCanvas.height !== _lastHwH) {
_lastHwW = highwayCanvas.width;
_lastHwH = highwayCanvas.height;
if (box.w > 0 && box.h > 0) applySize(box.w, box.h);
} else if (box.w > 0 && box.h > 0 &&
(Math.abs(box.w - _appliedW) > 1 || Math.abs(box.h - _appliedH) > 1)) {
applySize(box.w, box.h);
}
}
update(bundle);
camUpdate(bundle);
// Background animations (#13). Compute frame dt once,
// read audio bands when reactivity is on, delegate to
// the active style's update().
if (bgGroup && _bgEffectiveStyleId() !== 'off') {
const nowMs = performance.now();
const dt = _bgLastT === 0 ? 1 / 60 : Math.min(0.1, (nowMs - _bgLastT) / 1000);
_bgLastT = nowMs;
const bands = bgReactive ? _bgReadBands() : BG_ZERO_BANDS;
const style = BG_STYLES[_bgEffectiveStyleId()];
if (style && bgState) {
try { style.update(bgState, bands, dt, nowMs / 1000); }
catch (e) { console.error('[3D-Hwy] bg update threw', _bgEffectiveStyleId(), e); }
}
}
pbBeg(6); ren.render(scene, cam); pbEnd(6);
if (lyricsCtx && lyricsCanvas) {
lyricsCtx.clearRect(0, 0, lyricsCanvas.width, lyricsCanvas.height);
// Capture the actual lyrics-banner bottom so overlay cards
// step down past every wrapped row, not just a 2-row estimate.
let lyricsBottom = 0;
if (bundle.lyricsVisible && bundle.lyrics?.length) {
lyricsBottom = drawLyrics(bundle.lyrics, bundle.currentTime, lyricsCtx, lyricsCanvas.width, lyricsCanvas.height) || 0;
}
drawNotedetectLabels(lyricsCtx, lyricsCanvas.width, lyricsCanvas.height);
drawScoreFx(lyricsCtx, lyricsCanvas.width, lyricsCanvas.height);
// Corner-stacking: overlays drawn first claim the topmost slot;
// later overlays are pushed down by the accumulated height + gap.
// Draw order (top → bottom per corner):
// 1. FPS counter — always first
// 2. Section HUD
// 3. Tone HUD
// 4. Chord diagram — always last
const STACK_GAP = 8;
const cornerStack = { tl: 0, tr: 0, bl: 0, br: 0 };
const stackPush = (pos, h) => {
if (pos in cornerStack && h > 0) cornerStack[pos] += h + STACK_GAP;
};
// 1. FPS counter (always top-right, always topmost).
// EMA update runs unconditionally so the smoothed value is accurate
// even when fpsVisible is off.
const _fpsNowMs = performance.now();
if (_fpsLastT > 0) {
const dt = _fpsNowMs - _fpsLastT;
if (dt > 0) {
const inst = 1000 / dt;
_fpsEma = _fpsEma === 0 ? inst : _fpsEma + (inst - _fpsEma) * (1 / 30);
}
}
_fpsLastT = _fpsNowMs;
if (fpsVisible) {
if (_fpsNowMs - _fpsLastSampleT > 250) {
_fpsDisplay = _fpsEma;
_fpsLastSampleT = _fpsNowMs;
}
const W = lyricsCanvas.width;
const H = lyricsCanvas.height;
const txt = _fpsDisplay.toFixed(1) + ' fps';
lyricsCtx.save();
lyricsCtx.font = 'bold 14px ui-monospace, Menlo, Consolas, monospace';
lyricsCtx.textAlign = 'right';
lyricsCtx.textBaseline = 'top';
const _fpsPadX = 8, _fpsPadY = 4;
const _fpsMetrics = lyricsCtx.measureText(txt);
const _fpsBoxW = Math.ceil(_fpsMetrics.width) + _fpsPadX * 2;
const _fpsBoxH = 14 + _fpsPadY * 2;
const _fpsE = 8;
const _fpsBaseY = Math.round(Math.max(_fpsE + H * 0.06, lyricsBottom + _fpsE));
const _fpsX = W - 8 - _fpsBoxW;
const _fpsY = _fpsBaseY + cornerStack['tr'];
lyricsCtx.fillStyle = 'rgba(0,0,0,0.55)';
lyricsCtx.fillRect(_fpsX, _fpsY, _fpsBoxW, _fpsBoxH);
lyricsCtx.fillStyle = _fpsDisplay >= 55 ? '#7fff9a'
: _fpsDisplay >= 30 ? '#ffe84d' : '#ff6b6b';
lyricsCtx.fillText(txt, _fpsX + _fpsBoxW - _fpsPadX, _fpsY + _fpsPadY);
lyricsCtx.restore();
stackPush('tr', _fpsBoxH);
}
// 2. Section HUD.
if (sectionHudVisible && bundle.sections && bundle.sections.length) {
const secH = drawSectionHud(lyricsCtx, {
sections: bundle.sections,
currentTime: bundle.currentTime,
canvasW: lyricsCanvas.width, canvasH: lyricsCanvas.height,
position: sectionHudPosition,
sizeSlider: sectionHudSize,
lyricsBottom,
stackOffset: cornerStack[sectionHudPosition] || 0,
});
stackPush(sectionHudPosition, secH);
}
// 3. Tone HUD.
if (toneHudVisible && (bundle.toneChanges?.length || bundle.toneBase)) {
const toneH = drawToneHud(lyricsCtx, {
toneChanges: bundle.toneChanges,
toneBase: bundle.toneBase,
currentTime: bundle.currentTime,
canvasW: lyricsCanvas.width, canvasH: lyricsCanvas.height,
position: toneHudPosition,
sizeSlider: toneHudSize,
lyricsBottom,
stackOffset: cornerStack[toneHudPosition] || 0,
});
stackPush(toneHudPosition, toneH);
}
// 4. Chord diagram — always last (bottommost in the stack).
// Draw outgoing first so the incoming diagram renders on top,
// making the entrance scale-in animation visible during crossfades.
// The outgoing (prev) diagram uses the same corner slot — it is
// fading out while the incoming one fades in, so they share the
// same stack position and don't double-count the height.
if (chordDiagramVisible && _diagPrev && _diagPrevOpacity > 0) {
_drawDiagramCached(lyricsCtx, {
name: _diagPrev.name, frets: _diagPrev.frets,
opacity: _diagPrevOpacity,
entranceT: (_diagPrev.t !== undefined)
? Math.min(1.0, Math.max(0, (bundle.currentTime - _diagPrev.t) / DIAG_ENTRANCE_S))
: 1.0,
canvasW: lyricsCanvas.width, canvasH: lyricsCanvas.height,
inverted: _invertedCached,
sizeSlider: chordDiagramSize, position: chordDiagramPosition,
nStr: _diagPrev.nStr ?? nStr,
lyricsBottom,
stackOffset: cornerStack[chordDiagramPosition] || 0,
});
// Don't push here — outgoing and incoming share the same slot.
}
if (chordDiagramVisible && _diagChord) {
const diagH = _drawDiagramCached(lyricsCtx, {
name: _diagChord.name, frets: _diagChord.frets,
opacity: Math.max(0, 1 + (_diagChord.t - bundle.currentTime) / DIAG_LINGER_S),
entranceT: _diagEntranceT,
canvasW: lyricsCanvas.width, canvasH: lyricsCanvas.height,
inverted: _invertedCached,
sizeSlider: chordDiagramSize, position: chordDiagramPosition,
nStr: _diagChord.nStr ?? nStr,
lyricsBottom,
stackOffset: cornerStack[chordDiagramPosition] || 0,
});
stackPush(chordDiagramPosition, diagH);
}
}
// Draw-hook compatibility: fire hooks registered via
// window.highway.addDrawHook() on our 2D overlay canvas
// so overlay plugins (fretboard, chord-label HUDs, etc.)
// continue to render when the 3D renderer is active.
// The hooks expect a 2D context — lyricsCtx is exactly
// that, positioned above the WebGL surface.
if (lyricsCtx && lyricsCanvas &&
window.highway &&
typeof window.highway.fireDrawHooks === 'function') {
window.highway.fireDrawHooks(
lyricsCtx, lyricsCanvas.width, lyricsCanvas.height
);
}
},
resize(w, h) {
if (!_isReady) return;
const s = canvasSize(highwayCanvas);
applySize(s.w > 0 ? s.w : w, s.h > 0 ? s.h : h);
},
destroy() {
_destroyed = true; _isReady = false; _diagChord = null; _diagPrev = null; _diagLastKey = null; _diagRenderCache.clear();
_lastHwW = 0; _lastHwH = 0;
_appliedW = 0; _appliedH = 0;
_unsubscribeFocus(); teardown();
highwayCanvas = null;
},
};
}
window.slopsmithViz_highway_3d = createFactory;
// Per-panel control descriptors (splitscreen). The palette selector was
// removed — per-string colors are set via the core "Highway String Colors"
// UI, which drives both highways by named string.
window.slopsmithViz_highway_3d.panelControls = [
{
key: 'cameraSmoothing',
label: 'Camera smoothing (X-pan)',
type: 'range',
min: 0,
max: 1,
step: 0.05,
default: BG_DEFAULTS.cameraSmoothing,
},
{
key: 'cameraLockLow',
label: 'Lock camera at frets 1-12',
type: 'toggle',
default: BG_DEFAULTS.cameraLockLow,
},
{
key: 'cameraLockZoom',
label: 'Locked zoom (In ↔ Out)',
type: 'range',
min: 0,
max: 1,
step: 0.05,
default: BG_DEFAULTS.cameraLockZoom,
},
];
// Static metadata exposed on the factory:
// panelControls - optional, host-readable descriptors for a
// curated per-panel control surface. Renderer
// values still flow through _bgLoadSettings().
// contextType - required canvas context type. highway.js
// replaces the <canvas> element when the
// requested type differs from the current one,
// so this renderer can be installed mid-session
// even if the canvas was previously bound to 2D.
// matchesArrangement - Auto-mode predicate. When the picker is on
// "Auto", core installs the first registered
// viz whose predicate returns truthy on the
// current song_info. Lead/Rhythm/Bass/Guitar
// arrangements route here; Keys arrangements
// are matched by the piano plugin instead.
// _canRun3D() in app.js still gates Auto from
// picking us on machines without WebGL2.
window.slopsmithViz_highway_3d.contextType = 'webgl2';
window.slopsmithViz_highway_3d.__test = {
getAnalyserForBridgeTest: _bgGetAnalyser,
readBandsForBridgeTest: _bgReadBands,
resetAnalyserBridgeForTest() { _bgBridgeKeys.clear(); _bgAudio = null; _bgAudioCore = null; _bgAudioFailedAt = 0; },
};
// Canonical guitar arrangement names (server.py: _ALLOWED_ARRANGEMENT_NAMES)
// are Lead / Rhythm / Bass / Combo. `guitar` is included as a safety
// net for sources that use a generic name (older imports, third-party
// sloppaks). Word boundaries (\b) keep us from accidentally matching
// arrangements that merely contain these as substrings (e.g. a
// "BasslineKeys" arrangement would otherwise match `bass`).
window.slopsmithViz_highway_3d.matchesArrangement = function (songInfo) {
const arr = (songInfo && songInfo.arrangement) || '';
return /\b(?:lead|rhythm|bass|combo|guitar)\b/i.test(arr);
};
// No imperative register() call needed: slopsmith#272 introduced the
// consolidated tour menu, which discovers this plugin's tour automatically
// via /api/plugins (has_tour:true from plugin.json's tour field) and
// gates relevance on whether highway_3d is the active viz. A register()
// call with only injectTriggerInto was a no-op anyway since the new menu
// owns trigger placement; for buildSteps / onStart / onComplete / a
// custom screens override, register() is still the right hook.
})();