/** * Canvas-based note highway renderer. * Receives note data via WebSocket, renders on requestAnimationFrame. */ function createHighway() { let canvas, ctx, ws; // Promise chain for serializing async ws.onmessage handlers — // reset on each connect() so reconnections start a fresh chain. let _msgChain = Promise.resolve(); // Monotonically-increasing connection generation counter. // Incremented on every connect() so async handlers that survive a // reconnect can detect they are stale and bail out before mutating // shared state. let _wsGen = 0; // The audio-element accessor below is polled by plugins, so record its // legacy bridge hit only once per highway instead of on every call — // otherwise a per-frame poller floods playback:bridge-hit events and // diagnostics. let _audioElementBridgeRecorded = false; // Pending JUCE routing promise for the current connection's song_info. // The 'ready' handler awaits this so _juceMode is settled before // _onReady / song:ready fire, without blocking note/chord processing. let _juceRoutingPromise = Promise.resolve(); function _audioSession() { return window.feedBack && window.feedBack.audioSession; } function _reportAudioSessionStart(info) { const session = _audioSession(); if (!session || typeof session.startSession !== 'function') return; try { session.startSession({ sessionId: `main:${info && (info.filename || info.title || 'song')}`, playerId: 'main', songKey: info && (info.filename || info.audio_url || info.title), songFormat: info && (info.format || 'unknown'), }); } catch (_) { /* audio-session diagnostics are best-effort */ } } function _reportAudioRoute(routeKind, availability, reason) { const session = _audioSession(); if (!session || typeof session.setRoute !== 'function') return; try { session.setRoute({ routeId: 'song-output', routeKind, availability: availability || 'available', selectedByUser: true, fallbackReason: reason || '' }); } catch (_) { /* audio-session diagnostics are best-effort */ } } function _reportAudioMonitoring(monitoringId, state, reason) { const session = _audioSession(); if (!session || typeof session.startMonitoring !== 'function') return; try { session.startMonitoring({ monitoringId, participantId: 'core.juce-route', state, failureReason: reason || '' }); } catch (_) { /* audio-session diagnostics are best-effort */ } } function _reportAudioBridge(bridgeId, domain, legacySurface, outcome, reason) { const session = _audioSession(); if (!session || typeof session.recordBridgeHit !== 'function') return; try { session.recordBridgeHit({ bridgeId, domain, legacySurface, participantId: 'core.highway', outcome: outcome || 'handled', reason: reason || '' }); } catch (_) { /* audio-session diagnostics are best-effort */ } } // Two notions of "now" — kept deliberately separate: // chartTime — audio-aligned clock. What getTime() exposes to plugins // (scoring, note detection, etc.) and what setTime() receives. // currentTime — rendering clock. Equal to chartTime + avOffsetSec, so the // draw code can shift visual notes forward to compensate // for audio-output pipeline latency without plugins having // to care about the offset. // avOffsetSec is set by setAvOffset(ms); default 0 means old behavior. let chartTime = 0; let currentTime = 0; let avOffsetSec = 0; let songOffset = 0.0; // per-song chart offset (loose-folder format only) // Monotonic getTime support: between setTime() calls, getTime() // interpolates forward using performance.now() so plugins observe a // smooth sub-frame clock instead of the coarse step-quantization // that audio.currentTime exposes (browsers don't refresh the // reported value every audio frame — the practical gap between // distinct readings is closer to 20+ ms in Chrome/Firefox even // though the underlying audio thread runs much faster). The anchor // only updates when setTime() receives a // genuinely new value — repeated calls with the same chartTime // (e.g. browser hasn't refreshed audio.currentTime yet) keep the // anchor's perfNow fixed so interpolation continues from the right // origin instead of stuttering. // NaN sentinel for "no prior anchor" so the very first setTime call // always triggers the re-anchor branch — even setTime(0) on the // initial 60 Hz tick. Plain 0 would compare equal to setTime(0) and // skip the branch, leaving _chartAnchorPerfNow uninitialized and // causing getTime() to return NaN. let _chartAnchorAudioT = NaN; let _chartAnchorPerfNow = NaN; // Pause detection: the 60Hz tick in app.js keeps calling setTime() // even while paused (with a stalled audio clock). Track when t last // ADVANCED (not just when setTime was called) — if it's been still // for a while, audio is paused and getTime() should return raw // chartTime instead of interpolating forward against silent audio. // Independent of song:* events — avoids the edge cases where the // pause listener early-returns and never emits. let _chartLastAdvanceAt = 0; // Observed playback rate, derived from the actual delta between // successive anchor updates. FeedBack's speed slider (audio // playbackRate != 1) means audio time advances slower/faster than // real time; interpolating with a fixed 1x rate would drift. Each // re-anchor refines the estimate from the latest segment. Default // to 1 until we have two anchors to compare. let _chartObservedRate = 1; // Cap the interpolation so a stalled main thread (long task, GC, // dropped tick) can't make getTime drift far past reality. Also the // threshold for "audio looks paused" — if setTime hasn't advanced t // in this long, treat as paused. const _CHART_MAX_INTERP_MS = 100; // Visibility-aware rAF (feedBack#246): when the canvas is hidden // (display:none on itself or any ancestor — e.g. splitscreen's // workaround), pause renderer.draw and emit highway:visibility on // transitions so renderers that mount sibling DOM (3D Highway's // .h3d-wrap overlay) can hide it. _visibleOverride !== null forces // the state for hosts that hide via opacity / visibility / clipping // where offsetParent === null isn't enough. let _visibleOverride = null; let _lastVisible = null; let animFrame = null; // Paused-render throttle (feedBack#654). The rAF loop runs // unconditionally and only gates on visibility + ready, never on // playback — so an expensive renderer (3D Highway's Three.js WebGL // scene) does a full render every frame even while paused. That is // pure waste, and the dominant cost on high-refresh / ANGLE setups // (Chromium on Windows paces rAF to the fastest attached monitor, // so the loop can run at 144 Hz even on a 60 Hz panel). While the // audio clock is stalled, cap draws to one per // _PAUSED_FRAME_INTERVAL_MS. Note position is clock-derived // (n.t - currentTime), so this changes smoothness only — never // audio/visual sync. A low non-zero rate (not a hard skip) keeps // resize / seek-scrub / renderer-swap repaints correct without // having to hook each of those paths. const _PAUSED_FRAME_INTERVAL_MS = 100; let _lastPausedDrawAt = 0; let _connectOpts = {}; let _resizeContainer = null; let _resizeHandler = null; let _onLyricsChange = null; // Song data (populated via WebSocket) let songInfo = {}; let notes = []; let chords = []; let handShapes = []; let beats = []; let sections = []; let anchors = []; let chordTemplates = []; // Number of strings on the active arrangement. Updated from the // `stringCount` field in each `song_info` WS message; falls back // to `tuning.length` (works for older servers that don't yet emit // stringCount) then to 6 (final safety). 4 = bass, 6 = guitar, // 7+ = extended-range GP imports. let stringCount = 6; let lyrics = []; // Provenance of the active lyric set. Set from the highway WS `lyrics` // message's `source` field; empty when no lyrics have arrived yet or the // source produced lyrics without provenance (e.g. legacy GP imports). // Exposed via the bundle + getLyricsSource() so plugins can render an // "auto-transcribed" badge for whisperx-sourced lyrics. let lyricsSource = ""; let toneChanges = []; let toneBase = ""; // Drum-tab payload (sloppak-spec §5.3). When the active sloppak's // manifest carries a `drum_tab:` key, the server streams a `drum_tab` // metadata message followed by chunked `drum_hits`. `drumTab.hits` is // concatenated across chunks and exposed on the bundle so the drums // plugin can render the new shape language instead of decoding the // legacy guitar-encoded `notes` stream. Stays at the null sentinel // for non-drum-tab songs so plugins can distinguish "no drums" from // "drums loaded but empty". let drumTab = null; // { version, name, kit: [...], hits: [...] } let ready = false; // Master-difficulty (feedBack#48). _phrases stays null as a // "slider disabled" sentinel when the source chart has no ladder // data (GP imports, legacy sloppak) — the server omits the // `phrases` message entirely in that case. When populated, the // filter maps the slider fraction to a per-phrase level index and // stages _filteredNotes / _filteredChords for the render loop. // _filteredNotes === null means "fall through to flat notes" — // either no phrase data or filter not rebuilt yet. let _phrases = null; // Default to full chart. Persistence lives in the caller (app.js // loadSettings, or a splitscreen plugin managing its own panel // state) so multiple createHighway() instances stay truly // per-instance — no shared localStorage key to race on. let _mastery = 1; let _filteredNotes = null; let _filteredChords = null; let _filteredAnchors = null; let _filteredHandShapes = null; // Tracks whether ANY phrase level carries handshape data. Lets us // distinguish "this difficulty has none" (respect strictly — even // when empty) from "the chart's phrase data never authored any // handshapes at all" (fall back to the flat list so 3D arpeggio // hints still work on DLC that ships handshapes only on the // arrangement root). Without this flag the bundle would silently // surface arp-frame hints at low-mastery levels that shouldn't // have any. let _phrasesHaveHandShapes = false; let showLyrics = localStorage.getItem('showLyrics') !== 'false'; // Teaching marks (§6.2.2): the fret-hand finger numeral (fg) renders by // default (small, on the gem), but the scale-degree (sd) + strum-group (ch) // overlays are opt-in so the default highway stays uncluttered. Display only // — never used for grading. let _showTeachingMarks = localStorage.getItem('showTeachingMarks') === 'true'; // Fret-hand finger (fg) hints are shown by DEFAULT (the most broadly useful // teaching mark) but independently hideable — separate from the sd/ch opt-in // above so the two defaults (fg on, sd/ch off) can coexist. Default-true: an // absent key reads as on; only an explicit 'false' hides it. let _showFingerHints = localStorage.getItem('showFingerHints') !== 'false'; let _drawHooks = []; // plugin draw callbacks: fn(ctx, W, H) // feedBack#254 — per-note judgment overlay. A plugin (note_detect) // registers fn(note, chartTime) -> 'hit' | 'active' | 'miss' | null // (or { state, alpha?, color? }); renderers consult it per visible // note so the gem itself can light up / a held sustain can glow, // instead of relying on a separate overlay ring. null = no provider. let _noteStateProvider = null; // 1 = full, 0.5 = half res. Sanitize on load the same way setRenderScale // clamps: a corrupt/out-of-range localStorage value (NaN, 0, >1) would // otherwise flow into _effectiveRenderScale() and zero the canvas. let _renderScale = (function () { const v = parseFloat(localStorage.getItem('renderScale') || '1'); return Number.isFinite(v) ? Math.max(0.25, Math.min(1, v)) : 1; })(); // Load-adaptive render scale (feedBack#654). _renderScale is the // user's manual ceiling; _autoScale is an automatic multiplier the // draw loop lowers when the active renderer's per-frame cost blows // the budget (a heavy 3D Highway WebGL scene on a weak GPU, or on // high-refresh + ANGLE where Chromium runs the loop at the fastest // monitor's rate) and raises again once headroom returns. The scale // actually applied to the canvas backing store + bundle.renderScale // is _renderScale * _autoScale, floored at the user-configurable // _autoScaleMin (the applied floor is capped at the _renderScale ceiling // inside _effectiveRenderScale) — so an // auto change flows through the exact same plumbing as a manual // setRenderScale. Adapting resolution (not frame rate) keeps motion // at the display's native refresh, avoiding judder, and never // affects sync (note position is clock-derived). let _autoScale = 1; let _drawMsEMA = 0; // smoothed cost of _renderer.draw() let _frameMsEMA = 0; // smoothed frame interval (for the HUD) let _lastFramePerf = 0; let _lastAutoAdjustAt = 0; let _lastUpscaleAt = 0; // separate, longer clock for UPscaling (lazy) let _perfHud = null; let _hudOn = false; // cached highwayPerfHud flag (re-read ~2x/sec, not per-frame) let _hudFlagAt = 0; const _DRAW_BUDGET_HI_MS = 12; // sustained draw cost above this -> scale down const _DRAW_BUDGET_LO_MS = 7; // sustained draw cost below this -> scale back up const _AUTO_SCALE_MIN = 0.25; // hard floor (lowest the user-configurable floor may be set) // User-configurable floor for the load-adaptive render scale (#654): // 0.25 = stock (can drop to quarter-res on heavy frames → pixelated), // 1.0 = never auto-downscale below the Quality (renderScale) ceiling — so // it's only "full resolution" when Quality is HD; otherwise it pins at the // chosen Quality. Read once here; live changes come through // api.setMinRenderScale(), surfaced as the "Min res" control next to Quality // in the player controls (static/index.html). let _autoScaleMin = (function () { const v = parseFloat(localStorage.getItem('highwayMinRenderScale')); return Number.isFinite(v) ? Math.max(_AUTO_SCALE_MIN, Math.min(1, v)) : _AUTO_SCALE_MIN; })(); const _AUTO_ADJUST_COOLDOWN_MS = 600; // Upscaling is deliberately LAZY (longer cooldown than the downscale path) so // the resolution doesn't visibly hunt up/down on passages that hover near the // budget — testers saw "quality going up and down" as parts got busier (#618 // charrette). Downscale stays prompt to protect the frame rate. const _AUTO_UPSCALE_COOLDOWN_MS = 2500; let _inverted = localStorage.getItem('invertHighway') === 'true'; let _lefty = localStorage.getItem('lefty') === '1'; let _lastChordOnFretLine = null; // chord object currently shown on fret line let _chordFretLineNotes = []; // notes to render on fret line const _frameMismatchWarned = new Set(); // chord ids already warned about (feedBack#88) // Per-chord render info, computed lazily once per src array (feedBack#88). const _chordRenderInfo = new WeakMap(); // chord -> { chainIndex, chainLen, isFull, baseFret, sortedNotes, nonZeroNotes, nonZeroFrets, allMuted, hasMultipleNotes } let _chordRenderCacheSrc = null; let _chordRenderCacheInverted = null; // Also invalidate when chordTemplates is reassigned (WS 'chord_templates' // can land AFTER 'chords' chunks, and `isOpen(cn)` — used to compute // cached nonZeroNotes/nonZeroFrets — depends on the template lookup). let _chordRenderCacheTemplates = null; // Frame counter for cheap deterministic "random" lookups (sustain // shimmer). Incremented once per rAF in draw(). let _frameIdx = 0; // 64-entry precomputed jitter LUT replacing Math.random() in the // lit-sustain shimmer hot path (drawSustains). Visually // indistinguishable from per-frame Math.random at rAF cadence, // allocation-free, and removes 4 RNG calls per visible lit sustain // per frame on dense charts. Seeded deterministically (xorshift32) // so the LUT itself is identical across `createHighway()` instances // — shimmer is therefore reload-stable and test-reproducible PER // instance for a given (frameIdx, n.s, n.t) seed. The seed includes // closure-scope `_frameIdx` which is per-instance, so two // splitscreen highways with different rAF cadence will shimmer // differently at any given wall-clock moment; what's stable is the // LUT contents. // // _SHIMMER_LUT_SIZE MUST stay a power of two — `_shimmerNoise` // indexes with `& (_SHIMMER_LUT_SIZE - 1)` for the cheap modulo. const _SHIMMER_LUT_SIZE = 64; const _shimmerLut = new Float32Array(_SHIMMER_LUT_SIZE); for (let i = 0; i < _SHIMMER_LUT_SIZE; i++) { let x = (i + 1) | 0; // +1 dodges the all-zero xorshift trap x ^= x << 13; x ^= x >>> 17; x ^= x << 5; _shimmerLut[i] = (x >>> 0) / 4294967296; } function _shimmerNoise(seed) { // Mask works only because _SHIMMER_LUT_SIZE is a power of two // (see comment at the LUT declaration above). return _shimmerLut[(seed >>> 0) & (_SHIMMER_LUT_SIZE - 1)]; } // Memoize ctx.measureText() for the lyric overlay. Per-syllable // measurement was the dominant cost in dense karaoke charts; text // and fontSize are the only inputs (font face string is constant // `bold ${fontSize}px sans-serif`). Two-level Map (outer: fontSize, // inner: text) so a cache hit avoids the `fontSize + '|' + text` // concat that previously allocated on every lookup. // // Bounded on BOTH levels: window resizes change `fontSize`, so each // resize creates a fresh inner Map; without an outer cap, the cache // would retain every fontSize ever rendered for the page lifetime. // Cap outer at 16 distinct fontSize buckets (more than enough — a // session typically sees one or two), inner at 4096 entries per // bucket. Clear-on-overflow on both — a karaoke cold start re-warms // in one frame. const _LYRIC_MEASURE_OUTER_MAX = 16; const _LYRIC_MEASURE_INNER_MAX = 4096; const _lyricMeasureCache = new Map(); // Map> function _measureLyricText(c, fontSize, text) { let inner = _lyricMeasureCache.get(fontSize); if (inner === undefined) { if (_lyricMeasureCache.size >= _LYRIC_MEASURE_OUTER_MAX) _lyricMeasureCache.clear(); inner = new Map(); _lyricMeasureCache.set(fontSize, inner); } let w = inner.get(text); if (w === undefined) { if (inner.size >= _LYRIC_MEASURE_INNER_MAX) inner.clear(); w = c.measureText(text).width; inner.set(text, w); } return w; } // Rendering config const VISIBLE_SECONDS = 3.0; const Z_CAM = 2.2; const Z_MAX = 10.0; const BG = '#080810'; // String color palettes. Indices 0–5 cover guitar / bass; 6–7 // are added for extended-range GP imports (7-string, 8-string). // Lookups still use `|| '#888'` as a safety fallback for any // out-of-range index. // // These are `let`, not `const`: setStringColors() (used by the core // "Highway String Colors" theming UI) overrides per-index entries at // runtime, deriving the dim/bright variants from the chosen base color. // DEFAULT_* keep the originals so a reset restores them byte-for-byte. const DEFAULT_STRING_COLORS = [ '#cc0000', '#cca800', '#0066cc', '#cc6600', '#00cc66', '#9900cc', '#cc00aa', '#00cccc', // 7th = magenta, 8th = teal ]; const DEFAULT_STRING_DIM = [ '#520000', '#524200', '#002952', '#522900', '#005229', '#3d0052', '#520042', '#005252', ]; const DEFAULT_STRING_BRIGHT = [ '#ff3c3c', '#ffe040', '#3c9cff', '#ff9c3c', '#3cff9c', '#cc3cff', '#ff3ce0', '#3ce0e0', ]; let STRING_COLORS = DEFAULT_STRING_COLORS.slice(); let STRING_DIM = DEFAULT_STRING_DIM.slice(); let STRING_BRIGHT = DEFAULT_STRING_BRIGHT.slice(); // ── String-color helpers ───────────────────────────────────────────── function _clampByte(n) { return n < 0 ? 0 : (n > 255 ? 255 : Math.round(n)); } function _parseHex(hex) { if (typeof hex !== 'string') return null; let h = hex.trim().replace(/^#/, ''); if (h.length === 3) h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2]; if (!/^[0-9a-fA-F]{6}$/.test(h)) return null; return { r: parseInt(h.slice(0, 2), 16), g: parseInt(h.slice(2, 4), 16), b: parseInt(h.slice(4, 6), 16) }; } function _toHex(r, g, b) { const s = (n) => _clampByte(n).toString(16).padStart(2, '0'); return '#' + s(r) + s(g) + s(b); } // Darken toward black: factor 0..1 (0.4 keeps 40% of each channel), // matching the look of the default DIM band behind gems. function _darken(hex, factor) { const c = _parseHex(hex); if (!c) return hex; return _toHex(c.r * factor, c.g * factor, c.b * factor); } // Brighten by mixing toward white: t 0..1 fraction of white blended in. function _lighten(hex, t) { const c = _parseHex(hex); if (!c) return hex; return _toHex(c.r + (255 - c.r) * t, c.g + (255 - c.g) * t, c.b + (255 - c.b) * t); } // ── Projection ─────────────────────────────────────────────────────── function project(tOffset) { if (tOffset > VISIBLE_SECONDS || tOffset < -0.05) return null; if (tOffset < 0) return { y: 0.82 + Math.abs(tOffset) * 0.3, scale: 1.0 }; const z = tOffset * (Z_MAX / VISIBLE_SECONDS); const denom = z + Z_CAM; if (denom < 0.01) return null; const scale = Z_CAM / denom; const y = 0.82 + (0.08 - 0.82) * (1.0 - scale); return { y, scale }; } // ── Anchor / Fret mapping ──────────────────────────────────────────── // Zoom approach: fret 0 at the left edge, fret N at the right (entire canvas mirrored when lefty). // The "zoom level" determines how many frets are visible. // When playing low frets, zoom in (fewer frets visible, bigger notes). // When playing high frets, zoom out (more frets visible, smaller spacing). let displayMaxFret = 12; // rightmost visible fret (smoothed) function getAnchorAt(t) { // Same master-difficulty fallback as the render loops — the // anchor ladder pairs with the note ladder. const src = _filteredAnchors !== null ? _filteredAnchors : anchors; let a = src[0] || { fret: 1, width: 4 }; for (const anc of src) { if (anc.time > t) break; a = anc; } return a; } function getMaxFretInWindow(t) { // Find the highest fret needed across all anchors visible on screen const src = _filteredAnchors !== null ? _filteredAnchors : anchors; let maxFret = 0; for (const anc of src) { if (anc.time > t + VISIBLE_SECONDS + 2) break; // Skip anchors well in the future (with a little buffer to avoid moving early the cutoff) if (anc.time + 2 < t) continue; // skip anchors well in the past const top = anc.fret + anc.width; if (top > maxFret) maxFret = top; } return maxFret; } function updateSmoothAnchor(anchor, dt) { // Smoothing rate balances two regressions seen in feedBack#88: // rate=1.0 (was) snapped to target every frame — visible jitter // on aerial passages where anchors moved every few frames. // rate=0.15 (Knaifhogg) was too gentle — large jumps (low frets // to teens) took ~3s to catch up, pushing upcoming notes off the // right edge. // 0.4 splits the difference: half-life ~1.7s, but the per-frame // step at 60fps is ~0.0067 — still small enough that frame-to-frame // changes read as smooth. const rate = Math.min(0.4 * dt, 0.4); // Look ahead: use the widest fret range across all visible anchors const lookAheadMax = getMaxFretInWindow(currentTime); const currentMax = anchor.fret + anchor.width; const needed = Math.max(currentMax, lookAheadMax); const targetMax = Math.max(needed + 3, 8); displayMaxFret += (targetMax - displayMaxFret) * rate; } function fretX(fret, scale, w) { const hw = w * 0.52 * scale; const margin = hw * 0.06; const usable = hw * 2 - 2 * margin; const t = fret / Math.max(1, displayMaxFret); return w / 2 - hw + margin + t * usable; } /** Map a bend curve [{t, v}] (§6.2.1) to [{x, v}] with x normalized to * 0..1 across the curve's time span (0 when the span is degenerate). * Pure — drives the 2D bend-shape glyph. */ function bnvNormalizedPoints(bnv, sus) { if (!Array.isArray(bnv) || bnv.length === 0) return []; // Map each point's time over the NOTE's span [0, sus] so it sits at its // real fraction of the note (a bend that completes before the note ends // draws short of the glyph's right edge). Fall back to the curve's own // t-range only when the note has no usable sustain. if (Number.isFinite(sus) && sus > 0) { return bnv.map(p => ({ x: Math.min(Math.max(p.t / sus, 0), 1), v: p.v })); } const t0 = bnv[0].t; const span = bnv[bnv.length - 1].t - t0; return bnv.map(p => ({ x: span > 0 ? (p.t - t0) / span : 0, v: p.v })); } /** Teaching mark (§6.2.2): fret-hand-finger label for a note's `fg`. * '' when unset/out of range; 0 → 'T' (thumb), 1..4 → '1'..'4'. Pure. */ function teachingFingerLabel(fg) { if (!Number.isInteger(fg) || fg < 0 || fg > 4) return ''; return fg === 0 ? 'T' : String(fg); } /** Teaching mark (§6.2.2): scale-degree label for a note's `sd` (chromatic * 0..11 above the active key tonic). '' when unset/out of range. Pure. */ function teachingDegreeLabel(sd) { if (!Number.isInteger(sd) || sd < 0 || sd > 11) return ''; return String(sd); } /** Harmony annotations (§6.3.1 / §6.6): display labels for a chord's * harmonic function (the instance `fn.rn` Roman numeral) and its template * `voicing`, `caged` shape, and `guideTones`. Returns '' for each when * absent or malformed; `caged`/`guideTones` come back pre-formatted * ("CAGED: E" / "gt 4,10"). Pure; node-tested and shared by both highways. * Display/teaching only — MUST NEVER feed a grader (honesty rule). */ function chordHarmonyLabels(fn, voicing, caged, guideTones) { const rn = (fn && typeof fn.rn === 'string') ? fn.rn.trim() : ''; const vc = (typeof voicing === 'string') ? voicing.trim() : ''; const cg = (typeof caged === 'string' && /^[CAGED]$/.test(caged.trim())) ? 'CAGED: ' + caged.trim() : ''; const gt = Array.isArray(guideTones) ? guideTones.filter(n => Number.isInteger(n) && n >= 0 && n <= 11) : []; return { rn, voicing: vc, caged: cg, guideTones: gt.length ? 'gt ' + gt.join(',') : '' }; } /** Teaching mark (§6.2.2): bucket drawn notes by their strum-group key `ch`. * Returns the groups (in first-seen order) for each ch value >= 0 that has * at least two members — a lone note is not a strum gesture. Pure; drives * the strum-bracket overlay and is node-tested. */ function strumGroupBuckets(items) { if (!Array.isArray(items)) return []; const order = []; const byKey = new Map(); for (const it of items) { const ch = it && Number.isInteger(it.ch) ? it.ch : -1; if (ch < 0) continue; if (!byKey.has(ch)) { byKey.set(ch, []); order.push(ch); } byKey.get(ch).push(it); } return order.map(k => byKey.get(k)).filter(g => g.length >= 2); } /** Call while lefty mirror transform is active; keeps glyphs readable. */ function fillTextReadable(text, x, y) { // ctx may be null when the 2D context was never acquired // (canvas already locked to WebGL). No-op in that case — // alternatives would be throwing, which breaks plugin hooks // that call this after a context-type mismatch. if (!canvas || !ctx) return; const W = canvas.width; if (!_lefty) { ctx.fillText(text, x, y); return; } ctx.save(); ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.fillText(text, W - x, y); ctx.restore(); } // ── Per-note judgment state (feedBack#254) ────────────────────────── // Resolves the registered provider for one chart note. Returns null // when no provider is set, the provider throws, it reports nothing, // or the reported alpha is non-positive. Otherwise a normalized // { state: 'hit'|'active'|'miss', alpha: 0..1, color: string|null }. // 'hit' and 'active' are both "lit" — renderers may treat them the // same; the distinction (struck note vs currently-held sustain) is // there for renderers that want it. The provider owns all timing / // fade — `alpha` is whatever intensity it wants right now. function _noteState(note, chartTime) { if (!_noteStateProvider) return null; let raw; try { raw = _noteStateProvider(note, chartTime); } catch (e) { return null; } if (!raw) return null; const state = typeof raw === 'string' ? raw : raw.state; if (state !== 'hit' && state !== 'active' && state !== 'miss') return null; const alpha = (raw && typeof raw === 'object' && Number.isFinite(raw.alpha)) ? Math.max(0, Math.min(1, raw.alpha)) : 1; if (alpha <= 0) return null; const color = (raw && typeof raw === 'object' && typeof raw.color === 'string') ? raw.color : null; // Pass through the provider's `live` flag: note_detect tags its // ring-tracking 'active' responses with live:true so a renderer can // treat them as authoritative (extinguish on mute, relight on // re-strike) instead of latching them for the whole chart sustain. // Renderers that don't care simply ignore it. const live = (raw && typeof raw === 'object' && raw.live === true); return { state, alpha, color, live }; } // Stable bundle accessor for the registered provider — see // bundle.getNoteStateProvider below. Defined once per createHighway() // instance (not module scope — _noteStateProvider is per-instance), // so _makeBundle() doesn't reallocate an arrow function per frame // (matches getNoteState: _noteState's stable-reference pattern). function _getNoteStateProvider() { return _noteStateProvider; } // Paints the judgment effect on top of an already-drawn gem at // (cx,cy) with half-extent `r`. `ns` is the normalized state from // _noteState (or null → no-op). A miss → faint red wash. A correct // hit / held sustain → a "sizzle": throbbing additive halo + a // flickering white-hot core + crackling spark lines re-randomised // each frame + (for a fresh struck note that's fading) an expanding // shockwave ring. Intensity scales with `ns.alpha`, so a struck // note flares and dies while a held sustain crackles continuously. // Caller draws the gem normally first, then calls this BEFORE any // glyph so a readable fret number can land on top. function _paintGemGlow(cx, cy, r, stringIdx, ns) { if (!ns || !ctx) return; ctx.save(); if (ns.state === 'miss') { ctx.globalAlpha = 0.4 * ns.alpha; ctx.fillStyle = '#ff2828'; ctx.beginPath(); ctx.arc(cx, cy, r * 1.05, 0, Math.PI * 2); ctx.fill(); ctx.restore(); return; } const col = ns.color || STRING_BRIGHT[stringIdx] || '#ffffff'; const a = ns.alpha; const nowMs = (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now(); ctx.lineCap = 'round'; // Expanding shockwave — only on a fresh struck-and-fading hit // (alpha decays 1→0). 'active' (held sustain, alpha pinned 1) skips it. if (ns.state === 'hit' && a < 1) { const prog = 1 - a; // 0 at strike → 1 at fade-out ctx.globalCompositeOperation = 'lighter'; ctx.globalAlpha = a * 0.85; ctx.strokeStyle = col; ctx.lineWidth = Math.max(1.5, r * 0.26 * a); ctx.beginPath(); ctx.arc(cx, cy, r * (1.0 + prog * 2.7), 0, Math.PI * 2); ctx.stroke(); } // Throbbing halo (≈9 Hz wobble). const pulse = 0.8 + 0.2 * Math.sin(nowMs / 18); const haloR = r * 2.0 * pulse; ctx.globalCompositeOperation = 'lighter'; ctx.globalAlpha = a; const g = ctx.createRadialGradient(cx, cy, 0, cx, cy, haloR); g.addColorStop(0, '#ffffff'); g.addColorStop(0.30, col); g.addColorStop(1, 'rgba(0,0,0,0)'); ctx.fillStyle = g; ctx.beginPath(); ctx.arc(cx, cy, haloR, 0, Math.PI * 2); ctx.fill(); // Crackle — short bright spark lines flicking out from the gem, // re-randomised every frame so it shimmers. const sparkCount = 6; for (let i = 0; i < sparkCount; i++) { if (Math.random() > 0.55 * a + 0.2) continue; // intermittent const ang = Math.random() * Math.PI * 2; const inR = r * 0.45; const len = r * (0.7 + Math.random() * 1.6) * (0.5 + 0.5 * a); ctx.globalAlpha = a * (0.45 + Math.random() * 0.55); ctx.strokeStyle = Math.random() < 0.5 ? '#ffffff' : col; ctx.lineWidth = Math.max(1, r * (0.08 + Math.random() * 0.08)); ctx.beginPath(); ctx.moveTo(cx + Math.cos(ang) * inR, cy + Math.sin(ang) * inR); ctx.lineTo(cx + Math.cos(ang) * (inR + len), cy + Math.sin(ang) * (inR + len)); ctx.stroke(); } // Flickering white-hot core. ctx.globalCompositeOperation = 'lighter'; ctx.globalAlpha = a * (0.55 + Math.random() * 0.45); ctx.fillStyle = '#ffffff'; ctx.beginPath(); ctx.arc(cx, cy, r * (0.30 + Math.random() * 0.14), 0, Math.PI * 2); ctx.fill(); // Crisp bright rim. ctx.globalCompositeOperation = 'source-over'; ctx.globalAlpha = a; ctx.strokeStyle = col; ctx.lineWidth = Math.max(2, r * 0.2); ctx.beginPath(); ctx.arc(cx, cy, r * 0.95, 0, Math.PI * 2); ctx.stroke(); ctx.restore(); } // ── Drawing ────────────────────────────────────────────────────────── // // feedBack#36 — swappable renderers. // // The default renderer below is the original 2D canvas highway. Its // methods still reach into the factory closure (ctx, beats, notes, // _drawHooks, etc.) to avoid rewriting every helper; it's not // "isolated," just shaped as the contract. Custom renderers from // plugins (3D, tab, fretboard, future "keys"/"drums") pass through // setRenderer() and consume the bundle instead of the closure — // they stay self-contained and never touch the factory's `ctx`. // // Lifecycle: setRenderer(r) -> previous.destroy() -> r.init(canvas, // bundle) -> per frame r.draw(bundle) -> on resize r.resize(w, h) -> // on stop or swap r.destroy(). Renderer owns its rendering context // (2D, WebGL, DOM overlay). Factory owns canvas element, rAF, WS, // data state, resize subscription, _drawHooks for 2D compositing. // // Contract for setRenderer(r): r is an object with at minimum // {draw(bundle)}. init / resize / destroy are optional. Pass null // or undefined to restore the default renderer. // // The bundle (see _makeBundle) is a per-frame snapshot of factory // state — includes difficulty-filtered note / chord / anchor arrays // so renderers never touch _filteredX internals directly. Arrays // are live references (performance), NOT copies — renderers must // treat them as read-only. let _renderer = null; // Tracks the rendering context type currently bound to the canvas // element (`'2d'` or `'webgl2'`). Browsers lock a to the // first context type successfully acquired for its lifetime, so // when _setRenderer installs a renderer that declares a different // contextType we replace the underlying element entirely // (see _replaceCanvas) so the new renderer's getContext() can // succeed. Default 2D — matches what _defaultRenderer.init() does // on the freshly-mounted canvas. let _currentCanvasContextType = '2d'; function _makeBundle() { // Snapshot of current factory state passed to each renderer call. // Arrays and songInfo are LIVE references, not copies — the bundle // itself is rebuilt each frame but its `notes`, `chords`, // `anchors`, `beats`, etc. point at closure state. Renderers // MUST NOT mutate these; treat them as read-only. We don't // Object.freeze or deep-copy for per-frame allocation cost reasons. return { // Timing currentTime, songInfo, isReady: ready, // True while the chart clock is actively advancing; false when // audio is paused / stalled / mid-seek (setTime has kept getting // the same t for > _CHART_MAX_INTERP_MS). This is the same // predicate getTime() uses to decide raw-vs-interpolated, and the // no-anchor boot state reads as not-playing — matching getTime() // returning raw chartTime there. Renderers that run their own // sub-frame clock (highway_3d's smoothNow) gate on this to fall // back to raw instead of extrapolating forward against a frozen // audio sample. Undefined on downlevel hosts → those renderers // keep their own staleness-based fallback. isPlaying: !Number.isNaN(_chartAnchorPerfNow) && (performance.now() - _chartLastAdvanceAt) <= _CHART_MAX_INTERP_MS, // Chart content (filter-aware — difficulty-filtered arrays // preferred; raw arrays are the fallback when no ladder data). notes: _filteredNotes !== null ? _filteredNotes : notes, chords: _filteredChords !== null ? _filteredChords : chords, anchors: _filteredAnchors !== null ? _filteredAnchors : anchors, beats, sections, chordTemplates, stringCount, // Mirrors song_info tuning capo offsets (±semitones from the // instrument’s standard open-string layout). Live reference. tuning: songInfo?.tuning, capo: songInfo?.capo, lyrics, lyricsSource, toneChanges, toneBase, // Drum tab payload (or null when the active arrangement has // no drum_tab). Live reference — renderers MUST treat as // read-only. Plugins should prefer this over decoding the // standard `notes` stream when present; absence is the // signal to fall back to legacy MIDI-encoded drums. drumTab, // Master-difficulty (feedBack#48) mastery: _mastery, hasPhraseData: !!(_phrases && _phrases.length > 0), // When phrase data authored ANY handshape, respect the filtered // list strictly (even when this difficulty leaves it empty) — // otherwise low-mastery levels would surface arp hints that // don't belong. Only fall back to the flat list when the // phrase data carries no handshapes at all (common on DLC // where handshapes ship on the arrangement root). handShapes: (_filteredHandShapes !== null && _phrasesHaveHandShapes) ? _filteredHandShapes : handShapes, // Display flags inverted: _inverted, lefty: _lefty, renderScale: _effectiveRenderScale(), lyricsVisible: showLyrics, // Teaching marks sd/ch overlay pref (§6.2.2) so custom renderers // (e.g. the 3D highway) can mirror the 2D opt-in toggle. The fg // finger-hint pref rides alongside (default on, independently hideable). teachingMarksVisible: _showTeachingMarks, fingerHintsVisible: _showFingerHints, // 2D-style helpers (renderers that don't need these can ignore). // `fillTextUnmirrored` is deliberately NOT exposed here — // the factory-level version writes to the default renderer's // closure ctx, which is null for custom renderers. Renderers // that need lefty-aware text should check `bundle.lefty` and // apply the mirror transform themselves on their own context. project, fretX, // Per-note judgment overlay (feedBack#254). Renderers call // this per visible note / chord-note to find out whether a // scorer (note_detect) has flagged it hit / actively-held / // missed, so the gem itself can light up instead of relying // on an overlay ring. Returns null when no provider is set // or it reports nothing for this note; otherwise // { state: 'hit'|'active'|'miss', alpha: 0..1, color: string|null }. getNoteState: _noteState, // stable reference — no per-frame allocation // Lets custom renderers (e.g. highway_3d) tell "is a provider // attached" apart from "no provider, getNoteState always // returns null" — `getNoteState` always exists on the bundle // so its presence alone isn't a useful "detect mode" signal. // Renderers gate verdict-window cull / draw extensions on this. getNoteStateProvider: _getNoteStateProvider, // stable — see above }; } const _defaultRenderer = { _ctxWarned: false, init(canvasEl /* , bundle */) { // getContext('2d') returns null when the canvas is already // locked to another context type (e.g. a WebGL viz plugin // grabbed it first). Once that happens the 2D renderer can't // recover on the same canvas — surface a single clear error // and skip drawing. A future revision will recreate the // canvas element on renderer-type swap to avoid this. ctx = canvasEl.getContext('2d'); if (!ctx && !this._ctxWarned) { console.error( 'Default 2D renderer: canvas.getContext("2d") returned null ' + '— the canvas is locked to another context type. ' + 'Reload the page to restore the highway.' ); this._ctxWarned = true; } }, draw(/* bundle */) { // Still reads from the factory closure directly — the bundle // is shaped for custom renderers, not used here. Keeping the // default renderer's body unchanged from the pre-refactor // draw() preserves pixel-level parity with current main. if (!canvas || !ready || !ctx) return; try { const W = canvas.width; const H = canvas.height; ctx.fillStyle = BG; ctx.fillRect(0, 0, W, H); const anchor = getAnchorAt(currentTime); updateSmoothAnchor(anchor, 1 / 60); ctx.save(); if (_lefty) { ctx.translate(W, 0); ctx.scale(-1, 1); } drawHighway(W, H); drawFretLines(W, H); drawBeats(W, H); drawStrings(W, H); drawSustains(W, H); drawNowLine(W, H); drawNotes(W, H); drawChords(W, H); drawFretNumbers(W, H); // Plugin draw hooks (same coordinate system as the highway). // The default 2D renderer iterates the list directly here; // custom renderers (e.g. the bundled 3D Highway) invoke // `api.fireDrawHooks(ctx, W, H)` against their own overlay // 2D context after rendering. Either path receives the // same `(ctx, W, H)` callback signature. for (const hook of _drawHooks) { try { hook(ctx, W, H); } catch (e) { /* ignore */ } } ctx.restore(); // Lyrics: drawn unmirrored so lines stay left-to-right readable (layout is center-symmetric) if (showLyrics) drawLyrics(W, H); } catch (e) { console.error('draw error:', e); } }, resize(/* w, h */) { // no-op; canvas dimension change is handled by the factory, // and the 2D context doesn't maintain persistent state we'd // need to rebuild here. }, destroy() { // Leave ctx intact. Helper paths like fillTextReadable / // api.fillTextUnmirrored may still be called while another // renderer is active or after stop() (e.g. a residual draw // hook, plugin cleanup code). Forcing ctx to null would // make those calls throw. A subsequent init() re-assigns // ctx via canvasEl.getContext('2d') — the browser returns // the same cached context for the same canvas, so there's // nothing to "refresh" by nulling. Reset the warn-once // guard so a fresh init on a fresh canvas is a new // opportunity to succeed or fail. this._ctxWarned = false; }, }; // Tracks consecutive renderer.draw failures so a permanently broken // renderer auto-reverts to default instead of spamming the console // every frame. Reset on every successful draw and whenever a new // renderer is installed. let _rendererDrawFailures = 0; const MAX_RENDERER_DRAW_FAILURES = 3; // True only while the current renderer has had a successful init // since its last destroy (or was freshly installed but never init'd // because canvas was null). Gates destroy calls so an uninit'd // renderer doesn't receive spurious destroys — the restore-on- // page-load flow relies on this: setRenderer can run before init. let _rendererInited = false; function _destroyCurrentIfInited() { if (_renderer && _rendererInited && typeof _renderer.destroy === 'function') { try { _renderer.destroy(); } catch (e) { console.error('renderer destroy:', e); } } _rendererInited = false; } function _emitVizReverted(reason) { // Notify listeners (e.g. app.js's viz picker, splitscreen's // per-panel picker in Wave C) that the factory auto-reverted // to the default renderer — so the UI / persisted selection // don't keep advertising the broken plugin. if (window.feedBack && typeof window.feedBack.emit === 'function') { try { window.feedBack.emit('viz:reverted', { reason }); } catch (e) { console.error('viz:reverted emit:', e); } } } function _emitVizReady() { // Notify listeners that the custom renderer has fully initialised // and is actively drawing (its sync init returned, or its async // readyPromise resolved). App.js uses this to update the Auto // closed-state label only once the renderer is confirmed active. if (window.feedBack && typeof window.feedBack.emit === 'function') { try { window.feedBack.emit('viz:renderer:ready', {}); } catch (e) { console.error('viz:renderer:ready emit:', e); } } } // Resolve the contextType a renderer expects on the canvas before // its init() runs. Renderer factories may also declare // `factory.contextType = 'webgl2'` so app.js reads it without // constructing the renderer (used in Auto-mode evaluation to gate // WebGL2 renderers on _canRun3D()); here we receive the instance, // so we read the field off the instance itself. Absent → '2d'. The // built-in default renderer is always '2d'. function _resolveRendererContextType(r) { if (r === _defaultRenderer) return '2d'; if (r && typeof r.contextType === 'string' && r.contextType) { return r.contextType; } return '2d'; } // Logical identity of a renderer for swap detection: its viz id, not // its object reference. app.js stamps `pluginId`/`source` (the viz // picker id) onto every renderer it installs via _tagVizRenderer, and // setViz()/_autoMatchViz() build a FRESH renderer object with factory() // on every (re)apply — even when the selected viz did not change (e.g. // Auto re-resolving to the same viz across consecutive songs). Keying // the canvas-replacement decision on object identity would therefore // treat those benign re-installs as a swap and needlessly clone // #highway, dropping listeners/expando state attached to the element. // The default renderer has no id, so it keys on its own singleton; an // untagged custom renderer falls back to its object reference (the safe // "treat as a swap" answer). function _rendererVizKey(r) { if (r === _defaultRenderer) return _defaultRenderer; return (r && (r.pluginId || r.source)) || r; } // Replace the underlying element with a fresh one because // the next renderer needs a different context type than the current // canvas is locked to. Preserves the DOM position, id, classes, // data-* attributes, and inline style cssText so the surrounding // layout (CSS selectors, sibling overlays, splitscreen panels) is // unaffected. Plugins that re-query `getElementById('highway')` // lazily inside their own event handlers automatically pick up the // new element; longer-lived references can listen for the // `highway:canvas-replaced` event emitted at the end. function _replaceCanvas(newType) { if (!canvas) return; const oldCanvas = canvas; // cloneNode(false) preserves the element type and ALL HTML // attributes (id, class, style, data-*, aria-*, role, tabindex, // width/height attribute form, plus anything else a plugin // attached) without copying children — exactly what we need. // It does NOT copy event listeners or expando properties set // imperatively on the JS object (like `canvas._myCtx = gl` or // any plugin-attached data), so any bound rendering context is // left behind on the detached element — which is what allows the // new canvas to start fresh and accept a different getContext() // call. Note: canvas.width/height ARE reflected as HTML // attributes, so those values DO survive the clone; api.resize() // below re-applies the backing-store dimensions anyway. const newCanvas = oldCanvas.cloneNode(false); // Swap in place so siblings, parents, and document order all // stay intact. replaceWith() detaches the old node from the DOM. oldCanvas.replaceWith(newCanvas); canvas = newCanvas; // The default renderer caches its 2D context in the factory // closure (`ctx`). The old reference now points at a detached // canvas — null it so any straggling draw paths short-circuit // instead of painting into the void. The next default-renderer // init() will re-acquire `ctx` from the new canvas via // getContext('2d') (succeeds because the new element is fresh). ctx = null; // Re-size the new element to match the current container — // sets style.width/height and the backing-store width/height. // _renderer.resize is gated on _rendererInited, which has just // been cleared by _destroyCurrentIfInited, so api.resize() // here is a pure dimension update on the new element; the // renderer's own resize fires after init completes. try { api.resize(); } catch (e) { console.error('resize after canvas replace:', e); } _currentCanvasContextType = newType; // The visibility cache is per-canvas-instance: a freshly // attached canvas could be in a different displayed state // than the one it replaced, and _lastVisible would otherwise // suppress the first transition. Reset to null so the next // rAF tick re-emits unconditionally. _lastVisible = null; // Defensive notify for plugins / overlays that cache the // canvas element across events. Lazy lookups via // getElementById('highway') do not need this — they'll pick // up the new element on their next call. if (window.feedBack && typeof window.feedBack.emit === 'function') { try { window.feedBack.emit('highway:canvas-replaced', { oldCanvas, newCanvas, contextType: newType, }); } catch (e) { console.error('highway:canvas-replaced emit:', e); } } } function _setRenderer(r) { // Capture the outgoing renderer before _destroyCurrentIfInited / // the `_renderer = next` assignment below overwrite it — the // canvas-replacement decision needs to know whether we're // actually swapping to a *different* renderer instance. const prev = _renderer; _destroyCurrentIfInited(); // null/undefined reverts to default. Anything else must provide // at minimum a draw(bundle) function — without it the rAF loop // would throw every frame. Log once and fall back to default // rather than accepting a broken renderer. let next; if (r == null) { next = _defaultRenderer; } else if (typeof r.draw === 'function') { next = r; } else { console.error('setRenderer: renderer missing draw(bundle) function; reverting to default.'); next = _defaultRenderer; } _renderer = next; _rendererDrawFailures = 0; // Defer init/resize until the canvas is available. setRenderer // can legitimately be called before api.init() runs (e.g. app.js // restoring a saved picker selection at page load, before any // song has been played). api.init() will re-run these when it // assigns the canvas. if (!canvas) return; // Browsers lock a to the first context type // successfully acquired for its lifetime: once getContext('2d') // succeeds, getContext('webgl2') on the same element returns // null, and vice versa. When the renderer being installed // declares a different context type than the one already bound, // swap in a fresh so the new renderer's init() can // acquire its context cleanly. Previous renderer was already // destroyed by _destroyCurrentIfInited above, so it's safe to // detach the element from the DOM here. const nextType = _resolveRendererContextType(next); // Replace the underlying when (a) the new renderer needs // a different context type than the one currently bound, OR (b) // we're swapping to a *different* visualization while keeping the // same context type. Case (b) prevents a stale frame from the // previous renderer bleeding through: renderers that draw into a // sibling overlay (e.g. 3D Highway's `.h3d-wrap`) never paint // #highway, so whatever the *previous* renderer left on the shared // canvas would otherwise stay visible in any area the overlay does // not cover. The reported symptom was the 3D drum highway (which // renders directly onto #highway) showing through the gap below // the 3D guitar highway's overlay after switching between them — // both are webgl2, so the type-change check alone never fired. A // fresh canvas starts blank/transparent, so the incoming renderer // always begins over a clean surface. The swap test keys on the // viz id (_rendererVizKey), NOT object identity: setViz/_autoMatchViz // build a fresh renderer object on every apply, so an object-identity // check would clone #highway on every benign same-viz re-install // (e.g. Auto re-resolving to the same viz across songs) and drop the // element's listeners/expando state. Skipped when re-installing the // same viz, and on the very first install (prev === null) where // there is no prior frame to clear. const _vizChanged = prev && _rendererVizKey(next) !== _rendererVizKey(prev); if (nextType !== _currentCanvasContextType || _vizChanged) { _replaceCanvas(nextType); } const bundle = _makeBundle(); // A renderer without an init() function is treated as ready // by default (it simply has no setup to do). If an init() // exists, only flip the flag true when it returns without // throwing — otherwise a later destroy would run on an // effectively-uninitialized renderer. let initSucceeded = typeof _renderer.init !== 'function'; if (typeof _renderer.init === 'function') { try { _renderer.init(canvas, bundle); initSucceeded = true; } catch (e) { console.error('renderer init:', e); // Init may have partially allocated GPU/DOM resources // before throwing. Run destroy best-effort to release // whatever it got — renderer's destroy contract already // requires handling partial state gracefully. Then // revert to the default renderer so the user isn't // stranded on a broken viz, and notify the UI so the // picker + localStorage sync back to 'default'. if (_renderer !== _defaultRenderer) { if (typeof _renderer.destroy === 'function') { try { _renderer.destroy(); } catch (destroyErr) { console.error('renderer destroy after init failure:', destroyErr); } } _renderer = _defaultRenderer; _emitVizReverted('init-failure'); // The just-failed renderer may have already // acquired its (non-2D) context on the canvas // before throwing, locking the element to that // context type. Default renderer is 2D — swap in // a fresh canvas so its getContext('2d') succeeds. if (_currentCanvasContextType !== '2d') { _replaceCanvas('2d'); } if (typeof _renderer.init === 'function') { try { _renderer.init(canvas, _makeBundle()); initSucceeded = true; } catch (e2) { console.error('default renderer init after revert:', e2); } } else { initSucceeded = true; } } } } _rendererInited = initSucceeded; if (!_rendererInited) return; if (typeof _renderer.resize === 'function') { try { _renderer.resize(canvas.width, canvas.height); } catch (e) { console.error('renderer resize:', e); } } // Optional async-ready contract: if the renderer exposes a // `readyPromise`, it initialises asynchronously and the promise // settles when the renderer is actually drawing (resolve) or has // failed without throwing during sync init (reject). // On resolve → emit viz:renderer:ready so the UI can reflect the // confirmed active renderer. // On reject → revert to default, emit viz:reverted (same path as // a sync init failure) so the UI and Auto label sync. // If readyPromise is absent the sync init was all there was to do; // emit viz:renderer:ready immediately. const _installedRenderer = _renderer; if (_installedRenderer !== _defaultRenderer) { const rp = _installedRenderer.readyPromise; if (rp && typeof rp.then === 'function') { // Named handler for the rejection path so the async error // contract is readable at a glance without unwrapping a long // inline arrow function. function _handleAsyncInitFailure(e) { if (_renderer !== _installedRenderer) return; console.error('renderer async init failure:', e); _destroyCurrentIfInited(); _renderer = _defaultRenderer; _rendererDrawFailures = 0; _emitVizReverted('async-init-failure'); if (canvas) { // Async-init failure usually means the renderer // got far enough to acquire its (non-2D) context // before rejecting — the canvas is locked to // that type. Reverting to the 2D default // requires a fresh canvas element. if (_currentCanvasContextType !== '2d') { _replaceCanvas('2d'); } let defInitOk = typeof _defaultRenderer.init !== 'function'; if (typeof _defaultRenderer.init === 'function') { try { _defaultRenderer.init(canvas, _makeBundle()); defInitOk = true; } catch (e2) { console.error('default renderer init after async revert:', e2); } } _rendererInited = defInitOk; if (_rendererInited && typeof _defaultRenderer.resize === 'function') { try { _defaultRenderer.resize(canvas.width, canvas.height); } catch (e2) { console.error('default renderer resize after async revert:', e2); } } } } rp.then( () => { if (_renderer === _installedRenderer) _emitVizReady(); }, _handleAsyncInitFailure ); } else { _emitVizReady(); } } } // Visibility check (#246). offsetParent === null catches display:none // on the canvas OR any ancestor — the splitscreen case. Doesn't // catch visibility:hidden / opacity:0 / off-screen transforms; // hosts that need those use setVisible() instead. function _isHighwayVisible() { if (_visibleOverride !== null) return _visibleOverride; return !!(canvas && canvas.offsetParent !== null); } // Emit only on transition so renderer-side listeners aren't woken // every frame. The first call after init emits a transition from // null → boolean — that's the documented contract: "fired on // transitions including the first one." function _emitVisibilityIfChanged() { const v = _isHighwayVisible(); if (v === _lastVisible) return; _lastVisible = v; if (typeof window !== 'undefined' && window.feedBack && typeof window.feedBack.emit === 'function') { window.feedBack.emit('highway:visibility', { visible: v, canvas }); } } // Scale actually applied to the canvas + bundle.renderScale: the // user's manual ceiling times the load-adaptive factor, floored so a // pathological GPU can't drive it to zero. (#654) function _effectiveRenderScale() { // Sanitize each factor independently so one corrupt value doesn't // silently force full-res (which would ignore a valid manual ceiling) // or propagate NaN into canvas sizing. const user = Number.isFinite(_renderScale) ? _renderScale : 1; const auto = Number.isFinite(_autoScale) ? _autoScale : 1; // The adaptive floor can't exceed the user's manual ceiling — a minimum // resolution higher than the chosen maximum makes no sense — so clamp the // floor to `user` and never let the effective scale rise above the // ceiling (preserves the "Quality is the cap" semantics). const floor = Math.min(_autoScaleMin, user); return Math.min(user, Math.max(floor, user * auto)); } // Called once per drawn frame during active playback with the measured // cost of _renderer.draw(). Smooths the cost (EMA) and, on a cooldown, // nudges _autoScale down when the renderer blows the per-frame budget // and back up when it has headroom. A change re-applies via api.resize() // — the same path manual setRenderScale uses. (#654) function _adaptRenderScale(drawMs) { _drawMsEMA = _drawMsEMA === 0 ? drawMs : _drawMsEMA * 0.9 + drawMs * 0.1; const nowP = performance.now(); if (nowP - _lastAutoAdjustAt < _AUTO_ADJUST_COOLDOWN_MS) return; // Commit to one evaluation per cooldown regardless of outcome — // otherwise, once the cooldown elapses, the deadband branch below // would re-run this comparison every frame on the hot path. _lastAutoAdjustAt = nowP; const eff = _effectiveRenderScale(); let next = _autoScale; if (_drawMsEMA > _DRAW_BUDGET_HI_MS && eff > _autoScaleMin) { // Over budget — downscale promptly to protect the frame rate, and reset // the upscale clock so we don't immediately bounce back up. next = _autoScale * 0.85; _lastUpscaleAt = nowP; } else if (_drawMsEMA < _DRAW_BUDGET_LO_MS && eff < 1 && nowP - _lastUpscaleAt >= _AUTO_UPSCALE_COOLDOWN_MS) { // Headroom — upscale LAZILY: a smaller step on a longer cooldown, and // only when the projected cost AFTER the step (cost scales ~with the // pixel count, i.e. step²) still clears the high budget. That predictive // guard is what stops the up→over-budget→down ping-pong testers saw: the // scale settles just inside the deadband instead of oscillating across it. const step = 1.06; if (_drawMsEMA * step * step < _DRAW_BUDGET_HI_MS) { next = _autoScale * step; _lastUpscaleAt = nowP; } } // Clamp so _renderScale * _autoScale stays within [_autoScaleMin, 1]. // Cap `lo` at 1: when the floor exceeds the manual ceiling (e.g. quality // 0.5 + floor 1.0) the raw ratio is > 1, which would otherwise push // _autoScale above 1 and break the "auto is a [0,1] multiplier" invariant. const lo = _renderScale > 0 ? Math.min(1, _autoScaleMin / _renderScale) : 1; next = Math.max(lo, Math.min(1, next)); if (Math.abs(next - _autoScale) < 0.01) return; _autoScale = next; try { api.resize(); } catch (e) { /* resize is best-effort */ } } // Optional on-screen perf readout (#654), gated on // localStorage.highwayPerfHud === '1'. Lets a reporter confirm the // adaptive cap is holding without opening devtools. No-op (and tears // down any existing HUD) when the flag is off. function _updatePerfHud() { if (typeof document === 'undefined' || !document.body) return; const nowP = performance.now(); // Re-read the debug-only flag at most ~2x/sec, not every frame: // localStorage access is synchronous and this is on the rAF hot path. if (nowP - _hudFlagAt > 500) { _hudFlagAt = nowP; try { _hudOn = localStorage.getItem('highwayPerfHud') === '1'; } catch (_) { _hudOn = false; } } if (!_hudOn) { if (_perfHud) { _perfHud.remove(); _perfHud = null; } return; } if (_lastFramePerf) { const d = nowP - _lastFramePerf; _frameMsEMA = _frameMsEMA === 0 ? d : _frameMsEMA * 0.9 + d * 0.1; } _lastFramePerf = nowP; if (!_perfHud) { _perfHud = document.createElement('div'); // Class, not a fixed id: multiple createHighway() instances // (splitscreen) would otherwise mint duplicate #ids — invalid // HTML. We hold the element by reference (_perfHud), not lookup. _perfHud.className = 'highway-perf-hud'; _perfHud.style.cssText = 'position:fixed;top:8px;right:8px;z-index:2147483647;' + 'font:11px/1.4 monospace;background:rgba(0,0,0,.7);color:#0f0;' + 'padding:4px 6px;border-radius:4px;pointer-events:none;white-space:pre;'; document.body.appendChild(_perfHud); } const fps = _frameMsEMA > 0 ? 1000 / _frameMsEMA : 0; _perfHud.textContent = 'fps ' + fps.toFixed(0) + ' draw ' + _drawMsEMA.toFixed(1) + 'ms' + ' scale ' + _effectiveRenderScale().toFixed(2) + ' (user ' + _renderScale.toFixed(2) + ' / auto ' + _autoScale.toFixed(2) + ')'; } function draw() { animFrame = requestAnimationFrame(draw); if (!canvas || !_renderer) return; _frameIdx = (_frameIdx + 1) | 0; // Visibility-aware skip (#246). Run BEFORE the !ready bail so // hide/show transitions during the loading / reconnect window // still propagate to listeners (a splitscreen-driven hide that // straddles a song change would otherwise leave 3D Highway's // overlay visible across the not-ready frames). _emitVisibilityIfChanged(); // Decide once whether this frame renders, and reuse it for both the // perf-HUD reset and the draw gate. `_lastVisible` going false has two // distinct causes that differ for a CUSTOM renderer painting its own // surface (feedBack#819): // • override-hide (`_visibleOverride === false`) — a renderer called // `setVisible(false)` because an opaque overlay covers the // *canvas*. The `highway:visibility` event already fired above so // sibling overlay renderers (3D Highway's `.h3d-wrap`) pause their // own loops, but the ACTIVE custom renderer that triggered it is // still painting its own visible surface (e.g. Tab View's alphaTab // DOM over the hidden canvas) and must keep getting draw(). The // default 2D renderer — whose canvas IS the occluded surface — is // not exempted. // • genuine off-screen (`canvas.offsetParent === null`) — navigate- // away or a `display:none` splitscreen panel. Nothing is on // screen, so pause everything (#246/#654) even an active custom // renderer, even if an override-hide is also in effect. // Previously the gate was a bare `if (!_lastVisible) return`, which // starved an active custom renderer's draw() on an override-hide — the // Tab View cursor froze in single-player (feedBack#734). let _rendering = _lastVisible; if (!_rendering && _visibleOverride === false && _renderer !== _defaultRenderer && canvas && canvas.offsetParent !== null) { _rendering = true; } // Don't let the debug HUD strand on screen while we're not actively // rendering (hidden, or WS not ready). It re-creates next frame once // rendering resumes and the flag is still on. (#654) if (_perfHud && (!_rendering || !ready)) { _perfHud.remove(); _perfHud = null; } if (!_rendering) return; // Match pre-refactor behaviour: skip draw until WS ready fires. // This gates out the brief "arrays cleared, WS reconnecting" // window during playSong / reconnect. Renderers that want to // draw a loading state can still opt in via the `isReady` // field on the bundle passed to a custom pre-ready handler — // we'd need to widen the contract to support that, out of // scope here. Default 2D renderer also checks `ready` in its // draw body (defence in depth). if (!ready) return; // Playback-aware throttle (#654). Reuse getTime()'s pause // signal: once an anchor exists, chartTime not advancing for // > _CHART_MAX_INTERP_MS means audio is paused/stalled (the // 60 Hz tick in app.js keeps calling setTime() with the same t // while paused). While paused, draw at most once per // _PAUSED_FRAME_INTERVAL_MS so a heavy WebGL renderer stops // pinning the GPU re-rendering a static frame. Active playback // (clock advancing) is never throttled. let _paused = false; if (!Number.isNaN(_chartAnchorPerfNow)) { const _nowP = performance.now(); if (_nowP - _chartLastAdvanceAt > _CHART_MAX_INTERP_MS) { _paused = true; if (_nowP - _lastPausedDrawAt < _PAUSED_FRAME_INTERVAL_MS) return; _lastPausedDrawAt = _nowP; } } // Skip bundle allocation when the default renderer is active — // it reads closure state directly and ignores the bundle. // _makeBundle at 60fps was a steady GC churn for the common // case where no custom renderer is installed. const bundle = _renderer === _defaultRenderer ? undefined : _makeBundle(); try { const _drawStart = performance.now(); _renderer.draw(bundle); _rendererDrawFailures = 0; // Adaptive render-scale (#654): only adapt during active // playback — paused frames are throttled above, so their // timing isn't representative of the playback workload. if (!_paused) _adaptRenderScale(performance.now() - _drawStart); _updatePerfHud(); } catch (e) { _rendererDrawFailures += 1; console.error('renderer draw:', e); // Self-heal: a plugin whose draw() throws every frame // would otherwise spam the console and leave the canvas // blank indefinitely. After a short streak of failures, // revert to the built-in renderer so the user at least // gets the default highway back. 2D default is known-safe. if (_rendererDrawFailures >= MAX_RENDERER_DRAW_FAILURES && _renderer !== _defaultRenderer) { console.error( 'renderer draw: failed ' + _rendererDrawFailures + ' frames in a row; reverting to default renderer.' ); _setRenderer(_defaultRenderer); _emitVizReverted('draw-failure'); } } } function drawHighway(W, H) { const strips = 40; for (let i = 0; i < strips; i++) { const t0 = (i / strips) * VISIBLE_SECONDS; const t1 = ((i + 1) / strips) * VISIBLE_SECONDS; const p0 = project(t0), p1 = project(t1); if (!p0 || !p1) continue; const hw0 = W * 0.26 * p0.scale; const hw1 = W * 0.26 * p1.scale; const bright = 18 + 10 * p0.scale; ctx.fillStyle = `rgb(${bright|0},${bright|0},${(bright+14)|0})`; ctx.beginPath(); ctx.moveTo(W/2 - hw0, p0.y * H); ctx.lineTo(W/2 + hw0, p0.y * H); ctx.lineTo(W/2 + hw1, p1.y * H); ctx.lineTo(W/2 - hw1, p1.y * H); ctx.fill(); } } function drawFretLines(W, H) { const pad = 3; const lo = 0; const hi = Math.ceil(displayMaxFret); ctx.strokeStyle = '#2d2d45'; ctx.lineWidth = 1; for (let fret = lo; fret <= hi; fret++) { if (fret < 0) continue; ctx.beginPath(); for (let i = 0; i <= 40; i++) { const t = (i / 40) * VISIBLE_SECONDS; const p = project(t); if (!p) continue; const x = fretX(fret, p.scale, W); if (i === 0) ctx.moveTo(x, p.y * H); else ctx.lineTo(x, p.y * H); } ctx.stroke(); } } function drawBeats(W, H) { for (const beat of beats) { const tOff = beat.time - currentTime; const p = project(tOff); if (!p || p.scale < 0.06) continue; const hw = W * 0.26 * p.scale; const isMeasure = beat.measure >= 0; ctx.strokeStyle = isMeasure ? '#343450' : '#202038'; ctx.lineWidth = isMeasure ? 2 : 1; ctx.beginPath(); ctx.moveTo(W/2 - hw, p.y * H); ctx.lineTo(W/2 + hw, p.y * H); ctx.stroke(); } } function drawStrings(W, H) { const strTop = H * 0.83; const strBot = H * 0.95; const margin = W * 0.03; // Adapt to the active arrangement's string count: 4 for bass, // 6 for guitar, 7+ for extended-range GP imports. The visible // band [strTop..strBot] gets divided into (stringCount - 1) // slots, so 4 strings spread across the full band rather than // using the upper 4/6ths of the 6-string layout. The Math.max // guards against a hypothetical 1-string instrument (denom=0). const span = Math.max(1, stringCount - 1); for (let i = 0; i < stringCount; i++) { const yi = _inverted ? (stringCount - 1 - i) : i; const y = strTop + (yi / span) * (strBot - strTop); ctx.strokeStyle = STRING_COLORS[i] || '#888'; ctx.lineWidth = 3; ctx.beginPath(); ctx.moveTo(margin, y); ctx.lineTo(W - margin, y); ctx.stroke(); } } function drawNowLine(W, H) { const y = H * 0.82; const hw = W * 0.26; // Glow for (let i = 1; i < 5; i++) { const a = Math.max(0, 70 - i * 15); ctx.strokeStyle = `rgba(${a},${a},${a+8},1)`; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(W/2 - hw, y - i); ctx.lineTo(W/2 + hw, y - i); ctx.stroke(); ctx.beginPath(); ctx.moveTo(W/2 - hw, y + i); ctx.lineTo(W/2 + hw, y + i); ctx.stroke(); } ctx.strokeStyle = '#dce0f0'; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(W/2 - hw, y); ctx.lineTo(W/2 + hw, y); ctx.stroke(); } function drawNote(W, H, x, y, scale, string, fret, opts, ns) { // ns (feedBack#254): normalized judgment state from _noteState, // or null/undefined. `lit` means render the gem in the bright // string colour with an additive halo; a miss gets a faint red // wash instead. ns absent → byte-for-byte the original render. const lit = !!(ns && ns.state !== 'miss'); const isHarmonic = opts?.hm || opts?.hp || false; const isPinchHarmonic = opts?.hp || false; const isChord = opts?.chord || false; const bend = opts?.bn || 0; const slide = opts?.sl ?? -1; // pitched slide-to fret (-1 = none; 0 = slide to open) const slu = opts?.slu ?? -1; // unpitched slide-to fret (-1 = none) const hammerOn = opts?.ho || false; const pullOff = opts?.po || false; const tap = opts?.tp || false; const palmMute = opts?.pm || false; const tremolo = opts?.tr || false; const accent = opts?.ac || false; const sz = Math.max(12, 80 * scale * (H / 900)); const half = sz / 2; // When lit, bump the body one step brighter and the backing-glow // one step up from STRING_DIM, so even shapes that don't get the // _paintGemGlow halo (the open-string bar) read as "lit". const color = lit ? (ns.color || STRING_BRIGHT[string] || STRING_COLORS[string] || '#888') : (STRING_COLORS[string] || '#888'); const dark = lit ? (STRING_COLORS[string] || '#666') : (STRING_DIM[string] || '#222'); if (sz < 6) { ctx.fillStyle = color; ctx.beginPath(); ctx.arc(x, y, 2, 0, Math.PI * 2); ctx.fill(); return; } // Open string: wide bar spanning the highway (only for standalone notes) if (fret === 0 && !isChord) { const hw = W * 0.26 * scale; const barH = Math.max(6, sz * 0.45); // Shadow ctx.fillStyle = dark; roundRect(ctx, W/2 - hw - 1, y - barH/2 - 1, hw * 2 + 2, barH + 2, 3); ctx.fill(); // Body ctx.fillStyle = color; roundRect(ctx, W/2 - hw, y - barH/2, hw * 2, barH, 2); ctx.fill(); // Judgment glow (feedBack#254) — central halo on the bar. // _paintGemGlow takes a half-extent; barH is the full bar height. _paintGemGlow(W/2, y, barH * 0.5, string, ns); // "0" label const fontSize = Math.max(8, sz * 0.5) | 0; ctx.fillStyle = '#fff'; ctx.font = `bold ${fontSize}px sans-serif`; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; fillTextReadable('0', W/2, y); // Technique labels on open strings — PM, H/P/T, tremolo, and // accent markers are all meaningful on fret 0. Bend and slide // are omitted because they reference a fret position that the // centered bar doesn't visually convey. Matches the sz<14 gate // the fretted path uses so labels don't render on tiny bars. // Fixes #21. if (sz >= 14) { // H / P / T above if (hammerOn || pullOff || tap) { const label = tap ? 'T' : (hammerOn ? 'H' : 'P'); ctx.fillStyle = '#fff'; ctx.font = `bold ${Math.max(9, sz * 0.3) | 0}px sans-serif`; ctx.textAlign = 'center'; ctx.textBaseline = 'bottom'; fillTextReadable(label, W/2, y - barH/2 - 4); } // PM below if (palmMute) { ctx.fillStyle = '#aaa'; ctx.font = `bold ${Math.max(8, sz * 0.25) | 0}px sans-serif`; ctx.textAlign = 'center'; ctx.textBaseline = 'top'; fillTextReadable('PM', W/2, y + barH/2 + 2); } // Tremolo (wavy line above) if (tremolo) { const ty = y - barH/2 - 6; ctx.strokeStyle = '#ff0'; ctx.lineWidth = 1.5; ctx.beginPath(); for (let i = -3; i <= 3; i++) { const wx = W/2 + i * sz * 0.08; const wy = ty + Math.sin(i * 2) * 3; if (i === -3) ctx.moveTo(wx, wy); else ctx.lineTo(wx, wy); } ctx.stroke(); } // Accent caret above if (accent) { const ay2 = y - barH/2 - 4; ctx.strokeStyle = '#fff'; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(W/2 - sz * 0.2, ay2 + 3); ctx.lineTo(W/2, ay2 - 2); ctx.lineTo(W/2 + sz * 0.2, ay2 + 3); ctx.stroke(); } } return; } if (isHarmonic) { // Diamond shape for harmonics const dh = half * 1.15; // Glow ctx.fillStyle = dark; ctx.beginPath(); ctx.moveTo(x, y - dh - 3); ctx.lineTo(x + half + 3, y); ctx.lineTo(x, y + dh + 3); ctx.lineTo(x - half - 3, y); ctx.closePath(); ctx.fill(); // Body ctx.fillStyle = color; ctx.beginPath(); ctx.moveTo(x, y - dh); ctx.lineTo(x + half, y); ctx.lineTo(x, y + dh); ctx.lineTo(x - half, y); ctx.closePath(); ctx.fill(); // Bright outline ctx.strokeStyle = STRING_BRIGHT[string] || '#fff'; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(x, y - dh); ctx.lineTo(x + half, y); ctx.lineTo(x, y + dh); ctx.lineTo(x - half, y); ctx.closePath(); ctx.stroke(); // PH label for pinch harmonics if (isPinchHarmonic && sz >= 14) { ctx.fillStyle = '#ff0'; ctx.font = `bold ${Math.max(8, sz * 0.25) | 0}px sans-serif`; ctx.textAlign = 'center'; ctx.textBaseline = 'top'; fillTextReadable('PH', x, y + dh + 2); } } else { // Glow ctx.fillStyle = dark; roundRect(ctx, x - half - 4, y - half - 4, sz + 8, sz + 8, sz / 3); ctx.fill(); // Body ctx.fillStyle = color; roundRect(ctx, x - half, y - half, sz, sz, sz / 5); ctx.fill(); } // Judgment glow (feedBack#254) — additive halo for a correct // hit / held sustain, faint red wash for a miss. Drawn before // the fret number so the number stays legible on top. _paintGemGlow(x, y, isHarmonic ? half * 1.2 : half, string, ns); // Fret number const fontSize = Math.max(10, sz * 0.5) | 0; ctx.fillStyle = '#fff'; ctx.font = `bold ${fontSize}px sans-serif`; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; fillTextReadable(String(fret), x, y); // Bend notation if (bend && bend > 0 && sz >= 12) { const lw = Math.max(2, sz / 10); const ay = y - half - 4; // px above the gem for a bend of `v` semitones (shared by the // curve contour and the scalar-arrow fallback). const hOf = (v) => sz * 0.55 * Math.min(Math.max(v, 0), 2); const bnv = Array.isArray(opts?.bnv) ? opts.bnv : null; ctx.strokeStyle = '#fff'; ctx.lineWidth = lw; let labelTopY; // y of the highest drawn point, for the label if (bnv && bnv.length >= 2) { // Bend curve (§6.2.1): trace the real shape as a contour above // the gem (round-trip rises then falls, pre-bend starts high, // release descends, …) — `bt` is implicit in the point shape. const pts = bnvNormalizedPoints(bnv, opts?.sus); const gw = sz * 0.6; const x0 = x - gw / 2; ctx.beginPath(); pts.forEach((pt, i) => { const px = x0 + pt.x * gw; const py = ay - hOf(pt.v); if (i === 0) ctx.moveTo(px, py); else ctx.lineTo(px, py); }); ctx.stroke(); // Arrowhead only when the gesture ends rising (plain bend / // pre-bend); round-trip and release finish heading down. const a = pts[pts.length - 2], b = pts[pts.length - 1]; if (b.v > a.v + 0.05) { const tipX = x0 + b.x * gw, tipY = ay - hOf(b.v); ctx.beginPath(); ctx.moveTo(tipX - sz * 0.1, tipY + sz * 0.12); ctx.lineTo(tipX, tipY); ctx.lineTo(tipX + sz * 0.1, tipY + sz * 0.12); ctx.stroke(); } labelTopY = ay - hOf(Math.max(...pts.map(p => p.v))); } else { // Fallback: single curved arrow up to the scalar peak. const arrowH = hOf(bend); // taller for bigger bends const tipY = ay - arrowH; ctx.beginPath(); ctx.moveTo(x, ay); ctx.quadraticCurveTo(x + sz * 0.2, ay - arrowH * 0.5, x, tipY); ctx.stroke(); ctx.beginPath(); ctx.moveTo(x - sz * 0.12, tipY + sz * 0.12); ctx.lineTo(x, tipY); ctx.lineTo(x + sz * 0.12, tipY + sz * 0.12); ctx.stroke(); labelTopY = tipY; } // Bend label: peak magnitude — "full", "1/2", "1 1/2", "2" let label; if (bend === 0.5) label = '½'; else if (bend === 1) label = 'full'; else if (bend === 1.5) label = '1½'; else if (bend === 2) label = '2'; else label = bend.toFixed(1); ctx.fillStyle = '#fff'; ctx.font = `bold ${Math.max(9, sz * 0.28) | 0}px sans-serif`; ctx.textAlign = 'center'; ctx.textBaseline = 'bottom'; fillTextReadable(label, x, labelTopY - 2); } if (sz < 14) return; // Skip small technique labels // Teaching marks (§6.2.2) — display only, never grading. The fret-hand // finger (fg) renders by default as a small numeral hugging the gem's // right edge (T = thumb, 1..4), hideable via the finger-hints toggle; the // scale degree (sd) is opt-in and sits on the left edge so the two never // collide with the centred fret number. const fgLabel = _showFingerHints ? teachingFingerLabel(opts?.fg) : ''; if (fgLabel) { ctx.fillStyle = '#7fd1ff'; ctx.font = `bold ${Math.max(8, sz * 0.26) | 0}px sans-serif`; ctx.textAlign = 'left'; ctx.textBaseline = 'middle'; fillTextReadable(fgLabel, x + half + 2, y + half * 0.5); } if (_showTeachingMarks) { const sdLabel = teachingDegreeLabel(opts?.sd); if (sdLabel) { ctx.fillStyle = '#ffcc66'; ctx.font = `bold ${Math.max(8, sz * 0.26) | 0}px sans-serif`; ctx.textAlign = 'right'; ctx.textBaseline = 'middle'; fillTextReadable(sdLabel, x - half - 2, y + half * 0.5); } } // Slide indicator (diagonal arrow). Pitched (sl) draws a solid arrow to // the target fret; unpitched (slu) draws a dashed diagonal with no // arrowhead (no definite target pitch). The two are mutually exclusive // in the data; the 3D highway makes the same pitched/unpitched split. if (slide >= 0 || slu >= 0) { const pitched = slide >= 0; const target = pitched ? slide : slu; const dir = target > fret ? -1 : 1; // up or down the neck; mirror handles lefty ctx.strokeStyle = '#fff'; ctx.lineWidth = Math.max(2, sz / 10); if (!pitched) ctx.setLineDash([Math.max(2, sz / 8), Math.max(2, sz / 8)]); ctx.beginPath(); ctx.moveTo(x - sz * 0.3, y + dir * sz * 0.3); ctx.lineTo(x + sz * 0.3, y - dir * sz * 0.3); ctx.stroke(); if (!pitched) ctx.setLineDash([]); // Arrowhead only for a pitched slide (definite target pitch). if (pitched) { ctx.beginPath(); ctx.moveTo(x + sz * 0.3, y - dir * sz * 0.3); ctx.lineTo(x + sz * 0.15, y - dir * sz * 0.15); ctx.stroke(); } } // H/P/T label above note if (hammerOn || pullOff || tap) { const label = tap ? 'T' : (hammerOn ? 'H' : 'P'); const ly = y - half - (bend > 0 ? sz * 0.6 : 4); ctx.fillStyle = '#fff'; ctx.font = `bold ${Math.max(9, sz * 0.3) | 0}px sans-serif`; ctx.textAlign = 'center'; ctx.textBaseline = 'bottom'; fillTextReadable(label, x, ly); } // Palm mute (PM below note) if (palmMute) { ctx.fillStyle = '#aaa'; ctx.font = `bold ${Math.max(8, sz * 0.25) | 0}px sans-serif`; ctx.textAlign = 'center'; ctx.textBaseline = 'top'; fillTextReadable('PM', x, y + half + 2); } // Tremolo (wavy line above) if (tremolo) { const ty = y - half - (bend > 0 ? sz * 0.7 : 6); ctx.strokeStyle = '#ff0'; ctx.lineWidth = 1.5; ctx.beginPath(); for (let i = -3; i <= 3; i++) { const wx = x + i * sz * 0.08; const wy = ty + Math.sin(i * 2) * 3; if (i === -3) ctx.moveTo(wx, wy); else ctx.lineTo(wx, wy); } ctx.stroke(); } // Accent (> marker) if (accent) { const ay2 = y - half - 4; ctx.strokeStyle = '#fff'; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(x - sz * 0.2, ay2 + 3); ctx.lineTo(x, ay2 - 2); ctx.lineTo(x + sz * 0.2, ay2 + 3); ctx.stroke(); } } function drawSustains(W, H) { // Same master-difficulty fallback as drawNotes/drawChords — // without this, sustain bars for filtered-out notes would // still render, leaving orphan rectangles where no note head // is drawn. const src = _filteredNotes !== null ? _filteredNotes : notes; for (const n of src) { if (n.sus <= 0.01) continue; const end = n.t + n.sus; if (end < currentTime || n.t > currentTime + VISIBLE_SECONDS) continue; const t0 = Math.max(n.t - currentTime, 0); const t1 = Math.min(end - currentTime, VISIBLE_SECONDS); if (t0 >= t1) continue; const p0 = project(t0), p1 = project(t1); if (!p0 || !p1) continue; const x0 = fretX(n.f, p0.scale, W); const x1 = fretX(n.f, p1.scale, W); const sw0 = Math.max(2, 6 * p0.scale); const sw1 = Math.max(2, 6 * p1.scale); // feedBack#254 — a sustain that's currently being held // correctly "sizzles" in the bright string colour (glow + // flickering brightness + a crackling current down the // middle); otherwise the usual dim trail. A miss is left dim // (the gem / overlay marks the miss; a red trail would be // noisy). Skip the lookup entirely when no provider is set — // zero cost in the hot loop for the common case. const ns = _noteStateProvider ? _noteState(n, n.t) : null; const litTrail = !!(ns && ns.state !== 'miss'); const y0 = p0.y * H, y1 = p1.y * H; if (litTrail) { const a = ns.alpha; const col = ns.color || STRING_BRIGHT[n.s] || STRING_COLORS[n.s] || '#666'; // Per-note seed so neighbouring sustains shimmer // independently. Math.floor(n.t * 60) is stable across // frames yet drifts on song progression; combined with // _frameIdx + n.s it gives a non-correlated walk through // the LUT, matching the original visual intent // (feedBack#254 comment above). const seedBase = (_frameIdx + n.s + ((n.t * 60) | 0)) | 0; ctx.save(); ctx.fillStyle = col; ctx.shadowColor = col; ctx.shadowBlur = (8 + 6 * _shimmerNoise(seedBase)) * a; // shimmering glow ctx.globalAlpha = (0.45 + 0.45 * a) * (0.78 + 0.22 * _shimmerNoise(seedBase + 17)); ctx.beginPath(); ctx.moveTo(x0 - sw0, y0); ctx.lineTo(x0 + sw0, y0); ctx.lineTo(x1 + sw1, y1); ctx.lineTo(x1 - sw1, y1); ctx.fill(); // Crackling "current" — a jittery white core line down // the trail, re-randomised each frame. ctx.shadowBlur = 0; ctx.globalCompositeOperation = 'lighter'; ctx.globalAlpha = a * (0.55 + 0.45 * _shimmerNoise(seedBase + 31)); ctx.strokeStyle = '#ffffff'; ctx.lineWidth = Math.max(1.5, sw0 * 0.5); ctx.lineJoin = 'round'; ctx.lineCap = 'round'; ctx.beginPath(); const segs = 7; for (let k = 0; k <= segs; k++) { const f = k / segs; const jx = (k === 0 || k === segs) ? 0 : (_shimmerNoise(seedBase + 47 + k) - 0.5) * sw0 * 2.2; const xx = x0 + (x1 - x0) * f + jx; const yy = y0 + (y1 - y0) * f; if (k === 0) ctx.moveTo(xx, yy); else ctx.lineTo(xx, yy); } ctx.stroke(); ctx.restore(); } else { ctx.fillStyle = STRING_DIM[n.s] || '#333'; ctx.beginPath(); ctx.moveTo(x0 - sw0, y0); ctx.lineTo(x0 + sw0, y0); ctx.lineTo(x1 + sw1, y1); ctx.lineTo(x1 - sw1, y1); ctx.fill(); } } } function drawNotes(W, H) { // Master-difficulty filter (feedBack#48): when the source had // phrase-level ladder data, render from the mastery-filtered // array. _filteredNotes stays null for slider-disabled sources // so rendering falls through to the flat notes array unchanged. const src = _filteredNotes !== null ? _filteredNotes : notes; // Binary search for visible range const tMin = currentTime - 0.25; const tMax = currentTime + VISIBLE_SECONDS; let lo = bsearch(src, tMin); let hi = bsearch(src, tMax); // Include sustained notes while (lo > 0 && src[lo-1].t + src[lo-1].sus > currentTime) lo--; // Collect drawn positions for unison bend detection const drawnNotes = []; for (let i = hi - 1; i >= lo; i--) { const n = src[i]; let tOff = n.t - currentTime; // Hold sustained notes at now line let p; if (tOff < -0.05 && n.sus > 0 && n.t + n.sus > currentTime) { p = { y: 0.82, scale: 1.0 }; } else { p = project(tOff); } if (!p) continue; const x = fretX(n.f, p.scale, W); drawNote(W, H, x, p.y * H, p.scale, n.s, n.f, n, _noteStateProvider ? _noteState(n, n.t) : null); drawnNotes.push({ t: n.t, s: n.s, f: n.f, bn: n.bn || 0, x, y: p.y * H, scale: p.scale, ch: Number.isInteger(n.ch) ? n.ch : -1, pkd: Number.isInteger(n.pkd) ? n.pkd : -1, }); } // Draw unison bend connectors drawUnisonBends(W, H, drawnNotes); // Strum-group brackets (teaching mark ch, §6.2.2) — opt-in overlay. // Scoped to standalone notes (the stream drawNotes renders); chord-note // strum groups aren't bracketed (the editor authors ch over single-note // selections, and chord notes already read as one simultaneous gesture). if (_showTeachingMarks) drawStrumGroups(W, H, drawnNotes); } function drawStrumGroups(W, H, drawnNotes) { // Teaching mark (§6.2.2): notes sharing a `ch` key >= 0 are one // strum/rake gesture. Connect each group's gems with a bracket and a // single arrowhead whose direction comes from `pkd` (0 = down-strum, // 1 = up-strum). Display only — never grading. for (const group of strumGroupBuckets(drawnNotes)) { const pts = group.slice().sort((a, b) => a.y - b.y || a.x - b.x); const scale = pts[0].scale; const sz = Math.max(12, 80 * scale * (H / 900)); if (sz < 14) continue; const pkd = (group.find(p => p.pkd === 0 || p.pkd === 1) || {}).pkd; ctx.save(); ctx.strokeStyle = '#c89bff'; ctx.lineWidth = Math.max(2, sz / 12); ctx.lineJoin = 'round'; ctx.beginPath(); pts.forEach((p, i) => (i === 0 ? ctx.moveTo(p.x, p.y) : ctx.lineTo(p.x, p.y))); ctx.stroke(); // Arrowhead at the gesture start: down-strum (pkd 0) points toward // the last gem, up-strum (pkd 1) toward the first. if (pkd === 0 || pkd === 1) { const head = pkd === 1 ? pts[0] : pts[pts.length - 1]; const from = pkd === 1 ? pts[1] : pts[pts.length - 2]; const dy = Math.sign(head.y - from.y) || 1; const a = sz * 0.18; ctx.beginPath(); ctx.moveTo(head.x - a, head.y - dy * a); ctx.lineTo(head.x, head.y); ctx.lineTo(head.x + a, head.y - dy * a); ctx.stroke(); } ctx.restore(); } } function drawUnisonBends(W, H, drawnNotes) { // Group notes by time (within 0.01s tolerance) const groups = []; const used = new Set(); for (let i = 0; i < drawnNotes.length; i++) { if (used.has(i)) continue; const group = [drawnNotes[i]]; used.add(i); for (let j = i + 1; j < drawnNotes.length; j++) { if (used.has(j)) continue; if (Math.abs(drawnNotes[j].t - drawnNotes[i].t) < 0.01) { group.push(drawnNotes[j]); used.add(j); } } if (group.length >= 2) groups.push(group); } for (const group of groups) { // Find pairs: one with bend, one without (or both with different bends) const bent = group.filter(n => n.bn > 0); const unbent = group.filter(n => n.bn === 0); if (bent.length === 0 || unbent.length === 0) continue; // Draw connector between each bent-unbent pair for (const bn of bent) { // Find the closest unbent note by string let closest = unbent[0]; for (const ub of unbent) { if (Math.abs(ub.s - bn.s) < Math.abs(closest.s - bn.s)) closest = ub; } const sz = Math.max(12, 80 * bn.scale * (H / 900)); if (sz < 14) continue; // Draw a curved dashed line connecting bent note to target note const x1 = bn.x, y1 = bn.y; const x2 = closest.x, y2 = closest.y; const midX = (x1 + x2) / 2 + sz * 0.5; const midY = (y1 + y2) / 2; ctx.save(); ctx.strokeStyle = '#60d0ff'; ctx.lineWidth = Math.max(2, sz / 12); ctx.setLineDash([4, 4]); ctx.beginPath(); ctx.moveTo(x1, y1); ctx.quadraticCurveTo(midX, midY, x2, y2); ctx.stroke(); ctx.setLineDash([]); ctx.restore(); // "U" label at midpoint const labelSz = Math.max(10, sz * 0.3) | 0; ctx.fillStyle = '#60d0ff'; ctx.font = `bold ${labelSz}px sans-serif`; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; const cpX = (x1 + 2 * midX + x2) / 4; const cpY = (y1 + 2 * midY + y2) / 4; fillTextReadable('U', cpX + sz * 0.3, cpY); } } } function drawChords(W, H) { // See drawNotes — _filteredChords is null for slider-disabled // sources so we fall through to the flat chords array. const src = _filteredChords !== null ? _filteredChords : chords; _ensureChordRenderCache(src); const tMin = currentTime - 0.25; const tMax = currentTime + VISIBLE_SECONDS; const lo = bsearchChords(src, tMin); const hi = bsearchChords(src, tMax); _updateFretLinePreview(src, lo, hi); _drawFretLineChordPreview(W, H); for (let i = hi - 1; i >= lo; i--) { const ch = src[i]; const p = project(ch.t - currentTime); if (!p) continue; const info = _chordRenderInfo.get(ch); const { isFull, baseFret, sortedNotes: sorted, nonZeroNotes, nonZeroFrets, allMuted, hasMultipleNotes } = info; const sz = Math.max(10, 28 * p.scale * (H / 900)); const spread = sz * 0.85; const minSpread = sz + 16 * p.scale; const actualSpread = Math.max(spread, minSpread); const actualTotalH = actualSpread * Math.max(0, sorted.length - 1); const { tmpl, getTemplateFret } = getChordTemplateInfo(ch.id, chordTemplates); const hasNonZero = nonZeroNotes.length >= 1; const frameLeftFret = baseFret; const frameRightFret = baseFret + CHORD_FRAME_FRETS; // Frame validation — log once per chord id rather than every frame. if (hasNonZero && !_frameMismatchWarned.has(ch.id)) { let notesInFrame = true; for (let k = 0; k < nonZeroFrets.length; k++) { const f = nonZeroFrets[k]; if (f < frameLeftFret || f > frameRightFret) { notesInFrame = false; break; } } if (!notesInFrame) { _frameMismatchWarned.add(ch.id); console.warn('Chord frame mismatch:', ch.id, { frameLeftFret, frameRightFret, nonZeroFrets }); } } // X span between fretted notes (excluding open strings) — // single pass over cached nonZeroFrets, no spread + Math.min/max. let xMin = null, xMax = null; if (hasNonZero) { xMin = Infinity; xMax = -Infinity; for (let k = 0; k < nonZeroFrets.length; k++) { const x = fretX(nonZeroFrets[k], p.scale, W); if (x < xMin) xMin = x; if (x > xMax) xMax = x; } } if (allMuted) { const { boxX, boxW, boxTop, boxH } = _computeChordBox(p, H, W, sorted, sz, actualSpread, baseFret); ctx.strokeStyle = MUTE_BOX_STROKE; ctx.lineWidth = Math.max(2, sz / 6); roundRect(ctx, boxX, boxTop, boxW, boxH, 2); ctx.stroke(); ctx.fillStyle = MUTE_BOX_BAR; ctx.fillRect(boxX, boxTop + 2, boxW, 4); // Gray X cross, centered in frame const xInset = sz * 0.6; const xStartX = boxX + xInset; const xEndX = boxX + boxW - xInset; ctx.beginPath(); ctx.moveTo(xStartX, boxTop + sz * 0.5); ctx.lineTo(xEndX, boxTop + boxH - sz * 0.5); ctx.moveTo(xEndX, boxTop + sz * 0.5); ctx.lineTo(xStartX, boxTop + boxH - sz * 0.5); ctx.stroke(); continue; } // Repeat chord (mid-chain): translucent box + bracket bar. if (!isFull) { const { boxX, boxW, boxTop, boxH } = _computeChordBox(p, H, W, sorted, sz, actualSpread, baseFret); ctx.fillStyle = REPEAT_BOX_FILL; roundRect(ctx, boxX, boxTop, boxW, boxH, 2); ctx.fill(); ctx.fillStyle = REPEAT_BOX_BAR; ctx.fillRect(boxX, boxTop + 2, boxW, 4); continue; } // First-in-chain (or short chain): full chord rendering. // Bracket bar above the notes. if (hasNonZero || sorted.length >= 2) { const positions = (hasNonZero ? nonZeroNotes : sorted).map((cn, j) => ({ x: fretX(cn.f, p.scale, W), y: p.y * H - actualTotalH / 2 + j * actualSpread, })); const barY = positions[0].y - sz * 0.7; const barLeft = hasNonZero ? xMin : fretX(frameLeftFret, p.scale, W); const barRight = hasNonZero ? xMax : fretX(frameRightFret, p.scale, W); ctx.fillStyle = REPEAT_BOX_BAR; ctx.lineWidth = Math.max(3, sz / 4); roundRect(ctx, barLeft - 2, barY - 2, barRight - barLeft + 4, 4, 2); ctx.fill(); for (const pos of positions) { ctx.fillRect(pos.x - 2, barY, 4, pos.y - sz / 2 - barY); } } // Chord name label if (!ch.hd && p.scale > 0.15 && tmpl && tmpl.name) { const labelY = hasNonZero ? (p.y * H - actualTotalH / 2 - sz * 0.7 - sz * 0.4) : (p.y * H - sz * 0.8); const labelX = hasNonZero ? (xMin + xMax) / 2 : (sorted.length >= 2 ? (fretX(frameLeftFret, p.scale, W) + fretX(frameRightFret, p.scale, W)) / 2 : fretX(sorted[0].f, p.scale, W)); ctx.fillStyle = '#fff'; ctx.font = `bold ${Math.max(14, sz * 0.45) | 0}px sans-serif`; ctx.textAlign = 'center'; ctx.textBaseline = 'bottom'; fillTextReadable(tmpl.name, labelX, labelY); } // Harmony annotations (§6.3.1 / §6.6) — the chord's function // (fn.rn Roman numeral) and template voicing, stacked above the // chord name. Gated behind the teaching-marks opt-in (same overlay // class as sd/ch) so they don't clutter the default highway. // Display only — never grading. if (_showTeachingMarks && !ch.hd && p.scale > 0.15 && sorted.length > 0) { const { rn, voicing, caged, guideTones } = chordHarmonyLabels( ch.fn, tmpl && tmpl.voicing, tmpl && tmpl.caged, tmpl && tmpl.guideTones); if (rn || voicing || caged || guideTones) { const hx = hasNonZero ? (xMin + xMax) / 2 : (sorted.length >= 2 ? (fretX(frameLeftFret, p.scale, W) + fretX(frameRightFret, p.scale, W)) / 2 : fretX(sorted[0].f, p.scale, W)); // Baseline = just above where the chord name sits. const nameY = hasNonZero ? (p.y * H - actualTotalH / 2 - sz * 0.7 - sz * 0.4) : (p.y * H - sz * 0.8); ctx.font = `bold ${Math.max(10, sz * 0.32) | 0}px sans-serif`; ctx.textAlign = 'center'; ctx.textBaseline = 'bottom'; let stackY = nameY - sz * 0.5; if (rn) { ctx.fillStyle = '#ffcc66'; // matches the sd teaching color fillTextReadable(rn, hx, stackY); stackY -= sz * 0.45; } if (voicing) { ctx.fillStyle = '#7fd1ff'; // matches the fg teaching color fillTextReadable(voicing, hx, stackY); stackY -= sz * 0.45; } if (caged) { ctx.fillStyle = '#a0ffa0'; // CAGED shape teaching color fillTextReadable(caged, hx, stackY); stackY -= sz * 0.45; } if (guideTones) { ctx.fillStyle = '#d0a0ff'; // guide-tone teaching color fillTextReadable(guideTones, hx, stackY); } } } // Notes — wide colored bar for open strings inside a chord, // normal note glyph otherwise. // Classify into bent / unbent arrays inline (was: post-filter // chordPositions twice into bent/unbent). const bent = []; const unbent = []; for (let j = 0; j < sorted.length; j++) { const cn = sorted[j]; const x = fretX(cn.f, p.scale, W); const ny = p.y * H - actualTotalH / 2 + j * actualSpread; // feedBack#254 — per-string judgment, keyed by the // chord's chart time (matches how note_detect stores it). const cnNs = _noteStateProvider ? _noteState(cn, ch.t) : null; // Open-string-in-chord wide bar — only when the note has no // technique flags. Otherwise fall back to drawNote so PM / // H / P / T / tremolo / accent labels still render (drawNote // is the only path that emits those labels). if (getTemplateFret(cn) === 0 && hasMultipleNotes && !_noteHasTechniqueFlags(cn)) { const litBar = !!(cnNs && cnNs.state !== 'miss'); const color = litBar ? (cnNs.color || STRING_BRIGHT[cn.s] || STRING_COLORS[cn.s] || '#888') : (STRING_COLORS[cn.s] || '#888'); const dark = litBar ? (STRING_COLORS[cn.s] || '#666') : (STRING_DIM[cn.s] || '#222'); const barH = sz; const barLeft = fretX(frameLeftFret, p.scale, W); const barRight = fretX(frameRightFret, p.scale, W); ctx.fillStyle = dark; roundRect(ctx, barLeft - 1, ny - barH / 2 - 1, barRight - barLeft + 2, barH + 2, 3); ctx.fill(); ctx.fillStyle = color; roundRect(ctx, barLeft, ny - barH / 2, barRight - barLeft, barH, 2); ctx.fill(); _paintGemGlow((barLeft + barRight) / 2, ny, barH * 0.5, cn.s, cnNs); const fontSize = Math.max(8, sz * 0.5) | 0; ctx.fillStyle = '#fff'; ctx.font = `bold ${fontSize}px sans-serif`; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; fillTextReadable('0', (barLeft + barRight) / 2, ny); } else { drawNote(W, H, x, ny, p.scale, cn.s, cn.f, { ...cn, chord: true }, cnNs); } // Nullish-coalesce (??) rather than `||`: undefined / null // from missing bend data still maps to 0 (matches historic // encoding — old code shipped `entry.bn = cn.bn || 0`), but // NaN stays NaN so it fails BOTH the strict-equality branch // below and the `> 0` branch — keeping bad data out of the // unbent connector set rather than silently classifying it // as unbent. const cnBn = cn.bn ?? 0; const entry = { s: cn.s, f: cn.f, bn: cnBn, x, y: ny, scale: p.scale }; if (cnBn > 0) bent.push(entry); else if (cnBn === 0) unbent.push(entry); } // Unison bend within chord — bent / unbent classified inline above. if (bent.length > 0 && unbent.length > 0 && sz >= 14) { for (const bn of bent) { let closest = unbent[0]; for (const ub of unbent) { if (Math.abs(ub.s - bn.s) < Math.abs(closest.s - bn.s)) closest = ub; } const x1 = bn.x, y1 = bn.y; const x2 = closest.x, y2 = closest.y; const midX = (x1 + x2) / 2 + sz * 0.5; const midY = (y1 + y2) / 2; ctx.save(); ctx.strokeStyle = '#60d0ff'; ctx.lineWidth = Math.max(2, sz / 12); ctx.setLineDash([4, 4]); ctx.beginPath(); ctx.moveTo(x1, y1); ctx.quadraticCurveTo(midX, midY, x2, y2); ctx.stroke(); ctx.setLineDash([]); ctx.restore(); const labelSz = Math.max(10, sz * 0.3) | 0; ctx.fillStyle = '#60d0ff'; ctx.font = `bold ${labelSz}px sans-serif`; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; const cpX = (x1 + 2 * midX + x2) / 4; const cpY = (y1 + 2 * midY + y2) / 4; fillTextReadable('U', cpX + sz * 0.3, cpY); } } } } function drawFretNumbers(W, H) { const y = H * 0.97; const pad = 3; const lo = 0; const hi = Math.ceil(displayMaxFret); const anchor = getAnchorAt(currentTime); ctx.font = 'bold 20px sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; for (let fret = lo; fret <= hi; fret++) { if (fret < 0) continue; const x = fretX(fret, 1.0, W); const inAnchor = fret >= anchor.fret && fret <= anchor.fret + anchor.width; ctx.fillStyle = inAnchor ? '#e8c040' : '#8a6830'; fillTextReadable(String(fret), x, y); } } // ── Helpers ─────────────────────────────────────────────────────────── function drawLyrics(W, H) { if (!lyrics.length) return; const fontSize = Math.max(18, H * 0.028) | 0; const lineY = H * 0.04; // Vocal markers: a trailing "-" means the syllable joins the // next one into a single word (no space); a trailing "+" marks the end // of an authored line. Build a flat list of authored lines so we can // cap rendering to a 2-line rolling window (current + upcoming). 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('-'); // Safety fallback: if a song has no "+" markers at all, force a // line break on any gap > 4s so we never build a single giant line. if (line && i > 0) { const prev = lyrics[i - 1]; if (l.t - (prev.t + prev.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; // Current line = most recently started line. Before the first line has // started, preview the first line if it's within 2s of starting. 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; currentIdx = 0; } const currentLine = allLines[currentIdx]; const nextLine = allLines[currentIdx + 1] || null; const gapToNext = nextLine ? (nextLine.start - currentLine.end) : Infinity; // Hide once the current line is clearly over and nothing relevant follows. if (currentTime > currentLine.end + 0.5 && gapToNext > 3.0) return; const linesToShow = [currentLine]; if (nextLine && gapToNext <= 3.0) linesToShow.push(nextLine); 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 = _measureLyricText(ctx, fontSize, ' '); const maxWidth = W * 0.8; // Respect authored line breaks; wrap only if a line overflows maxWidth. 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 = _measureLyricText(ctx, fontSize, text); 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)'; roundRect(ctx, W/2 - bgWidth/2, lineY - 4, bgWidth, totalHeight, 8); 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; if (isActive) { ctx.fillStyle = '#4ae0ff'; ctx.font = `bold ${fontSize}px sans-serif`; } else if (isPast) { ctx.fillStyle = '#8899aa'; ctx.font = `normal ${fontSize}px sans-serif`; } else { ctx.fillStyle = '#556677'; ctx.font = `normal ${fontSize}px sans-serif`; } ctx.fillText(part.text, xPos, yPos); xPos += part.width; } xPos += spaceWidth; } } } function roundRect(ctx, x, y, w, h, r) { ctx.beginPath(); ctx.moveTo(x + r, y); ctx.lineTo(x + w - r, y); ctx.quadraticCurveTo(x + w, y, x + w, y + r); ctx.lineTo(x + w, y + h - r); ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h); ctx.lineTo(x + r, y + h); ctx.quadraticCurveTo(x, y + h, x, y + h - r); ctx.lineTo(x, y + r); ctx.quadraticCurveTo(x, y, x + r, y); ctx.closePath(); } function bsearch(arr, time) { let lo = 0, hi = arr.length; while (lo < hi) { const mid = (lo + hi) >> 1; if (arr[mid].t < time) lo = mid + 1; else hi = mid; } return lo; } function bsearchChords(arr, time) { let lo = 0, hi = arr.length; while (lo < hi) { const mid = (lo + hi) >> 1; if (arr[mid].t < time) lo = mid + 1; else hi = mid; } return lo; } // ── Chord rendering — chains, frames, fretline preview (feedBack#88) ── // // Charts often repeat the same chord shape several times in a // row (e.g. a G strummed 4 times). We call a contiguous run of same-id // chords with gaps < CHAIN_GAP_THRESHOLD a "chain". Chains drive two // visual choices: // • The first chord in a chain renders in full; subsequent chords in // a chain of CHAIN_RENDER_FULL_MAX or longer render as a "repeat // box" — a translucent boxed frame so the eye can see the rhythm // pattern without re-scanning identical fret numbers. // • Each chord anchors a CHORD_FRAME_FRETS-wide frame; muted and // open-only chords inherit the frame from their predecessor so // they don't snap to fret 0. // // We compute chain stats and frame anchors once per `src` array via // _ensureChordRenderCache (lazy, invalidates when the array reference // changes — which happens on chord ingest, mastery rebuild, or song // reset). The render path is then pure read. const CHAIN_GAP_THRESHOLD = 0.5; const CHAIN_RENDER_FULL_MAX = 4; const CHORD_FRAME_FRETS = 4; // Fretline preview: the static fret line at the bottom shows the chord // closest to the strum line (currentTime + FRETLINE_TARGET_OFFSET) within // the [target - FRETLINE_WINDOW_BEFORE, target + FRETLINE_WINDOW_AFTER] // window, as a teaching aid. const FRETLINE_TARGET_OFFSET = -0.25; const FRETLINE_WINDOW_BEFORE = 0.1; const FRETLINE_WINDOW_AFTER = 0.3; // Repeat / mute box colors. const REPEAT_BOX_FILL = 'rgba(48, 80, 128, 0.06)'; const REPEAT_BOX_BAR = '#50a0dc'; const MUTE_BOX_STROKE = '#6060809b'; const MUTE_BOX_BAR = '#606080d1'; // Reset all chord-render-derived state. Called from init() and // reconnect() so per-song state (preview, frame-mismatch warnings, // chain cache) doesn't leak across songs that reuse chord IDs. function _resetChordRenderState() { _lastChordOnFretLine = null; _chordFretLineNotes = []; _frameMismatchWarned.clear(); _chordRenderCacheSrc = null; _chordRenderCacheInverted = null; _chordRenderCacheTemplates = null; } // True if a chord note carries per-strum technique data (bend, // hammer/pull/tap, slide, palm-mute, vibrato, tremolo, accent, harmonic, pinch // harmonic, dead note). drawNote shows these in 3D (`ac` accent is a brighter // gem instead of a glyph there). Alternate render paths (repeat box, // open-string-in-chord wide bar) // bypass drawNote and so must fall back to the full path whenever a // technique flag is present, otherwise authored cues vanish silently. function _noteHasTechniqueFlags(n) { if (n.bn || n.ho || n.po || n.tp || n.pm || n.vb || n.tr || n.ac || n.hm || n.hp || n.mt || n.fhm) return true; if (typeof n.sl === 'number' && n.sl >= 0) return true; return false; } function _chordHasTechniqueFlags(ch) { const notes = ch.notes; for (let i = 0; i < notes.length; i++) { if (_noteHasTechniqueFlags(notes[i])) return true; } return false; } // Template lookup: returns helpers that classify a chord note's fret // against its template. Open = template fret 0 (regardless of cn.f). function getChordTemplateInfo(chordId, chordTemplates) { const tmpl = chordTemplates[chordId]; const tmplFrets = tmpl && tmpl.frets ? tmpl.frets : []; const getTemplateFret = (cn) => cn.s < tmplFrets.length ? tmplFrets[cn.s] : cn.f; const isOpen = (cn) => getTemplateFret(cn) === 0; return { tmpl, tmplFrets, getTemplateFret, isOpen }; } // Build _chordRenderInfo for every chord in `src` if the cache is stale. // Two passes over the array: chain bounds, then base-fret resolution // (which can read previous chord's cached baseFret). function _ensureChordRenderCache(src) { const templatesChanged = _chordRenderCacheTemplates !== chordTemplates; if (_chordRenderCacheSrc === src && _chordRenderCacheInverted === _inverted && !templatesChanged) return; _chordRenderCacheSrc = src; _chordRenderCacheInverted = _inverted; _chordRenderCacheTemplates = chordTemplates; // Templates feed isOpen() — when they land after `chords`, // _updateFretLinePreview's stashed open/non-open classification // for the currently-active chord is also stale. It only refreshes // on the next chord transition, so force a refresh here. if (templatesChanged) { _lastChordOnFretLine = null; _chordFretLineNotes = []; // Also clear the once-per-chord-id frame-mismatch warner — // a chord ID warned against stale (missing/empty) templates // would otherwise never be re-validated against the // corrected templates that just landed. _frameMismatchWarned.clear(); } // Pass 1: walk forward, marking chain index / length / isFull on a // per-chord WeakMap entry. A chain breaks when the next chord has a // different id OR the time gap is >= CHAIN_GAP_THRESHOLD. // Chords that carry per-strum technique flags (bend / palm-mute / // hammer / pull / tap / slide / vibrato / tremolo / accent / harmonic / mute) // never collapse to a repeat box — those cues are authored on each // strum and must stay visible. let chainStart = 0; for (let i = 0; i <= src.length; i++) { const breakHere = (i === src.length) || (i > chainStart && (src[i].id !== src[i - 1].id || Math.abs(src[i].t - src[i - 1].t) >= CHAIN_GAP_THRESHOLD)); if (breakHere && i > chainStart) { const len = i - chainStart; for (let k = chainStart; k < i; k++) { const chainIndex = k - chainStart; const hasTechniques = _chordHasTechniqueFlags(src[k]); _chordRenderInfo.set(src[k], { chainIndex, chainLen: len, isFull: len < CHAIN_RENDER_FULL_MAX || chainIndex === 0 || hasTechniques, baseFret: 0, // filled in pass 2 sortedNotes: null, // ↓ all filled in pass 2 — cached to skip nonZeroNotes: null, // per-frame sort/filter/min/max in drawChords. nonZeroFrets: null, allMuted: false, hasMultipleNotes: false, }); } chainStart = i; } } // Pass 2: resolve baseFret. Fretted chords use their own lowest // non-open fret; chained same-id chords inherit from the previous // entry; open-only / muted chords with a different-id predecessor // inherit that predecessor's frame too. The walk is forward so // prev's cached value is always present when we read it. for (let i = 0; i < src.length; i++) { const ch = src[i]; const info = _chordRenderInfo.get(ch); const { isOpen } = getChordTemplateInfo(ch.id, chordTemplates); const sortedNotes = [...ch.notes].sort((a, b) => _inverted ? b.s - a.s : a.s - b.s); const nonZero = sortedNotes.filter(cn => !isOpen(cn)); const nonZeroFrets = nonZero.map(cn => cn.f); if (nonZero.length >= 1) { let minF = nonZeroFrets[0]; for (let j = 1; j < nonZeroFrets.length; j++) if (nonZeroFrets[j] < minF) minF = nonZeroFrets[j]; info.baseFret = minF; } else if (i > 0) { const prevInfo = _chordRenderInfo.get(src[i - 1]); info.baseFret = prevInfo ? prevInfo.baseFret : 0; } else { info.baseFret = 0; } info.sortedNotes = sortedNotes; info.nonZeroNotes = nonZero; info.nonZeroFrets = nonZeroFrets; info.hasMultipleNotes = sortedNotes.length >= 2; let allMuted = sortedNotes.length > 0; if (allMuted) { for (let j = 0; j < sortedNotes.length; j++) { if (!(sortedNotes[j].mt || sortedNotes[j].fhm)) { allMuted = false; break; } } } info.allMuted = allMuted; } } // Compute the on-screen box for a chord (used by both muted and repeat // box renderings). Box height tracks the per-string note positions; box // width spans the CHORD_FRAME_FRETS frame anchored at info.baseFret. function _computeChordBox(p, H, W, sorted, sz, actualSpread, baseFret) { const actualTotalH = actualSpread * Math.max(0, sorted.length - 1); const yCenter = p.y * H; const boxTop = yCenter - actualTotalH / 2 - sz * 0.5; const boxBottom = boxTop + Math.max(sz, actualTotalH + sz); const boxX = fretX(baseFret, p.scale, W); const boxW = fretX(baseFret + CHORD_FRAME_FRETS, p.scale, W) - boxX; return { boxX, boxW, boxTop, boxH: boxBottom - boxTop }; } // Search [lo, hi) for the chord we should preview on the static fret // line. Prefer the chord nearest the strum line that's within // [target - before, target + after]; if none match, fall back to the // first visible chord. Updates _lastChordOnFretLine / _chordFretLineNotes // only when the active chord changes (lets the preview persist while a // chord is held). function _updateFretLinePreview(src, lo, hi) { const targetTime = currentTime + FRETLINE_TARGET_OFFSET; let activeChord = null; let activeNotesOnFret = []; let bestChordTime = -Infinity; for (let i = lo; i < hi; i++) { const ch = src[i]; if (ch.t >= targetTime - FRETLINE_WINDOW_BEFORE && ch.t < targetTime + FRETLINE_WINDOW_AFTER && ch.t > bestChordTime) { bestChordTime = ch.t; activeChord = ch; const { isOpen } = getChordTemplateInfo(ch.id, chordTemplates); const nonZero = ch.notes.filter(cn => !isOpen(cn)); activeNotesOnFret = nonZero.length >= 1 ? nonZero.map(cn => ({ s: cn.s, f: cn.f })) : []; } } if (activeChord === null) { for (let i = lo; i < hi; i++) { const ch = src[i]; const p = project(ch.t - currentTime); if (!p) continue; activeChord = ch; const { isOpen } = getChordTemplateInfo(ch.id, chordTemplates); const nonZero = ch.notes.filter(cn => !isOpen(cn)); activeNotesOnFret = nonZero.length >= 1 ? nonZero.map(cn => ({ s: cn.s, f: cn.f })) : []; break; } } // Compare by chord OBJECT identity rather than .id — two strums of // the same chord template are different objects, so a chain like // (G normal) → (G all-muted) refreshes the preview instead of // leaving the first strum's fingerings stuck on the fret line. if (activeChord !== _lastChordOnFretLine) { _chordFretLineNotes = activeNotesOnFret; _lastChordOnFretLine = activeChord; } } function _drawFretLineChordPreview(W, H) { if (_chordFretLineNotes.length === 0) return; const strTop = H * 0.83; const strBot = H * 0.95; // Scale glyphs with H so preview stays proportionate at any // resolution / renderScale. Constants picked to match the prior // hardcoded 30px diameter / 24px font at H=900. const noteSize = Math.max(14, H * 0.033); const fontSize = Math.max(11, H * 0.027) | 0; ctx.font = `bold ${fontSize}px sans-serif`; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; for (const cn of _chordFretLineNotes) { const yi = _inverted ? 5 - cn.s : cn.s; const syl = strTop + (yi / 5) * (strBot - strTop); const fretXPos = fretX(cn.f, 1, W); ctx.fillStyle = STRING_COLORS[cn.s] || '#888'; ctx.beginPath(); ctx.arc(fretXPos, syl, noteSize / 2, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = '#fff'; fillTextReadable(String(cn.f), fretXPos, syl); } } // Rebuild the mastery-filtered note/chord arrays from _phrases + // _mastery. Called on `ready` and on every setMastery(). When // _phrases is null (slider-disabled source), we clear the filtered // arrays — drawNotes/drawChords fall through to the flat arrays. // // Output arrays are pre-sorted by time because phrase iterations // arrive in chronological order and within each level the notes/ // chords are time-sorted already (PR 1's parser sorts them), so // concatenation preserves the order. No explicit sort needed. function _rebuildMasteryFilter() { // Null OR empty → fall through to flat arrays. The server's // chunked emission invariant means _phrases should never land // at `[]` in practice (it'd require the `phrases` message to // fire with zero data), but the defensive guard means a bug // on the way in wouldn't blank the chart. if (_phrases === null || _phrases.length === 0) { _filteredNotes = null; _filteredChords = null; _filteredAnchors = null; _filteredHandShapes = null; _phrasesHaveHandShapes = false; return; } const outNotes = []; const outChords = []; const outAnchors = []; const outHandShapes = []; // Scan EVERY level (not just the current mastery's slice): if // any level anywhere authored a handshape, the chart's phrase // data is the authoritative source and the bundle should // respect filtered emptiness strictly. Otherwise, the chart // didn't ship handshapes via phrases at all and we should fall // back to the flat arrangement-root list (DLC pattern). let anyHandShapeInPhrases = false; for (const p of _phrases) { const n = p.levels.length; if (n === 0) continue; // Map slider fraction to a level index. `n` already equals // `max_difficulty + 1` for fully-authored phrases, and // equals the authored-level count otherwise — so indexing // into p.levels.length is both correct and defensive. const idx = Math.min(n - 1, Math.floor(_mastery * n)); const lv = p.levels[idx]; for (const x of lv.notes) outNotes.push(x); for (const x of lv.chords) outChords.push(x); // Anchors drive the fret zoom / pan. Keeping max-mastery // anchors while hiding higher-difficulty notes would leave // the highway panning into empty regions — filter them to // the same level as the notes they pair with. for (const x of lv.anchors) outAnchors.push(x); for (const x of (lv.handshapes || [])) outHandShapes.push(x); if (!anyHandShapeInPhrases) { for (const level of p.levels) { if (level.handshapes && level.handshapes.length > 0) { anyHandShapeInPhrases = true; break; } } } } _filteredNotes = outNotes; _filteredChords = outChords; _filteredAnchors = outAnchors; if (outHandShapes.length) { outHandShapes.sort((a, b) => a.start_time - b.start_time); } _filteredHandShapes = outHandShapes; _phrasesHaveHandShapes = anyHandShapeInPhrases; } // ── Public API ─────────────────────────────────────────────────────── const api = { init(canvasEl, container) { canvas = canvasEl; _resizeContainer = container || null; // Size the canvas BEFORE installing the renderer so // _setRenderer's init/resize calls see the real dimensions // instead of the default 300x150 backing store. Otherwise // WebGL renderers would allocate framebuffers at the wrong // size and immediately have to tear them down when // api.resize fires afterwards. this.resize(); // Install the default renderer on first init. If a caller // pre-selected a custom renderer before init ran (e.g. // app.js restoring a saved viz picker selection at page // load), re-apply that choice now that the canvas is // available instead of clobbering it with the default. // _setRenderer(_renderer) is correct: it re-applies the // selected renderer now that the canvas exists, and only // destroys the previous renderer if it had been // successfully init'd before this mount (so a pre-selected // renderer that never saw a canvas gets init'd fresh, not // destroy+init'd). _setRenderer(_renderer || _defaultRenderer); if (_resizeHandler) window.removeEventListener('resize', _resizeHandler); _resizeHandler = () => this.resize(); window.addEventListener('resize', _resizeHandler); ready = false; notes = []; chords = []; handShapes = []; beats = []; sections = []; anchors = []; chordTemplates = []; lyrics = []; lyricsSource = ""; toneChanges = []; toneBase = ""; drumTab = null; stringCount = 6; // default until song_info arrives // Reset phrase ladder + filter (feedBack#48). _mastery // persists across arrangement switches — the slider's // position stays put. Filter rebuilds on the next `ready` // once the new arrangement's phrases arrive (or stays // disabled if the new source has no phrase data). _phrases = null; _filteredNotes = null; _filteredChords = null; _filteredAnchors = null; _filteredHandShapes = null; _phrasesHaveHandShapes = false; _resetChordRenderState(); }, resize() { if (!canvas) return; let w, h; if (_resizeContainer) { const rect = _resizeContainer.getBoundingClientRect(); w = rect.width; h = rect.height; } else { // Measure #player-footer (Section Practice bar + transport row) // so the canvas doesn't draw under the practice bar; fall back // to #player-controls if the footer wrapper isn't present. const controls = document.getElementById('player-footer') || document.getElementById('player-controls'); const controlsH = controls ? controls.offsetHeight : 50; w = document.documentElement.clientWidth; h = document.documentElement.clientHeight - controlsH; } canvas.style.width = w + 'px'; canvas.style.height = h + 'px'; canvas.width = Math.round(w * _effectiveRenderScale()); canvas.height = Math.round(h * _effectiveRenderScale()); // Notify the active renderer so WebGL / offscreen buffers // can recreate their framebuffers. Setting canvas.width // above already invalidates both 2D and WebGL state — any // renderer relying on persistent GPU resources listens here. // // Gated on _rendererInited: a renderer pre-selected via // setRenderer before api.init has run is stashed but not // initialized yet. Calling resize() on it would violate // the init-before-resize contract and can break renderers // that assume resize() means "canvas dims changed after // setup." The subsequent api.init will call its resize() // once init succeeds. if (_renderer && _rendererInited && typeof _renderer.resize === 'function') { try { _renderer.resize(canvas.width, canvas.height); } catch (e) { console.error('renderer resize:', e); } } }, setRenderScale(scale) { const v = Number(scale); // Reject non-finite input (undefined / NaN / non-numeric) so a // bad caller can't poison _renderScale and blank the canvas; // keep the current scale in that case. Mirrors the load guard. if (!Number.isFinite(v)) return; _renderScale = Math.max(0.25, Math.min(1, v)); localStorage.setItem('renderScale', _renderScale); this.resize(); }, getRenderScale() { return _renderScale; }, // Floor for the load-adaptive render scale (#654). 0.25 = stock (allows // quarter-res on heavy frames), 1.0 = never auto-downscale below the // Quality (renderScale) ceiling — so it's only full resolution when // Quality is HD; otherwise it pins at the chosen Quality. Persisted; // takes effect immediately via resize(). Exposed as the "Min res" // control next to Quality in the player controls. setMinRenderScale(scale) { const v = Number(scale); if (!Number.isFinite(v)) return; _autoScaleMin = Math.max(_AUTO_SCALE_MIN, Math.min(1, v)); // Pull the live multiplier back within the new bounds so a raised // floor applies at once and a previously-low (or, defensively, a // >1) _autoScale can't strand the resolution when the floor changes. const lo = _renderScale > 0 ? Math.min(1, _autoScaleMin / _renderScale) : 1; _autoScale = Math.max(lo, Math.min(1, _autoScale)); localStorage.setItem('highwayMinRenderScale', _autoScaleMin); this.resize(); // recompute the backing store via _effectiveRenderScale() }, getMinRenderScale() { return _autoScaleMin; }, // Scale actually applied = manual ceiling * load-adaptive factor (#654). getEffectiveRenderScale() { return _effectiveRenderScale(); }, // Live perf numbers a reporter or the HUD can read to confirm the // adaptive cap is holding. getPerfStats() { return { drawMs: _drawMsEMA, autoScale: _autoScale, renderScale: _renderScale, effectiveScale: _effectiveRenderScale(), }; }, getInverted() { return _inverted; }, setInverted(v) { _inverted = v; localStorage.setItem('invertHighway', v); }, setLefty(on) { _lefty = !!on; localStorage.setItem('lefty', _lefty ? '1' : '0'); }, getLefty() { return _lefty; }, // Master-difficulty (feedBack#48). Per-instance: splitscreen // plugins that call createHighway() separately get their own // _mastery via closure. setMastery(fraction) { // Same NaN guard as the init (plugins could pass undefined // or a string that coerces badly). Silently ignore — the // caller probably meant to pass a number; keeping the // previous value is safer than propagating NaN into // Math.floor → p.levels[NaN]. const next = Number(fraction); if (!Number.isFinite(next)) return; _mastery = Math.max(0, Math.min(1, next)); _rebuildMasteryFilter(); }, getMastery() { return _mastery; }, // Align with _rebuildMasteryFilter's own "null OR empty → fall // through" check. If we returned true for _phrases = [], the // slider would be enabled (via song:ready's hasPhraseData) but // dragging it would do nothing (filter stays null). Same // sentinel, same check, single source of truth. hasPhraseData() { return !!(_phrases && _phrases.length > 0); }, // Lightweight phrase windows for Section Practice — timing only, no note payloads. getPracticePhrases() { if (!_phrases || !_phrases.length) return null; return _phrases.map((p, index) => ({ index, start_time: p.start_time, end_time: p.end_time, max_difficulty: p.max_difficulty, })); }, connect(wsUrl, opts = {}) { _connectOpts = opts; // Bump generation so async handlers from the previous connection // can detect they are stale and skip state mutations. _wsGen += 1; // Fresh routing promise for this connection's song_info load. _juceRoutingPromise = Promise.resolve(); // Clear any stale "initial routing in-flight" flag from a prior // connection so the app.js engine-reroute watcher isn't wedged. window._highwayJuceRoutingPending = false; ws = new WebSocket(wsUrl); ws.onclose = () => { console.log('WS closed'); }; ws.onerror = (e) => { console.error('WS error', e); }; // Reset the serialization chain so old in-flight handlers // from a previous connection don't delay new messages. _msgChain = Promise.resolve(); // Helper: attach the HTML5 audio buffering overlay to `audio`. // Shared by both the direct HTML5 path and the JUCE fallback path. function _showAudioBufferingOverlay(audio) { let overlay = document.getElementById('audio-buffer-overlay'); if (!overlay) { overlay = document.createElement('div'); overlay.id = 'audio-buffer-overlay'; overlay.className = 'fixed inset-0 z-[200] flex items-center justify-center bg-black/60 backdrop-blur-sm'; overlay.innerHTML = `
Loading audio...
0%
`; document.body.appendChild(overlay); } const bar = document.getElementById('audio-buffer-bar'); const pct = document.getElementById('audio-buffer-pct'); const MIN_BUFFER_SECS = 30; function onProgress() { if (audio.buffered.length > 0 && audio.duration > 0) { const loaded = audio.buffered.end(audio.buffered.length - 1); const p = Math.round((loaded / audio.duration) * 100); if (bar) bar.style.width = p + '%'; if (pct) pct.textContent = p + '%'; if (loaded >= MIN_BUFFER_SECS || loaded >= audio.duration) { cleanup(); } } } function cleanup() { audio.removeEventListener('progress', onProgress); audio.removeEventListener('canplaythrough', cleanup); const ol = document.getElementById('audio-buffer-overlay'); if (ol) ol.remove(); } audio.addEventListener('progress', onProgress); audio.addEventListener('canplaythrough', cleanup, { once: true }); } ws.onmessage = (ev) => { const data = ev.data; // Capture generation synchronously before any async work so // stale completions from a prior WebSocket can be detected. const gen = _wsGen; _msgChain = _msgChain.then(async () => { // Bail out if a reconnect happened while this step was queued. if (gen !== _wsGen) return; try { const msg = JSON.parse(data); if (msg.error) { console.error('Server error:', msg.error); if (opts.onError) opts.onError(msg.error); else alert('Error: ' + msg.error); return; } switch (msg.type) { case 'loading': console.log('Loading:', msg.stage); break; case 'song_info': // Normalise to camelCase so matchesArrangement predicates // can use songInfo.hasNotation without knowing the wire // field name. Keep has_notation intact for consumers that // already read the raw field (e.g. _isNotationOnlySong). songInfo = Object.assign({}, msg, { hasNotation: Boolean(msg.has_notation), hasDrumTab: Boolean(msg.has_drum_tab), }); _reportAudioSessionStart(msg); { const parsedOffset = Number(msg.offset); songOffset = Number.isFinite(parsedOffset) ? parsedOffset : 0.0; } // Pick up the active arrangement's string count. // Prefer the explicit `stringCount` field (added // in feedBack-plugin-3dhighway#7); fall back to // `tuning.length` for older servers that haven't // started emitting it (works correctly for // GP-imported sources where tuning is already // truncated, and for sloppaks loaded against an // updated lib/song.py); final fallback is 6 for // safety so a missing/malformed payload doesn't // surface as 0 strings. // // Clamp to [1, MAX_STRINGS] before storing — // stringCount drives loop bounds in drawStrings // and downstream plugins. A malformed payload // (huge or zero / negative) would otherwise hang // the UI or render no strings at all. 8 covers // every real-world instrument we ship colors // for; values above that fall back to '#888' // anyway via the STRING_COLORS lookup so // capping the loop bound costs nothing visible. const MAX_STRINGS = 8; let _sc; if (typeof msg.stringCount === 'number' && msg.stringCount > 0) { _sc = msg.stringCount; } else if (Array.isArray(msg.tuning) && msg.tuning.length > 0) { _sc = msg.tuning.length; } else { _sc = 6; } // Math.trunc(_sc) (with finite check) instead of // `_sc | 0` — bitwise-OR forces 32-bit signed // conversion, so any value ≥ 2^31 wraps negative // and the Math.max(1, ...) clamp would land at // 1 string. Math.trunc preserves the magnitude; // the Math.min(MAX_STRINGS, ...) below caps it // safely. const _scTrunc = Number.isFinite(_sc) ? Math.trunc(_sc) : 1; stringCount = Math.max(1, Math.min(MAX_STRINGS, _scTrunc)); if (opts.onSongInfo) { opts.onSongInfo(msg); } else { document.getElementById('hud-artist').textContent = msg.artist; document.getElementById('hud-title').textContent = msg.title; // Prefer the server-echoed naming_mode (resolved from // the WS query param) so the HUD stays consistent with // app.js's in-memory cache even when localStorage is // unavailable. Fall back to localStorage for older // backends that don't echo it yet. let namingMode = msg.naming_mode; if (namingMode !== 'smart' && namingMode !== 'legacy') { try { namingMode = localStorage.getItem('arrangementNamingMode') === 'legacy' ? 'legacy' : 'smart'; } catch (_) { namingMode = 'smart'; } } const arrLabel = (namingMode === 'smart' && msg.arrangement_smart_name) ? msg.arrangement_smart_name : msg.arrangement; document.getElementById('hud-arrangement').textContent = arrLabel; const hudTuningEl = document.getElementById('hud-tuning'); const hudTargetsEl = document.getElementById('hud-tuning-targets'); const tuningLabel = (typeof window.displayTuningName === 'function') ? window.displayTuningName(null, msg.tuning) : ''; if (hudTuningEl) { hudTuningEl.textContent = tuningLabel ? ('Tuning: ' + tuningLabel) : ''; } if (hudTargetsEl) { let targetsText = ''; if (tuningLabel === 'Custom Tuning' && Array.isArray(msg.tuning) && msg.tuning.length && typeof window.displayTuningTargets === 'function') { const targets = window.displayTuningTargets(msg.tuning, { stringCount: msg.stringCount, arrangement: msg.arrangement, arrangement_smart_name: msg.arrangement_smart_name, tuningName: tuningLabel, }); targetsText = targets ? ('Targets: ' + targets) : ''; } hudTargetsEl.textContent = targetsText; } // Clear any lingering audio-error banner from a prior song. const existingAudioErr = document.getElementById('audio-error-banner'); if (existingAudioErr) existingAudioErr.remove(); // Server reported a concrete audio-pipeline failure and has // no URL to give us — surface it instead of leaving the // user with a cryptic "Empty src attribute" from audio.play(). if (!msg.audio_url && msg.audio_error) { _reportAudioRoute('unknown', 'unavailable', msg.audio_error); const banner = document.createElement('div'); banner.id = 'audio-error-banner'; banner.className = 'fixed top-4 left-1/2 -translate-x-1/2 z-[300] bg-red-900/95 border border-red-700 text-red-100 rounded-lg px-4 py-3 max-w-2xl shadow-xl'; banner.innerHTML = `
Audio unavailable
`; banner.querySelector('.text-xs').textContent = msg.audio_error; banner.querySelector('button').addEventListener('click', () => banner.remove()); document.body.appendChild(banner); } if (msg.audio_url) { const audio = document.getElementById('audio'); const audioFilename = msg.audio_url.split('/').pop(); // Only attempt JUCE routing for /audio/ URLs — sloppak stems // (/api/sloppak/…) are not resolvable via audio-local-path. const isAudioUrl = msg.audio_url.startsWith('/audio/'); // Record the loaded song's audio so app.js can re-route it // between the HTML5 and JUCE paths if the audio engine is // started/stopped after the song is already loaded. Set this // unconditionally (not just on reload): when alreadyLoaded is // true the watcher must still see correct, current metadata. window._currentSongAudio = { url: msg.audio_url, juceEligible: isAudioUrl }; const alreadyLoaded = window._juceMode ? window._juceAudioUrl === msg.audio_url : (audio.src && audio.src.includes(audioFilename)); if (!alreadyLoaded) { const juceApi = window.feedBackDesktop?.audio; if (isAudioUrl && juceApi) { // Run JUCE routing off the critical message-processing chain // so subsequent notes/chords/ready messages aren't blocked // waiting for IPC + HTTP round-trips. The 'ready' handler // awaits _juceRoutingPromise so _juceMode is settled before // _onReady / song:ready fire. const audioUrl = msg.audio_url; // Flag the initial song-load JUCE routing as in-flight so the // app.js engine-reroute watcher stands down until _juceMode is // settled — otherwise its 350ms poll could race this routing // and double-call loadBackingTrack for the same URL. window._highwayJuceRoutingPending = true; _juceRoutingPromise = (async () => { let pathLabel = ''; try { // Wait out any in-flight native-audio reconfiguration (e.g. // a NAM tone graph build that restarts the audio device) // before touching the JUCE backing engine — otherwise the // device restart races the backing-track load (and an // isAudioRunning() check landing mid-restart would skip // backing entirely). Raced against a local 3s timeout so a // plugin barrier that never settles cannot wedge song entry; // the try/catch + Promise.resolve wrapper also covers a // synchronous throw from the barrier call. if (typeof window.feedBackAudioBarrier === 'function') { let barrierTimer; let barrierTimedOut = false; try { await Promise.race([ Promise.resolve().then(() => window.feedBackAudioBarrier()), new Promise((r) => { barrierTimer = setTimeout(() => { barrierTimedOut = true; r(); }, 3000); }), ]); _reportAudioMonitoring('juce-audio-barrier', barrierTimedOut ? 'unavailable' : 'active', barrierTimedOut ? 'Audio barrier timed out before native route setup' : ''); _reportAudioBridge('audio-monitoring.audio-barrier', 'audio-monitoring', 'window.feedBackAudioBarrier', barrierTimedOut ? 'degraded' : 'handled', barrierTimedOut ? 'Audio barrier timed out before native route setup' : ''); } catch (_) { _reportAudioMonitoring('juce-audio-barrier', 'failed', 'Audio barrier failed before native route setup'); _reportAudioBridge('audio-monitoring.audio-barrier', 'audio-monitoring', 'window.feedBackAudioBarrier', 'failed', 'Audio barrier failed before native route setup'); } // Promise.race doesn't cancel the loser — clear the timer // when the barrier wins so rapid song switches don't leak. clearTimeout(barrierTimer); if (gen !== _wsGen) return; // navigated away during the wait } if (await juceApi.isAudioRunning()) { if (gen !== _wsGen) return; // stale const res = await fetch(`/api/audio-local-path?url=${encodeURIComponent(audioUrl)}`); if (!res.ok) throw new Error('HTTP ' + res.status); const { path } = await res.json(); pathLabel = (typeof path === 'string' && path.split(/[\\/]/).pop()) || ''; const ok = await juceApi.loadBackingTrack(path); console.log('[highway] JUCE loadBackingTrack file=', pathLabel, 'ok=', ok); if (ok === false) throw new Error('JUCE rejected backing track: ' + pathLabel); if (gen !== _wsGen) return; // stale if (window.jucePlayer) window.jucePlayer._dur = await juceApi.getBackingDuration(); if (gen !== _wsGen) return; // stale window._juceMode = true; window._juceAudioUrl = audioUrl; _reportAudioRoute('juce', 'available'); // Re-apply the active Song fader whenever a new backing // track is loaded so song-to-song switches keep the same // user-selected level instead of the engine default. try { const apply = window.feedBack?.audio?.applySongVolume; if (typeof apply === 'function') { await apply(); } else { // audio-mixer.js registers applySongVolume but is // loaded after highway.js in index.html. In JUCE // mode the HTML5