From 77547af11023ded3e4820362aeef2ffe7b726305 Mon Sep 17 00:00:00 2001 From: OmikronApex Date: Thu, 2 Jul 2026 00:02:54 +0200 Subject: [PATCH] perf: allocation/scan hardening for weaker hardware MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Static-analysis follow-ups to the trace-backed fixes; each is cheap insurance on machines where the profiled headroom doesn't exist. - highway.js: _makeBundle now mutates one persistent per-instance object instead of allocating a fresh ~35-field bundle every rAF frame (xN under splitscreen). Object identity is stable and meaningless; array fields still swap reference on chart changes, which field-identity caches rely on. Contract documented in both CLAUDE.mds. - highway.js: new bsearchTime (lower-bound on .time) windows the default 2D renderer's beat-line scan (was O(all beats) per frame); bundle.lowerBoundT / bundle.lowerBoundTime expose the searches to custom viz so they stop reimplementing visible-window culling. - highway_3d: localStorage 'h3d_full_sus' polled at ~1 Hz instead of every frame (synchronous storage read on the hot path). - highway_3d: drawLyrics caches the measureText row layout keyed on (lyrics ref, line index, shown count, font size, width) — per-frame work is now just drawing over cached widths. - tests/js: bundle source-shape assertions widened to accept the assignment form ([:=]) alongside the old object-literal form. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 3 + CLAUDE.md | 12 + plugins/highway_3d/CLAUDE.md | 2 + plugins/highway_3d/screen.js | 86 ++++--- static/highway.js | 219 ++++++++++-------- tests/js/handshapes_pipeline.test.js | 2 +- tests/js/highway_3d_lefty.test.js | 2 +- .../js/highway_3d_smooth_clock_pause.test.js | 4 +- tests/js/highway_adaptive_scale.test.js | 2 +- tests/js/highway_note_state.test.js | 4 +- 10 files changed, 208 insertions(+), 128 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f7d1651..6244b4f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed +- **Player frame-time hotspots removed (trace-backed) + weak-hardware hardening.** A Chrome performance trace of a 3D-highway session surfaced two core per-frame layout-thrash sources, now fixed: the highway's visibility check read `canvas.offsetParent` every rAF frame (forces style/layout recalc — now sampled every 10th frame with a cached value, force-refreshed on init/canvas-replace/resize/override-clear), and the v3 player chrome loop called `matches(':hover')` per frame and unconditionally rewrote the Up-Next pill's `textContent`/bar width at 6 Hz (now hover-tracked via mouseenter/mouseleave, DOM writes only on value change, progress bar moved from `width` to compositor-only `scaleX`). The 3D highway pre-warms shader programs (`ren.compile`) and deterministic label textures at init — and chart-dependent chord/section label textures on first draw — so first-appearance shader-compile/texture-upload frame spikes move into the load spinner. For weaker hardware: the per-frame renderer bundle is now a single reused object instead of a fresh ~35-field allocation per frame (object identity is stable and meaningless; array fields still swap reference on chart changes), custom viz get `bundle.lowerBoundT`/`bundle.lowerBoundTime` binary-search helpers for visible-window culling, the default 2D highway's beat lines no longer scan every beat in the song per frame, and the 3D highway stops reading `localStorage` per frame (1 Hz poll) and caches its lyrics text-measurement layout per displayed line instead of re-measuring every syllable every frame. + ### Added - **The tuner now tracks what tuning your instrument is *actually* in, so it prompts you to retune in BOTH directions — down to a song's tuning, and back up when the next song needs it.** The coverage check used to compare each song against your fixed instrument-profile tuning, so it only ever prompted you *away* from "home" (e.g. E → Drop C#) and stayed silent coming back (Drop C# → E), even though you'd physically retuned. It now reads the host's live **per-instrument working tuning** (`window.feedBack.workingTuning`) — what your selected instrument is currently in — so coverage is measured against your *actual* tuning and fires both ways. When you clear an auto-opened tuner, the tuner publishes that song's tuning as your instrument's live working tuning (`assumed` — an explicit "I tuned / Skip" refines it in a later PR), so the next song is judged against where you now are. **Per-instrument** — your guitar's and bass's tunings are tracked separately (keyed like the selector), so switching instruments uses the right one. Feature-detected: on a host without the working-tuning capability it falls back to the static `/api/settings` tuning (today's behavior). `plugins/tuner/screen.js` (`_playerTuning` reads `workingTuning` keyed by the selected instrument; `_publishWorkingTuning` writes on clear). Builds on the host `workingTuning` foundation (PR 1 of the series) + the instrument→chart routing (PR 2). Tests: `tests/js/tuner_auto_open.test.js` (both-directions coverage via a live Drop-D working tuning; publish-on-clear targets the right instrument slot) — 29 pass. - **`.jsonc` support for feedpak data files** (feedpak-spec §8, FEP #3 / PR #13). Hand-edited packs may now use the `.jsonc` extension (JSON with C-style `//` line and `/* */` block comments) for any data file the manifest points at — arrangements, notation sidecars, `drum_tab`, `song_timeline`, `lyrics`, and `keys`. New shared `lib/jsonc.py` provides `parse_jsonc(text)` + `load_json(path)` (auto-detects `.jsonc` by suffix, string-aware so comment-like text inside JSON string values is preserved) and is now used by every reader in `lib/sloppak.py` (six side-file sites) and `scripts/lift_keys_notation.py` (three arrangement / song_timeline read sites). The strip regex mirrors the reference validator in `feedpak-spec/tools/validate.py`. This is an additive (MINOR) change: `.jsonc` is opt-in, so any pack that keeps its data files as `.json` is unaffected and needs no regeneration. Note that a `.jsonc` file containing real comments only loads on a reader that implements §8 — a pre-this-change reader calls bare `json.loads` and fails on the comments rather than ignoring them, so don't hand out `.jsonc` packs to older hosts. Tests: `tests/test_sloppak_jsonc_load.py` (covers all six side-file types, the lift helper, and the string-boundary preservation rule end-to-end). diff --git a/CLAUDE.md b/CLAUDE.md index 74e6f66..819359f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -231,6 +231,18 @@ window.feedBackViz_my_viz = function () { // toneChanges, toneBase, mastery, hasPhraseData, inverted, // lefty, renderScale, lyricsVisible, the 2D coordinate // helpers project and fretX, and getNoteState (see below). + // The bundle OBJECT is reused across frames (mutated in + // place — no per-frame allocation): never cache it or + // compare its identity between frames; field values are + // only valid for the current draw call. Array FIELDS still + // swap reference when chart data changes, so field-identity + // caches (`myRef !== bundle.chords`) remain valid. + // Windowed-iteration helpers (stable fn refs): bundle + // .lowerBoundT(arr, time) is a lower-bound binary search on + // `.t` (notes/chords); bundle.lowerBoundTime(arr, time) on + // `.time` (beats/anchors/sections). Use these to cull to + // the visible window instead of full-scanning chart arrays + // per frame. // `stringCount` is the active arrangement's string count (4 // for bass, 6 for guitar, 7+ for extended-range GP imports — // size string-indexed geometry against this, not a hardcoded diff --git a/plugins/highway_3d/CLAUDE.md b/plugins/highway_3d/CLAUDE.md index de8c6d4..32e4e57 100644 --- a/plugins/highway_3d/CLAUDE.md +++ b/plugins/highway_3d/CLAUDE.md @@ -157,6 +157,8 @@ Every per-frame renderer call receives a `bundle` from feedBack core. Fields use `tuning` and `capo` aren't consumed by this plugin. +Core reuses the bundle OBJECT across frames (mutated in place); never cache it or compare its identity between frames — field values are only valid for the current draw call. Field-identity caches on ARRAY fields (this plugin's `_mergeCacheChordsRef === bundle.chords` etc.) remain valid: arrays still swap reference when chart data changes. Core also exposes `bundle.lowerBoundT(arr, time)` (lower-bound on `.t`, notes/chords) and `bundle.lowerBoundTime(arr, time)` (on `.time`, beats/anchors/sections) — prefer these over the local `lowerBoundT` helper when a downlevel-host fallback isn't needed. + ### Score FX (notedetect game-scoring layer) - **"+N" score pops** → `_fxSpawnPop()` from `drawNote()` (just after the provider verdict-override block), drawn by `drawScoreFx()` (called from the `lyricsCtx` block in `draw()`, right after `drawNotedetectLabels()`). Fixed 24-slot pool (`_fxPops`), deduped per `popKey` via the TTL'd `_fxSeen` map (pruned in `drawScoreFx`). Pops rise/fade over 700 ms; font size scales with the multiplier tier. diff --git a/plugins/highway_3d/screen.js b/plugins/highway_3d/screen.js index ba804af..2f1a8ee 100644 --- a/plugins/highway_3d/screen.js +++ b/plugins/highway_3d/screen.js @@ -4677,10 +4677,12 @@ // the full look (re-enable the rail bloom) per browser, no rebuild: // localStorage.h3d_full_sus = '1' // re-enable rail bloom halo // delete localStorage.h3d_full_sus // back to lean default - // Read once per frame at the top of update() so the flag takes effect - // live. The bloom pool/material/gaussian texture are kept intact + // Polled at ~1 Hz at the top of update() (perf: localStorage reads + // are synchronous) so the console flag still takes effect live. + // The bloom pool/material/gaussian texture are kept intact // (still pinned by the bloom unit tests and used by the opt-out path). let _leanSus = true; + let _leanSusPollCounter = 0; // Lifecycle flags let _isReady = false; @@ -6250,6 +6252,12 @@ return boxH; } + // Lyrics layout cache — measureText per syllable + row wrapping + // only changes when the displayed line(s), font size, or canvas + // width change, not per frame. Keyed below; the per-frame work is + // just drawing over the cached widths. + let _lyrRowsCache = null; + function drawLyrics(lyrics, currentTime, ctx, W, H) { if (!lyrics._lines) { const lines = []; @@ -6297,37 +6305,51 @@ const sylText = s => { const t = s.w || ''; return (t.endsWith('+') || t.endsWith('-')) ? t.slice(0, -1) : t; }; ctx.font = `bold ${fontSize}px sans-serif`; - const spaceWidth = ctx.measureText(' ').width; - const maxWidth = W * 0.8; + let rows, spaceWidth, bgWidth; + const _lc = _lyrRowsCache; + if (_lc && _lc.lyricsRef === lyrics && _lc.idx === currentIdx + && _lc.shown === linesToShow.length + && _lc.fontSize === fontSize && _lc.W === W) { + rows = _lc.rows; spaceWidth = _lc.spaceWidth; bgWidth = _lc.bgWidth; + } else { + spaceWidth = ctx.measureText(' ').width; + const maxWidth = W * 0.8; - const rows = []; - for (const authoredLine of linesToShow) { - let row = [], rowWidth = 0; - for (const wordSyls of authoredLine.words) { - const parts = []; - let wordWidth = 0; - for (const s of wordSyls) { - const text = sylText(s); - const w = ctx.measureText(text).width; - parts.push({ syl: s, text, width: w }); - wordWidth += w; + rows = []; + for (const authoredLine of linesToShow) { + let row = [], rowWidth = 0; + for (const wordSyls of authoredLine.words) { + const parts = []; + let wordWidth = 0; + for (const s of wordSyls) { + const text = sylText(s); + const w = ctx.measureText(text).width; + parts.push({ syl: s, text, width: w }); + wordWidth += w; + } + const advance = wordWidth + spaceWidth; + if (row.length > 0 && rowWidth + advance > maxWidth) { rows.push(row); row = []; rowWidth = 0; } + row.push({ parts, advance }); + rowWidth += advance; } - 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); } - if (row.length) rows.push(row); + + 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); + _lyrRowsCache = { + lyricsRef: lyrics, idx: currentIdx, + shown: linesToShow.length, fontSize, W, + rows, spaceWidth, bgWidth, + }; } const rowHeight = fontSize + 6; const totalHeight = rows.length * rowHeight + 10; - let bgWidth = 0; - for (const row of rows) { - const rw = row.reduce((s, w) => s + w.advance, 0) - spaceWidth; - if (rw > bgWidth) bgWidth = rw; - } - bgWidth = Math.min(bgWidth + 30, W * 0.85); ctx.fillStyle = 'rgba(0,0,0,0.7)'; ctx.beginPath(); @@ -9958,10 +9980,14 @@ // Lean sustain rendering is the default (see declaration above): // the trail/ribbon outline always draws; only the additive rail // bloom halo is dropped. The full look (with bloom) is an opt-out. - // Cheap per-frame read so the console flag takes effect live. - try { - _leanSus = localStorage.getItem('h3d_full_sus') !== '1'; - } catch (_) { _leanSus = true; } + // localStorage.getItem is a synchronous storage read — polled at + // ~1 Hz instead of every frame; the console flag still takes + // effect live (within a second). + if ((_leanSusPollCounter++ % 60) === 0) { + try { + _leanSus = localStorage.getItem('h3d_full_sus') !== '1'; + } catch (_) { _leanSus = true; } + } // Materialize the text-size multiplier from the user's slider. // textSize ∈ [0,1]; _textSizeMul ∈ [0.5, 1.5] with 0.5 ↦ 1.0× // so default behaviour matches what the renderer did pre-slider. diff --git a/static/highway.js b/static/highway.js index dad37f0..a21679f 100644 --- a/static/highway.js +++ b/static/highway.js @@ -746,103 +746,121 @@ function createHighway() { // on the freshly-mounted canvas. let _currentCanvasContextType = '2d'; + // One persistent bundle object per createHighway() instance — + // _makeBundle() mutates its fields in place each call instead of + // allocating a fresh ~35-field object per rAF frame (steady GC churn + // on weak hardware, ×N under splitscreen). Consequences for + // consumers: the bundle OBJECT's identity is stable across frames + // and carries no meaning; its field values are only valid for the + // duration of the current draw call. Array fields (`notes`, + // `chords`, `handShapes`, `chordTemplates`, ...) still swap + // reference whenever chart data changes — field-identity caches + // (e.g. highway_3d's merge caches) rely on that invariant. + const _bundleReused = {}; + 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, + // Arrays and songInfo are LIVE references, not copies — the + // bundle's `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 + // cost reasons. + const b = _bundleReused; + // Timing + b.currentTime = currentTime; + b.songInfo = songInfo; + b.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. + b.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, + // Chart content (filter-aware — difficulty-filtered arrays + // preferred; raw arrays are the fallback when no ladder data). + b.notes = _filteredNotes !== null ? _filteredNotes : notes; + b.chords = _filteredChords !== null ? _filteredChords : chords; + b.anchors = _filteredAnchors !== null ? _filteredAnchors : anchors; + b.beats = beats; + b.sections = sections; + b.chordTemplates = chordTemplates; + b.stringCount = stringCount; + // Mirrors song_info tuning capo offsets (±semitones from the + // instrument’s standard open-string layout). Live reference. + b.tuning = songInfo?.tuning; + b.capo = songInfo?.capo; + b.lyrics = lyrics; + b.lyricsSource = lyricsSource; + b.toneChanges = toneChanges; + b.toneBase = 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. + b.drumTab = 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, + // Master-difficulty (feedBack#48) + b.mastery = _mastery; + b.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). + b.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, + // Display flags + b.inverted = _inverted; + b.lefty = _lefty; + b.renderScale = _effectiveRenderScale(); + b.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). + b.teachingMarksVisible = _showTeachingMarks; + b.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, + // 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. + b.project = project; + b.fretX = fretX; + // Windowed-iteration helpers (stable references): lower-bound + // binary searches so custom viz don't full-scan chart arrays per + // frame. lowerBoundT keys on `.t` (notes / chords); lowerBoundTime + // keys on `.time` (beats / anchors / sections). + b.lowerBoundT = bsearch; + b.lowerBoundTime = bsearchTime; - // 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 - }; + // 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 }. + b.getNoteState = _noteState; // stable reference + // 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. + b.getNoteStateProvider = _getNoteStateProvider; // stable — see above + return b; } const _defaultRenderer = { @@ -1534,7 +1552,13 @@ function createHighway() { } function drawBeats(W, H) { - for (const beat of beats) { + // Window the beat scan — a long song carries thousands of beats + // and iterating (and projecting) all of them per frame was pure + // waste; project() culling stays as the safety net. + const lo = bsearchTime(beats, currentTime - 0.25); + const hi = bsearchTime(beats, currentTime + VISIBLE_SECONDS + 0.25); + for (let i = lo; i < hi; i++) { + const beat = beats[i]; const tOff = beat.time - currentTime; const p = project(tOff); if (!p || p.scale < 0.06) continue; @@ -2645,6 +2669,19 @@ function createHighway() { } return lo; } + // Lower-bound binary search for `.time`-keyed arrays (beats, anchors, + // sections) — bsearch/bsearchChords key on `.t` and would compare + // against undefined here. Exposed to custom viz as + // bundle.lowerBoundTime. + function bsearchTime(arr, time) { + let lo = 0, hi = arr.length; + while (lo < hi) { + const mid = (lo + hi) >> 1; + if (arr[mid].time < time) lo = mid + 1; + else hi = mid; + } + return lo; + } // ── Chord rendering — chains, frames, fretline preview (feedBack#88) ── // diff --git a/tests/js/handshapes_pipeline.test.js b/tests/js/handshapes_pipeline.test.js index c01c4de..4312333 100644 --- a/tests/js/handshapes_pipeline.test.js +++ b/tests/js/handshapes_pipeline.test.js @@ -62,7 +62,7 @@ test('bundle exposes handShapes to renderers with flat-list fallback', () => { const src = fs.readFileSync(HIGHWAY_JS, 'utf8'); assert.match( src, - /\bhandShapes:\s*\([^)]*_filteredHandShapes[^)]*\)\s*\?\s*_filteredHandShapes\s*:\s*handShapes\b/, + /\bhandShapes\s*[:=]\s*\([^)]*_filteredHandShapes[^)]*\)\s*\?\s*_filteredHandShapes\s*:\s*handShapes\b/, 'bundle must expose handShapes with the _filteredHandShapes-vs-handShapes ternary fallback', ); }); diff --git a/tests/js/highway_3d_lefty.test.js b/tests/js/highway_3d_lefty.test.js index 8d14538..b671f0b 100644 --- a/tests/js/highway_3d_lefty.test.js +++ b/tests/js/highway_3d_lefty.test.js @@ -20,7 +20,7 @@ function src(file) { test('highway renderer bundles surface the core lefty flag', () => { assert.match( src(HIGHWAY_JS), - /lefty\s*:\s*_lefty/, + /lefty\s*[:=]\s*_lefty/, 'custom renderer bundles must include lefty: _lefty', ); }); diff --git a/tests/js/highway_3d_smooth_clock_pause.test.js b/tests/js/highway_3d_smooth_clock_pause.test.js index 6f4c9bd..1829a74 100644 --- a/tests/js/highway_3d_smooth_clock_pause.test.js +++ b/tests/js/highway_3d_smooth_clock_pause.test.js @@ -43,13 +43,13 @@ test('core _makeBundle exposes isPlaying derived from the chart-clock anchor', ( const src = fs.readFileSync(highwayJs, 'utf8'); const fn = extractBlock(src, 'function _makeBundle()'); // Field present in the bundle. - assert.match(fn, /\bisPlaying\s*:/, 'bundle must expose isPlaying'); + assert.match(fn, /\bisPlaying\s*[:=]/, 'bundle must expose isPlaying'); // It is computed from the same anchor/advance state getTime() uses, not a // hardcoded literal — anchor must exist AND the clock must have advanced // within the interp cap. assert.match( fn, - /isPlaying\s*:\s*!Number\.isNaN\(\s*_chartAnchorPerfNow\s*\)/, + /isPlaying\s*[:=]\s*!Number\.isNaN\(\s*_chartAnchorPerfNow\s*\)/, 'isPlaying must gate on a live anchor (_chartAnchorPerfNow not NaN)', ); assert.match( diff --git a/tests/js/highway_adaptive_scale.test.js b/tests/js/highway_adaptive_scale.test.js index 4710301..86d937d 100644 --- a/tests/js/highway_adaptive_scale.test.js +++ b/tests/js/highway_adaptive_scale.test.js @@ -82,7 +82,7 @@ test('draw() only adapts during active playback and feeds the HUD', () => { test('bundle + canvas sizing use the effective scale, not the raw user value', () => { const src = fs.readFileSync(highwayJs, 'utf8'); - assert.match(src, /renderScale:\s*_effectiveRenderScale\(\)/, 'bundle.renderScale must be the effective scale'); + assert.match(src, /renderScale\s*[:=]\s*_effectiveRenderScale\(\)/, 'bundle.renderScale must be the effective scale'); assert.match(src, /canvas\.width\s*=\s*Math\.round\(w\s*\*\s*_effectiveRenderScale\(\)\)/, 'canvas backing store must use effective scale'); }); diff --git a/tests/js/highway_note_state.test.js b/tests/js/highway_note_state.test.js index dcebf85..4bf869e 100644 --- a/tests/js/highway_note_state.test.js +++ b/tests/js/highway_note_state.test.js @@ -51,7 +51,7 @@ test('_makeBundle exposes getNoteState (stable reference, no per-frame alloc)', const fn = extractBlock(src, 'function _makeBundle()'); // The bundle field must point straight at _noteState — not a fresh // arrow each frame (the per-frame allocation the review flagged). - assert.match(fn, /getNoteState:\s*_noteState\b/, 'bundle.getNoteState must be the stable _noteState reference'); + assert.match(fn, /getNoteState\s*[:=]\s*_noteState\b/, 'bundle.getNoteState must be the stable _noteState reference'); }); test('_makeBundle exposes getNoteStateProvider as a stable reference (feedBack#254)', () => { @@ -64,7 +64,7 @@ test('_makeBundle exposes getNoteStateProvider as a stable reference (feedBack#2 // identity-based guards in renderer code. assert.match( fn, - /getNoteStateProvider:\s*_getNoteStateProvider\b/, + /getNoteStateProvider\s*[:=]\s*_getNoteStateProvider\b/, 'bundle.getNoteStateProvider must be the stable _getNoteStateProvider reference (not a per-frame arrow)' ); // Sanity: the stable accessor exists per-createHighway-instance