refactor(highway): make the highway global explicit before the module flip (R3c) (#912)

73 bare `highway.x` references -> `window.highway.x`, across app.js and 10 other files.
Provably a NO-OP today. It is the precondition for flipping highway.js to a module.

━━━ WHY THIS HAS TO LAND FIRST ━━━

highway.js is a CLASSIC script. Its top-level `const highway = createHighway()` therefore
creates a GLOBAL LEXICAL BINDING — visible as a bare name to every other classic script AND
to every ES module. 73 call sites quietly rely on that.

The moment highway.js becomes a module, that binding is gone. `const` in a module is
module-scoped, not global. Every one of those 73 sites becomes a ReferenceError, and the
flip is impossible until they say what they mean.

`window.highway = highway` is already set, to the same object, on the same line. So this is
an identity rewrite — verified in the browser below.

━━━ THE REWRITE BIT ME THREE TIMES. REGEX IS NOT ENOUGH FOR THIS. ━━━

1. A SHADOWED LOCAL. capabilities/note-detection.js does `const highway = window.highway`.
   Its 9 bare uses are LOCAL and already correct; a blind rewrite would have emitted
   `const window.highway = window.highway`. Excluded.

2. HALF-CONVERTED GUARDS — the dangerous one. Six sites read
   `typeof highway !== 'undefined' && highway && typeof highway.setTime === 'function'`.
   The regex converted the CONSEQUENT and left the TEST, which is WORSE than not touching
   them: after the flip `typeof highway` is 'undefined', so each guard is PERMANENTLY FALSE
   and the code behind it silently never runs. transport.js's was the seek->setTime sync:
   the chart clock would have quietly desynced after every seek, with nothing failing.
   All six now test window.highway.

3. TWO MORE BARE REFERENCES, found by Codex [P2] and confirmed by an AST scan: app.js:3114
   and :3176 use `highway && typeof window.highway.getSections === 'function'`. My grep
   searched for `typeof highway`, not `highway &&`. After the flip these throw, the catch
   swallows it, and the editor silently falls back to a ±4s edit window and arrangement 0.

Regex missed a shadow, a half-conversion, and two bare reads. The final check is an
AST pass that resolves scopes and reports every `highway` identifier not bound locally.
It now reports ZERO.

VERIFIED. A/B against origin/main in two browsers, 15 probes, IDENTICAL, zero page errors:
window.highway is the same object as the bare global, the whole API surface resolves, a real
song plays, the chart clock advances, getPerf().drawMs > 0 — and `seek syncs chart` passes,
which is the exact guard I nearly broke in (2).

node 1045, pytest 2416, ESLint 0, Codex 0.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Byron Gamatos
2026-07-12 11:56:07 +02:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 23ecddc721
commit d9fa6d3f55
14 changed files with 87 additions and 67 deletions
+20 -20
View File
@@ -860,10 +860,10 @@ async function showScreen(id) {
const hadPlayableSong = !!audio.src || !!window._juceAudioUrl || S.isPlaying; const hadPlayableSong = !!audio.src || !!window._juceAudioUrl || S.isPlaying;
// Snapshot where we were so leaving the player — especially by accident // Snapshot where we were so leaving the player — especially by accident
// — is recoverable instead of dumping the user back at bar 1 next time. // — is recoverable instead of dumping the user back at bar 1 next time.
// Must run BEFORE highway.stop()/audio unload, while getSongInfo() and // Must run BEFORE window.highway.stop()/audio unload, while getSongInfo() and
// the position (stopTime) are still live. // the position (stopTime) are still live.
if (hadPlayableSong) _snapshotResumeSession(stopTime); if (hadPlayableSong) _snapshotResumeSession(stopTime);
highway.stop(); window.highway.stop();
// Cancel any queued seeks, in-flight shim closures, AND active // Cancel any queued seeks, in-flight shim closures, AND active
// count-in timers before stopping playback so none of these paths // count-in timers before stopping playback so none of these paths
// can mutate the torn-down session (mirrors the same triple reset // can mutate the torn-down session (mirrors the same triple reset
@@ -1049,7 +1049,7 @@ async function loadSettings() {
const demucsEl = document.getElementById('demucs-server-url'); const demucsEl = document.getElementById('demucs-server-url');
if (demucsEl) demucsEl.value = data.demucs_server_url || ''; if (demucsEl) demucsEl.value = data.demucs_server_url || '';
const leftyEl = document.getElementById('setting-lefty'); const leftyEl = document.getElementById('setting-lefty');
if (leftyEl) leftyEl.checked = highway.getLefty(); if (leftyEl) leftyEl.checked = window.highway.getLefty();
const autoplayExitEl = document.getElementById('setting-autoplay-exit'); const autoplayExitEl = document.getElementById('setting-autoplay-exit');
if (autoplayExitEl) autoplayExitEl.checked = _autoplayExitEnabled(); if (autoplayExitEl) autoplayExitEl.checked = _autoplayExitEnabled();
const showUpNextEl = document.getElementById('setting-show-upnext'); const showUpNextEl = document.getElementById('setting-show-upnext');
@@ -1435,7 +1435,7 @@ function setAvOffsetMs(ms, skipPersist) {
// the audio-aligned chart time so plugins (note detection, etc.) // the audio-aligned chart time so plugins (note detection, etc.)
// keep scoring against the real chart clock regardless of visual // keep scoring against the real chart clock regardless of visual
// calibration. // calibration.
if (typeof highway !== 'undefined' && highway?.setAvOffset) highway.setAvOffset(_avOffsetMs); if (window.highway?.setAvOffset) window.highway.setAvOffset(_avOffsetMs);
// Sync any visible Settings slider // Sync any visible Settings slider
const avSlider = document.getElementById('setting-av-offset'); const avSlider = document.getElementById('setting-av-offset');
if (avSlider) { if (avSlider) {
@@ -1771,7 +1771,7 @@ window._juceAudioUrl = null;
window.jucePlayer = jucePlayer; window.jucePlayer = jucePlayer;
// ── Engine start/stop → re-route song audio (HTML5 ⇄ JUCE) ────────────────── // ── Engine start/stop → re-route song audio (HTML5 ⇄ JUCE) ──────────────────
// window._juceMode is otherwise decided once, at song-load time (highway.js), // window._juceMode is otherwise decided once, at song-load time (window.highway.js),
// from isAudioRunning(). If the JUCE audio engine is started or stopped *after* // from isAudioRunning(). If the JUCE audio engine is started or stopped *after*
// a song is already loaded (e.g. the user presses CHAIN / AMP), that decision // a song is already loaded (e.g. the user presses CHAIN / AMP), that decision
// goes stale: the song stays on the HTML5 <audio> element while the engine // goes stale: the song stays on the HTML5 <audio> element while the engine
@@ -1907,7 +1907,7 @@ function _currentPlaybackSnapshot() {
return { return {
currentTime: Number.isFinite(time) ? time : null, currentTime: Number.isFinite(time) ? time : null,
mediaTime: Number.isFinite(time) ? time : null, mediaTime: Number.isFinite(time) ? time : null,
chartTime: (typeof highway !== 'undefined' && highway && typeof highway.getTime === 'function') ? highway.getTime() : null, chartTime: (typeof window.highway?.getTime === 'function') ? window.highway.getTime() : null,
duration: Number.isFinite(_audioDuration()) ? _audioDuration() : (song && song.duration) || null, duration: Number.isFinite(_audioDuration()) ? _audioDuration() : (song && song.duration) || null,
playbackRate: window._juceMode ? (window.jucePlayer && window.jucePlayer._speed || 1) : audio.playbackRate, playbackRate: window._juceMode ? (window.jucePlayer && window.jucePlayer._speed || 1) : audio.playbackRate,
isPlaying: S.isPlaying, isPlaying: S.isPlaying,
@@ -2587,7 +2587,7 @@ async function playSong(filename, arrangement, options) {
if (artAbortController) artAbortController.abort(); if (artAbortController) artAbortController.abort();
artAbortController = null; artAbortController = null;
highway.stop(); window.highway.stop();
// Cancel any active count-in: clear timers/RAF and bump the gen so // Cancel any active count-in: clear timers/RAF and bump the gen so
// delayed callbacks (rewind frames, post-seek then, count-in ticks, // delayed callbacks (rewind frames, post-seek then, count-in ticks,
// post-count play) bail before mutating the new session. // post-count play) bail before mutating the new session.
@@ -2618,7 +2618,7 @@ async function playSong(filename, arrangement, options) {
} }
audio.pause(); audio.pause();
audio.src = ''; audio.src = '';
// Stale until the incoming song's WS handler (highway.js) sets it again. // Stale until the incoming song's WS handler (window.highway.js) sets it again.
window._currentSongAudio = null; window._currentSongAudio = null;
// Fresh JUCE routing attempt for whatever song loads next. // Fresh JUCE routing attempt for whatever song loads next.
window._clearJuceRerouteMemo?.(); window._clearJuceRerouteMemo?.();
@@ -2655,19 +2655,19 @@ async function playSong(filename, arrangement, options) {
// Wait for previous WebSocket to fully close before opening new one // Wait for previous WebSocket to fully close before opening new one
await new Promise(r => setTimeout(r, 500)); await new Promise(r => setTimeout(r, 500));
highway.init(document.getElementById('highway')); window.highway.init(document.getElementById('highway'));
const wsParams = new URLSearchParams(); const wsParams = new URLSearchParams();
if (arrangement !== undefined) wsParams.set('arrangement', arrangement); if (arrangement !== undefined) wsParams.set('arrangement', arrangement);
wsParams.set('naming_mode', _getArrangementNamingMode()); wsParams.set('naming_mode', _getArrangementNamingMode());
const wsUrl = `${location.protocol === 'https:' ? 'wss:' : 'ws:'}//${location.host}/ws/highway/${decodeURIComponent(filename)}?${wsParams.toString()}`; const wsUrl = `${location.protocol === 'https:' ? 'wss:' : 'ws:'}//${location.host}/ws/highway/${decodeURIComponent(filename)}?${wsParams.toString()}`;
highway.connect(wsUrl); window.highway.connect(wsUrl);
_resetSectionPracticeLog(); _resetSectionPracticeLog();
_scheduleSectionPracticeRetries(); _scheduleSectionPracticeRetries();
loadSavedLoops(); loadSavedLoops();
document.getElementById('quality-select').value = highway.getRenderScale(); document.getElementById('quality-select').value = window.highway.getRenderScale();
const _minScaleSel = document.getElementById('min-scale-select'); const _minScaleSel = document.getElementById('min-scale-select');
if (_minScaleSel && highway.getMinRenderScale) _minScaleSel.value = String(highway.getMinRenderScale()); if (_minScaleSel && window.highway.getMinRenderScale) _minScaleSel.value = String(window.highway.getMinRenderScale());
} }
// Generation token + safety-timeout handle for changeArrangement's // Generation token + safety-timeout handle for changeArrangement's
@@ -2749,7 +2749,7 @@ async function changeArrangement(index) {
const clearMyCallback = () => { const clearMyCallback = () => {
// Only null out if the slot still points at us; a newer // Only null out if the slot still points at us; a newer
// invocation may have replaced it during the await. // invocation may have replaced it during the await.
if (highway._onReady === myCallback) highway._onReady = null; if (window.highway._onReady === myCallback) window.highway._onReady = null;
}; };
const r = await _audioSeek(time, 'arrangement-restore'); const r = await _audioSeek(time, 'arrangement-restore');
// Don't auto-resume on cancel OR off-target landing — same // Don't auto-resume on cancel OR off-target landing — same
@@ -2790,7 +2790,7 @@ async function changeArrangement(index) {
clearBusy(); clearBusy();
clearMyCallback(); clearMyCallback();
}; };
highway._onReady = myCallback; window.highway._onReady = myCallback;
// Reset the Section Practice bar for the incoming arrangement, mirroring // Reset the Section Practice bar for the incoming arrangement, mirroring
// playSong(): different arrangements have different section markers, so // playSong(): different arrangements have different section markers, so
@@ -2803,7 +2803,7 @@ async function changeArrangement(index) {
_resetSectionPracticeLog(); _resetSectionPracticeLog();
invalidateParentCount(); invalidateParentCount();
highway.reconnect(currentFilename, index); window.highway.reconnect(currentFilename, index);
window.feedBack.emit('arrangement:changed', { index, filename: currentFilename }); window.feedBack.emit('arrangement:changed', { index, filename: currentFilename });
} }
} }
@@ -2848,7 +2848,7 @@ if (window.feedBack) window.feedBack.restartCurrentSong = restartCurrentSong;
// Leave the player and return to the screen the song was launched from // Leave the player and return to the screen the song was launched from
// (Esc shortcut uses the same origin-aware target). showScreen() owns the // (Esc shortcut uses the same origin-aware target). showScreen() owns the
// full teardown: song:stop, audio unload, highway.stop(), count-in cancel. // full teardown: song:stop, audio unload, window.highway.stop(), count-in cancel.
function closeCurrentSong() { function closeCurrentSong() {
// A real close (user Escape/✕, or the queue-aware wrapper once the queue is // A real close (user Escape/✕, or the queue-aware wrapper once the queue is
// exhausted) abandons any play-queue so a stale one can't advance later. // exhausted) abandons any play-queue so a stale one can't advance later.
@@ -3111,8 +3111,8 @@ function _resolveEditRegion() {
if (loopA !== null && loopB !== null) return { a: loopA, b: loopB }; if (loopA !== null && loopB !== null) return { a: loopA, b: loopB };
const t = _audioTime(); const t = _audioTime();
try { try {
const secs = (highway && typeof highway.getSections === 'function') const secs = (window.highway && typeof window.highway.getSections === 'function')
? highway.getSections() : []; ? window.highway.getSections() : [];
if (Array.isArray(secs) && secs.length) { if (Array.isArray(secs) && secs.length) {
let start = null, end = null; let start = null, end = null;
for (let i = 0; i < secs.length; i++) { for (let i = 0; i < secs.length; i++) {
@@ -3173,7 +3173,7 @@ function editRegionInEditor() {
const region = _resolveEditRegion(); const region = _resolveEditRegion();
let arrangement = 0; let arrangement = 0;
try { try {
const si = highway && typeof highway.getSongInfo === 'function' ? highway.getSongInfo() : null; const si = window.highway && typeof window.highway.getSongInfo === 'function' ? window.highway.getSongInfo() : null;
if (si && typeof si.arrangement_index === 'number' && si.arrangement_index >= 0) { if (si && typeof si.arrangement_index === 'number' && si.arrangement_index >= 0) {
arrangement = si.arrangement_index; arrangement = si.arrangement_index;
} }
@@ -3337,7 +3337,7 @@ setInterval(() => {
if (_sectionPracticeBarIsReady() && _sectionPracticeSourceSections().length) { if (_sectionPracticeBarIsReady() && _sectionPracticeSourceSections().length) {
_updateSectionPracticeHighlight(ct); _updateSectionPracticeHighlight(ct);
} }
if (!isCountingIn()) highway.setTime(ct); if (!isCountingIn()) window.highway.setTime(ct);
}, 1000 / 60); }, 1000 / 60);
_installSectionPracticeDrawHook(); _installSectionPracticeDrawHook();
+7 -7
View File
@@ -60,7 +60,7 @@ const _CREDITS_HOLD_MS = 3000;
// Backstop: the overlay's primary dismiss is song:play, but playback can fail // Backstop: the overlay's primary dismiss is song:play, but playback can fail
// to start without emitting it (HTML5 autoplay rejection, JUCE start failure, // to start without emitting it (HTML5 autoplay rejection, JUCE start failure,
// a count-in handoff that never plays). This hard cap guarantees the credits // a count-in handoff that never plays). This hard cap guarantees the credits
// never linger over the highway. Generous enough to outlast a normal count-in. // never linger over the window.highway. Generous enough to outlast a normal count-in.
const _CREDITS_MAX_MS = 12000; const _CREDITS_MAX_MS = 12000;
export function _cancelCountIn() { export function _cancelCountIn() {
_countInGen++; _countInGen++;
@@ -107,7 +107,7 @@ function _creditLineLabel(role) {
return key.charAt(0).toUpperCase() + key.slice(1) + ' by'; return key.charAt(0).toUpperCase() + key.slice(1) + ' by';
} }
// Show the feedpak contributor credits over the highway. `authors` is the // Show the feedpak contributor credits over the window.highway. `authors` is the
// sanitized [{name, role}] list from window.feedBack.currentSong.authors. // sanitized [{name, role}] list from window.feedBack.currentSong.authors.
// Anchored to the lower third (bottom-center) so it never collides with the // Anchored to the lower third (bottom-center) so it never collides with the
// vertically-centered count-in number, and pointer-events-none so it never // vertically-centered count-in number, and pointer-events-none so it never
@@ -196,7 +196,7 @@ export async function startCountIn(opts = {}) {
return; return;
} }
S.lastAudioTime = loopA; S.lastAudioTime = loopA;
highway.setTime(loopA); window.highway.setTime(loopA);
if (window.feedBack) { if (window.feedBack) {
window.feedBack.emit('loop:restart', { loopA, loopB, time: loopA }); window.feedBack.emit('loop:restart', { loopA, loopB, time: loopA });
} }
@@ -217,7 +217,7 @@ export async function startCountIn(opts = {}) {
// Ease out quad // Ease out quad
const eased = 1 - (1 - t) * (1 - t); const eased = 1 - (1 - t) * (1 - t);
const currentT = fromTime + (toTime - fromTime) * eased; const currentT = fromTime + (toTime - fromTime) * eased;
highway.setTime(currentT); window.highway.setTime(currentT);
if (t < 1) { if (t < 1) {
_countInRaf = requestAnimationFrame(rewindStep); _countInRaf = requestAnimationFrame(rewindStep);
} else { } else {
@@ -262,7 +262,7 @@ export async function startCountIn(opts = {}) {
// marker for "new iteration starts at A", not the actual // marker for "new iteration starts at A", not the actual
// audio position. // audio position.
S.lastAudioTime = r.to; S.lastAudioTime = r.to;
highway.setTime(r.to); window.highway.setTime(r.to);
window.feedBack.emit('loop:restart', { loopA, loopB, time: loopA }); window.feedBack.emit('loop:restart', { loopA, loopB, time: loopA });
beginCount(); beginCount();
}); });
@@ -271,7 +271,7 @@ export async function startCountIn(opts = {}) {
_countInRaf = requestAnimationFrame(rewindStep); _countInRaf = requestAnimationFrame(rewindStep);
function beginCount() { function beginCount() {
const bpm = highway.getBPM(loopA); const bpm = window.highway.getBPM(loopA);
const beatInterval = 60 / bpm; const beatInterval = 60 / bpm;
let count = 0; let count = 0;
@@ -339,7 +339,7 @@ export async function startSongCountIn() {
} }
if (gen !== _countInGen) return; // teardown during pause if (gen !== _countInGen) return; // teardown during pause
const startT = S.lastAudioTime || 0; const startT = S.lastAudioTime || 0;
let bpm = highway.getBPM(startT); let bpm = window.highway.getBPM(startT);
// Pre-chart / malformed-tempo fallback: 4 beats at 120 BPM (500 ms each). // Pre-chart / malformed-tempo fallback: 4 beats at 120 BPM (500 ms each).
if (!Number.isFinite(bpm) || bpm <= 0) bpm = 120; if (!Number.isFinite(bpm) || bpm <= 0) bpm = 120;
const beatInterval = 60 / bpm; const beatInterval = 60 / bpm;
+1 -1
View File
@@ -155,7 +155,7 @@ function _hwcSlotKeysForChart(sc, isBass) {
return ['low8', 'low7', 'lowE', 'A', 'D', 'G', 'B', 'highE']; return ['low8', 'low7', 'lowE', 'A', 'D', 'G', 'B', 'highE'];
} }
// Current arrangement shape (string count + bass-vs-guitar) from the 2D highway. // Current arrangement shape (string count + bass-vs-guitar) from the 2D window.highway.
function _hwcChartShape() { function _hwcChartShape() {
let sc = 6, arr = ''; let sc = 6, arr = '';
try { sc = window.highway?.getStringCount?.() || 6; } catch (_) {} try { sc = window.highway?.getStringCount?.() || 6; } catch (_) {}
+2 -2
View File
@@ -111,7 +111,7 @@ import { S } from './player-state.js';
return false; return false;
} }
} }
// highway.js's initial song-load routing consults this for the same // window.highway.js's initial song-load routing consults this for the same
// feedpak-under-exclusive decision the watcher makes below. // feedpak-under-exclusive decision the watcher makes below.
window._juceOutputIsExclusive = _outputIsExclusive; window._juceOutputIsExclusive = _outputIsExclusive;
// Returns true when window._currentSongAudio no longer references the exact // Returns true when window._currentSongAudio no longer references the exact
@@ -383,7 +383,7 @@ import { S } from './player-state.js';
// the stem mixer / WebAudio path keeps working. Sloppak stem URLs // the stem mixer / WebAudio path keeps working. Sloppak stem URLs
// are never routable (per-stem mix can't ride a single transport). // are never routable (per-stem mix can't ride a single transport).
if (!songAudio || (!songAudio.juceEligible && !songAudio.feedpakFullMix)) return; if (!songAudio || (!songAudio.juceEligible && !songAudio.feedpakFullMix)) return;
// Don't race highway.js's own initial song-load routing: it owns // Don't race window.highway.js's own initial song-load routing: it owns
// _juceMode until _juceRoutingPromise settles. Re-running our switch // _juceMode until _juceRoutingPromise settles. Re-running our switch
// concurrently would double-call loadBackingTrack for the same URL. // concurrently would double-call loadBackingTrack for the same URL.
if (window._highwayJuceRoutingPending) return; if (window._highwayJuceRoutingPending) return;
+2 -2
View File
@@ -160,7 +160,7 @@ export function _resetPlaybackSpeedForNewSong() {
// //
// Debounced trailing-edge (300ms) so dragging the slider — which fires // Debounced trailing-edge (300ms) so dragging the slider — which fires
// oninput per pixel — doesn't flood the server with concurrent writes // oninput per pixel — doesn't flood the server with concurrent writes
// to config.json. highway.setMastery() still fires every oninput so // to config.json. window.highway.setMastery() still fires every oninput so
// the chart re-filters in real time; only disk persistence waits. // the chart re-filters in real time; only disk persistence waits.
let _masteryPersistTimer = null; let _masteryPersistTimer = null;
function _persistMastery(pct) { function _persistMastery(pct) {
@@ -209,7 +209,7 @@ export function _applyMastery(v, opts = {}) {
// unlike #mastery-label above, whose markup carries no trailing unit. // unlike #mastery-label above, whose markup carries no trailing unit.
const setLabel = document.getElementById('setting-highway-speed-val'); const setLabel = document.getElementById('setting-highway-speed-val');
if (setLabel) setLabel.textContent = pct; if (setLabel) setLabel.textContent = pct;
highway.setMastery(pct / 100); window.highway.setMastery(pct / 100);
if (!opts.skipPersist) _persistMastery(pct); if (!opts.skipPersist) _persistMastery(pct);
} }
// Reflect phrase-data availability on the slider after every `ready`. // Reflect phrase-data availability on the slider after every `ready`.
+3 -3
View File
@@ -31,12 +31,12 @@ const _RESUME_END_GUARD_S = 5; // ignore basically-finished
let _resumePillDismissed = false; // per-session: user waved off the current snapshot let _resumePillDismissed = false; // per-session: user waved off the current snapshot
// Snapshot the live session. Called from showScreen()'s teardown before // Snapshot the live session. Called from showScreen()'s teardown before
// highway.stop()/audio unload, while getSongInfo() + position are still valid. // window.highway.stop()/audio unload, while getSongInfo() + position are still valid.
export function _snapshotResumeSession(position) { export function _snapshotResumeSession(position) {
try { try {
if (!host.currentFilename()) return; if (!host.currentFilename()) return;
const si = (window.highway && typeof highway.getSongInfo === 'function') const si = (window.highway && typeof window.highway.getSongInfo === 'function')
? (highway.getSongInfo() || {}) : {}; ? (window.highway.getSongInfo() || {}) : {};
const dur = Number(si.duration) || 0; const dur = Number(si.duration) || 0;
const pos = Number(position) || 0; const pos = Number(position) || 0;
// Only worth resuming a song you were genuinely mid-way through — not a // Only worth resuming a song you were genuinely mid-way through — not a
+2 -2
View File
@@ -38,7 +38,7 @@ export function _sectionPracticeBarContains(el) {
} }
// ── Section Practice Bar ──────────────────────────────────────────────── // ── Section Practice Bar ────────────────────────────────────────────────
// One-click looping over song section markers (highway.getSections — // One-click looping over song section markers (window.highway.getSections —
// same array as 3D highway bundle.sections / "Now / Up Next"). // same array as 3D highway bundle.sections / "Now / Up Next").
// Reuses setLoop() so manual A/B controls and saved loops stay canonical. // Reuses setLoop() so manual A/B controls and saved loops stay canonical.
let _sectionPracticeRanges = []; let _sectionPracticeRanges = [];
@@ -127,7 +127,7 @@ export function _resetSectionPracticeLog() {
} }
function _sectionPracticeHighway() { function _sectionPracticeHighway() {
return window.highway || (typeof highway !== 'undefined' ? highway : null); return window.highway || null;
} }
function _sectionPracticeDuration() { function _sectionPracticeDuration() {
+3 -3
View File
@@ -172,7 +172,7 @@ export function _songEventPayload() {
return { return {
time: audioT, time: audioT,
audioT, audioT,
chartT: highway.getTime(), chartT: window.highway.getTime(),
perfNow: performance.now(), perfNow: performance.now(),
}; };
} }
@@ -289,8 +289,8 @@ export async function _audioSeek(s, reason) {
// _audioSeek resolves (e.g. the auto-resume song:play in // _audioSeek resolves (e.g. the auto-resume song:play in
// changeArrangement) sees an in-sync chartT via _songEventPayload. // changeArrangement) sees an in-sync chartT via _songEventPayload.
// Without this, chartT lags by one 60Hz tick after a seek. // Without this, chartT lags by one 60Hz tick after a seek.
if (typeof highway !== 'undefined' && highway && typeof highway.setTime === 'function') { if (window.highway && typeof window.highway.setTime === 'function') {
highway.setTime(to); window.highway.setTime(to);
} }
window.feedBack.emit('song:seek', { from, to, reason: reason || null }); window.feedBack.emit('song:seek', { from, to, reason: reason || null });
return { completed: true, from, to }; return { completed: true, from, to };
+19 -19
View File
@@ -62,7 +62,7 @@ function _hasPromotedFlag() {
// Pending nag: queued during _populateVizPicker, fired on the first // Pending nag: queued during _populateVizPicker, fired on the first
// `song:ready` (so the toast lands when the user actually opens the // `song:ready` (so the toast lands when the user actually opens the
// player, not at page load when they're still in the library). // player, not at page load when they're still in the library).
// `song:ready` is emitted by highway.js via window.feedBack.emit(), so // `song:ready` is emitted by window.highway.js via window.feedBack.emit(), so
// subscribe through the same EventTarget. window.feedBack is created in // subscribe through the same EventTarget. window.feedBack is created in
// this same file before _populateVizPicker is reachable, so the global // this same file before _populateVizPicker is reachable, so the global
// is guaranteed to exist by the time this listener registers — but guard // is guaranteed to exist by the time this listener registers — but guard
@@ -326,7 +326,7 @@ export async function _populateVizPicker(plugins) {
// plugin options — _autoMatchViz saw no candidates and left the // plugin options — _autoMatchViz saw no candidates and left the
// default active. Now that plugins are registered, re-evaluate // default active. Now that plugins are registered, re-evaluate
// against whatever song is currently loaded (a no-op when no song // against whatever song is currently loaded (a no-op when no song
// has been loaded yet, since highway.getSongInfo() returns {}). // has been loaded yet, since window.highway.getSongInfo() returns {}).
if (sel.value === 'auto') _autoMatchViz(); if (sel.value === 'auto') _autoMatchViz();
} }
@@ -356,7 +356,7 @@ function _noteVizAutoMatch(id, matched) {
} }
function _installVizRenderer(renderer, id, source = 'user-select') { function _installVizRenderer(renderer, id, source = 'user-select') {
highway.setRenderer(_tagVizRenderer(renderer, id)); window.highway.setRenderer(_tagVizRenderer(renderer, id));
// Drop any stale notation-view hint now that we have a resolved renderer id. // Drop any stale notation-view hint now that we have a resolved renderer id.
// This is also the path used by _autoMatchViz() after it resolves 'auto' to // This is also the path used by _autoMatchViz() after it resolves 'auto' to
// a real plugin id, so the null passed at evaluation start is corrected here. // a real plugin id, so the null passed at evaluation start is corrected here.
@@ -377,7 +377,7 @@ export function setViz(id) {
try { localStorage.setItem('vizSelection', 'default'); } catch (_) {} try { localStorage.setItem('vizSelection', 'default'); } catch (_) {}
const sel = document.getElementById('viz-picker'); const sel = document.getElementById('viz-picker');
if (sel) sel.value = 'default'; if (sel) sel.value = 'default';
highway.setRenderer(null); window.highway.setRenderer(null);
_syncVenueVizPlayerClass('default'); _syncVenueVizPlayerClass('default');
if (window.v3VenueScene3d && typeof window.v3VenueScene3d.syncViz === 'function') { if (window.v3VenueScene3d && typeof window.v3VenueScene3d.syncViz === 'function') {
window.v3VenueScene3d.syncViz('default'); window.v3VenueScene3d.syncViz('default');
@@ -399,7 +399,7 @@ export function setViz(id) {
try { localStorage.setItem('vizSelection', id || 'default'); } catch (_) {} try { localStorage.setItem('vizSelection', id || 'default'); } catch (_) {}
const _sel = document.getElementById('viz-picker'); const _sel = document.getElementById('viz-picker');
if (_sel) _sel.value = 'default'; if (_sel) _sel.value = 'default';
highway.setRenderer(null); window.highway.setRenderer(null);
_syncVenueVizPlayerClass('default'); _syncVenueVizPlayerClass('default');
if (window.v3VenueScene3d && typeof window.v3VenueScene3d.syncViz === 'function') { if (window.v3VenueScene3d && typeof window.v3VenueScene3d.syncViz === 'function') {
window.v3VenueScene3d.syncViz('default'); window.v3VenueScene3d.syncViz('default');
@@ -483,7 +483,7 @@ export function setViz(id) {
fallbackToDefault(); fallbackToDefault();
return; return;
} }
// Validate shape — highway.setRenderer will itself fall back to // Validate shape — window.highway.setRenderer will itself fall back to
// default on a bad renderer, but without this check the UI and // default on a bad renderer, but without this check the UI and
// localStorage would still advertise the broken selection. // localStorage would still advertise the broken selection.
if (!renderer || typeof renderer.draw !== 'function') { if (!renderer || typeof renderer.draw !== 'function') {
@@ -503,7 +503,7 @@ export function setViz(id) {
// Auto mode: evaluate each registered viz factory's static // Auto mode: evaluate each registered viz factory's static
// `matchesArrangement(songInfo)` predicate and install the first // `matchesArrangement(songInfo)` predicate and install the first
// matching renderer. No match → fall back to the built-in 2D highway. // matching renderer. No match → fall back to the built-in 2D window.highway.
// //
// vizSelection stays 'auto' across invocations so the next song:ready // vizSelection stays 'auto' across invocations so the next song:ready
// re-evaluates. An explicit picker choice overrides Auto by persisting // re-evaluates. An explicit picker choice overrides Auto by persisting
@@ -530,10 +530,10 @@ function _setAutoVizLabel(resolvedText) {
let _cancelPendingAutoLabel = null; let _cancelPendingAutoLabel = null;
// One-shot (per song) hint shown when a notation-only arrangement falls back // One-shot (per song) hint shown when a notation-only arrangement falls back
// to the built-in 2D highway. Such arrangements carry no wire notes // to the built-in 2D window.highway. Such arrangements carry no wire notes
// (sloppak-spec §5.3: `file:` may be omitted when `notation:` is present), so // (sloppak-spec §5.3: `file:` may be omitted when `notation:` is present), so
// the default renderer draws an empty board — without this the user is left // the default renderer draws an empty board — without this the user is left
// staring at a silently blank highway. Core ships no notation view; point at // staring at a silently blank window.highway. Core ships no notation view; point at
// the viz picker instead. // the viz picker instead.
let _notationHintShownFor = null; let _notationHintShownFor = null;
function _showNotationViewHint(arrangementIndex, activeVizId) { function _showNotationViewHint(arrangementIndex, activeVizId) {
@@ -579,8 +579,8 @@ function _dropStaleNotationHint(activeVizId) {
const curFilename = (window.feedBack && window.feedBack.currentSong const curFilename = (window.feedBack && window.feedBack.currentSong
&& window.feedBack.currentSong.filename) || ''; && window.feedBack.currentSong.filename) || '';
if (stale.dataset.filename !== curFilename) { stale.remove(); return; } if (stale.dataset.filename !== curFilename) { stale.remove(); return; }
const songInfo = (typeof highway !== 'undefined' && typeof highway.getSongInfo === 'function') const songInfo = (typeof window.highway?.getSongInfo === 'function')
? (highway.getSongInfo() || {}) : {}; ? (window.highway.getSongInfo() || {}) : {};
const curArrIdx = songInfo.arrangement_index != null ? String(songInfo.arrangement_index) : null; const curArrIdx = songInfo.arrangement_index != null ? String(songInfo.arrangement_index) : null;
if (curArrIdx !== null && stale.dataset.arrangementIndex !== undefined if (curArrIdx !== null && stale.dataset.arrangementIndex !== undefined
&& stale.dataset.arrangementIndex !== curArrIdx) { && stale.dataset.arrangementIndex !== curArrIdx) {
@@ -593,8 +593,8 @@ function _dropStaleNotationHint(activeVizId) {
export function _maybeShowNotationViewHint(activeVizId) { export function _maybeShowNotationViewHint(activeVizId) {
_dropStaleNotationHint(activeVizId); _dropStaleNotationHint(activeVizId);
const songInfo = (typeof highway !== 'undefined' && typeof highway.getSongInfo === 'function') const songInfo = (typeof window.highway?.getSongInfo === 'function')
? (highway.getSongInfo() || {}) : {}; ? (window.highway.getSongInfo() || {}) : {};
const activeArr = Array.isArray(songInfo.arrangements) const activeArr = Array.isArray(songInfo.arrangements)
? songInfo.arrangements.find(a => a.index === songInfo.arrangement_index) ? songInfo.arrangements.find(a => a.index === songInfo.arrangement_index)
: null; : null;
@@ -641,8 +641,8 @@ export function _autoMatchViz() {
// Reset label at evaluation start so a stale resolved label never persists // Reset label at evaluation start so a stale resolved label never persists
// if the song changes or the picker re-evaluates with a different outcome. // if the song changes or the picker re-evaluates with a different outcome.
_setAutoVizLabel(null); _setAutoVizLabel(null);
const songInfo = (typeof highway !== 'undefined' && typeof highway.getSongInfo === 'function') const songInfo = (typeof window.highway?.getSongInfo === 'function')
? (highway.getSongInfo() || {}) : {}; ? (window.highway.getSongInfo() || {}) : {};
// Only update the label when a real song is loaded. Before the first // Only update the label when a real song is loaded. Before the first
// song_info frame, getSongInfo() returns {} — leaving the reset state // song_info frame, getSongInfo() returns {} — leaving the reset state
// ("Auto (match arrangement)") is correct; we haven't evaluated yet. // ("Auto (match arrangement)") is correct; we haven't evaluated yet.
@@ -714,17 +714,17 @@ export function _autoMatchViz() {
_noteVizAutoMatch(id, true); _noteVizAutoMatch(id, true);
return; return;
} }
// No match — restore the built-in 2D highway. setRenderer(null) is // No match — restore the built-in 2D window.highway. setRenderer(null) is
// a no-op when the default is already active. If the previous Auto // a no-op when the default is already active. If the previous Auto
// pick was a WebGL renderer, highway.setRenderer() handles the // pick was a WebGL renderer, window.highway.setRenderer() handles the
// context-type change by replacing the canvas element (cloneNode + // context-type change by replacing the canvas element (cloneNode +
// replaceWith) so the default 2D renderer's getContext('2d') always // replaceWith) so the default 2D renderer's getContext('2d') always
// succeeds — no canvas-lock limitation here. // succeeds — no canvas-lock limitation here.
highway.setRenderer(null); window.highway.setRenderer(null);
_notifyVizDomain('default', 'auto-match'); _notifyVizDomain('default', 'auto-match');
_noteVizAutoMatch('default', false); _noteVizAutoMatch('default', false);
// Update the label so the user can see Auto resolved to the built-in // Update the label so the user can see Auto resolved to the built-in
// highway. Read from the DOM rather than hard-coding the name so a // window.highway. Read from the DOM rather than hard-coding the name so a
// future rename of the default entry is automatically reflected. // future rename of the default entry is automatically reflected.
if (hasSong) { if (hasSong) {
const defaultOpt = Array.from(sel.options).find(o => o.value === 'default'); const defaultOpt = Array.from(sel.options).find(o => o.value === 'default');
+7 -7
View File
@@ -6,7 +6,7 @@
* auto-hiding bottom transport, and the speed-level visual (bars + chevrons). * auto-hiding bottom transport, and the speed-level visual (bars + chevrons).
* *
* Design contract: the actual controls are the SAME legacy elements/handlers * Design contract: the actual controls are the SAME legacy elements/handlers
* (ids unchanged), just relocated into rail popovers — so app.js/highway.js * (ids unchanged), just relocated into rail popovers — so app.js/window.highway.js
* keep populating and reacting to them unmodified. This module only adds * keep populating and reacting to them unmodified. This module only adds
* presentation behavior (open/close, reveal/hide, mirror state). It runs only * presentation behavior (open/close, reveal/hide, mirror state). It runs only
* while #player is the active screen. * while #player is the active screen.
@@ -146,8 +146,8 @@
const rail = $('v3-player-rail'); const rail = $('v3-player-rail');
const lyr = rail && rail.querySelector('[data-rail-action="lyrics"]'); const lyr = rail && rail.querySelector('[data-rail-action="lyrics"]');
if (!lyr) return; if (!lyr) return;
const on = (window.highway && typeof highway.getLyricsVisible === 'function') const on = (window.highway && typeof window.highway.getLyricsVisible === 'function')
? highway.getLyricsVisible() ? window.highway.getLyricsVisible()
: lyr.classList.contains('is-active'); : lyr.classList.contains('is-active');
lyr.classList.toggle('is-active', !!on); lyr.classList.toggle('is-active', !!on);
lyr.setAttribute('aria-pressed', on ? 'true' : 'false'); lyr.setAttribute('aria-pressed', on ? 'true' : 'false');
@@ -159,13 +159,13 @@
rail.querySelectorAll('[data-rail]').forEach((b) => rail.querySelectorAll('[data-rail]').forEach((b) =>
b.addEventListener('click', (e) => { e.stopPropagation(); openPopFor(b); })); b.addEventListener('click', (e) => { e.stopPropagation(); openPopFor(b); }));
// Mic icon: a direct lyrics toggle (clicks the hidden canonical button so // Mic icon: a direct lyrics toggle (clicks the hidden canonical button so
// highway.toggleLyrics() + any label logic runs), mirroring on/off state. // window.highway.toggleLyrics() + any label logic runs), mirroring on/off state.
const lyr = rail.querySelector('[data-rail-action="lyrics"]'); const lyr = rail.querySelector('[data-rail-action="lyrics"]');
if (lyr) lyr.addEventListener('click', (e) => { if (lyr) lyr.addEventListener('click', (e) => {
e.stopPropagation(); e.stopPropagation();
const real = $('btn-lyrics'); const real = $('btn-lyrics');
if (real) real.click(); // runs highway.toggleLyrics() via its onclick if (real) real.click(); // runs window.highway.toggleLyrics() via its onclick
else if (window.highway && typeof highway.toggleLyrics === 'function') highway.toggleLyrics(); else if (window.highway && typeof window.highway.toggleLyrics === 'function') window.highway.toggleLyrics();
syncLyricsIcon(); // reflect the ACTUAL toggled state, not click parity syncLyricsIcon(); // reflect the ACTUAL toggled state, not click parity
}); });
// Click-outside + Esc close (bound once; harmless when no popover open). // Click-outside + Esc close (bound once; harmless when no popover open).
@@ -288,7 +288,7 @@
if (t - lastUpNext >= UPNEXT_MS) { if (t - lastUpNext >= UPNEXT_MS) {
lastUpNext = t; lastUpNext = t;
updateUpNext(); updateUpNext();
// Re-sync the lyrics icon so programmatic highway.setLyricsVisible() // Re-sync the lyrics icon so programmatic window.highway.setLyricsVisible()
// (e.g. from lyrics_karaoke) isn't left stale; cheap + idempotent. // (e.g. from lyrics_karaoke) isn't left stale; cheap + idempotent.
syncLyricsIcon(); syncLyricsIcon();
// Reconcile the edge-driven hover flag against ground truth at // Reconcile the edge-driven hover flag against ground truth at
+1 -1
View File
@@ -73,7 +73,7 @@
function readArrangementSignal() { function readArrangementSignal() {
// Intentional karaoke/vocals signal: active arrangement name from the // Intentional karaoke/vocals signal: active arrangement name from the
// highway WS (user selected Vocals in #arr-select). Do NOT use // highway WS (user selected Vocals in #arr-select). Do NOT use
// highway.getLyricsVisible() — lyrics overlay stays on during normal // window.highway.getLyricsVisible() — lyrics overlay stays on during normal
// guitar practice and must not force vocals POV. // guitar practice and must not force vocals POV.
try { try {
const si = root.highway && typeof root.highway.getSongInfo === 'function' const si = root.highway && typeof root.highway.getSongInfo === 'function'
+5
View File
@@ -124,6 +124,11 @@ function buildSandbox() {
jucePlayer: () => sandbox.jucePlayer, jucePlayer: () => sandbox.jucePlayer,
}; };
vm.createContext(sandbox); vm.createContext(sandbox);
// highway.js is loaded as a CLASSIC script today, so its top-level `const highway`
// creates a global lexical binding that app.js and the modules could reach as a bare
// name. That binding disappears the moment highway.js becomes a module (R3c), so every
// consumer now says `window.highway` — the same object, explicitly. Mirror it here.
if (sandbox.window && sandbox.highway) sandbox.window.highway = sandbox.highway;
return sandbox; return sandbox;
} }
+10
View File
@@ -45,6 +45,11 @@ function buildSandbox({ juceMode = false, audioT = 12.5, chartT = 11.8, juceT }
performance: { now: () => 1000.123 }, performance: { now: () => 1000.123 },
}; };
vm.createContext(sandbox); vm.createContext(sandbox);
// highway.js is loaded as a CLASSIC script today, so its top-level `const highway`
// creates a global lexical binding that app.js and the modules could reach as a bare
// name. That binding disappears the moment highway.js becomes a module (R3c), so every
// consumer now says `window.highway` — the same object, explicitly. Mirror it here.
if (sandbox.window && sandbox.highway) sandbox.window.highway = sandbox.highway;
return sandbox; return sandbox;
} }
@@ -97,6 +102,11 @@ test('time and audioT are the same number (not duplicated computation)', () => {
performance: { now: () => 1000 }, performance: { now: () => 1000 },
}; };
vm.createContext(sandbox); vm.createContext(sandbox);
// highway.js is loaded as a CLASSIC script today, so its top-level `const highway`
// creates a global lexical binding that app.js and the modules could reach as a bare
// name. That binding disappears the moment highway.js becomes a module (R3c), so every
// consumer now says `window.highway` — the same object, explicitly. Mirror it here.
if (sandbox.window && sandbox.highway) sandbox.window.highway = sandbox.highway;
loadFunctions(sandbox, src); loadFunctions(sandbox, src);
const p = sandbox.__payload(); const p = sandbox.__payload();
assert.equal(p.time, p.audioT, 'time must equal audioT'); assert.equal(p.time, p.audioT, 'time must equal audioT');
+5
View File
@@ -122,6 +122,11 @@ function buildSandbox({ juceMode = false } = {}) {
if (el) sliderInputs.push(el.id); if (el) sliderInputs.push(el.id);
}; };
vm.createContext(sandbox); vm.createContext(sandbox);
// highway.js is loaded as a CLASSIC script today, so its top-level `const highway`
// creates a global lexical binding that app.js and the modules could reach as a bare
// name. That binding disappears the moment highway.js becomes a module (R3c), so every
// consumer now says `window.highway` — the same object, explicitly. Mirror it here.
if (sandbox.window && sandbox.highway) sandbox.window.highway = sandbox.highway;
return sandbox; return sandbox;
} }