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;
// Snapshot where we were so leaving the player — especially by accident
// — 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.
if (hadPlayableSong) _snapshotResumeSession(stopTime);
highway.stop();
window.highway.stop();
// Cancel any queued seeks, in-flight shim closures, AND active
// count-in timers before stopping playback so none of these paths
// 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');
if (demucsEl) demucsEl.value = data.demucs_server_url || '';
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');
if (autoplayExitEl) autoplayExitEl.checked = _autoplayExitEnabled();
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.)
// keep scoring against the real chart clock regardless of visual
// calibration.
if (typeof highway !== 'undefined' && highway?.setAvOffset) highway.setAvOffset(_avOffsetMs);
if (window.highway?.setAvOffset) window.highway.setAvOffset(_avOffsetMs);
// Sync any visible Settings slider
const avSlider = document.getElementById('setting-av-offset');
if (avSlider) {
@@ -1771,7 +1771,7 @@ window._juceAudioUrl = null;
window.jucePlayer = jucePlayer;
// ── 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*
// 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
@@ -1907,7 +1907,7 @@ function _currentPlaybackSnapshot() {
return {
currentTime: 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,
playbackRate: window._juceMode ? (window.jucePlayer && window.jucePlayer._speed || 1) : audio.playbackRate,
isPlaying: S.isPlaying,
@@ -2587,7 +2587,7 @@ async function playSong(filename, arrangement, options) {
if (artAbortController) artAbortController.abort();
artAbortController = null;
highway.stop();
window.highway.stop();
// Cancel any active count-in: clear timers/RAF and bump the gen so
// delayed callbacks (rewind frames, post-seek then, count-in ticks,
// post-count play) bail before mutating the new session.
@@ -2618,7 +2618,7 @@ async function playSong(filename, arrangement, options) {
}
audio.pause();
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;
// Fresh JUCE routing attempt for whatever song loads next.
window._clearJuceRerouteMemo?.();
@@ -2655,19 +2655,19 @@ async function playSong(filename, arrangement, options) {
// Wait for previous WebSocket to fully close before opening new one
await new Promise(r => setTimeout(r, 500));
highway.init(document.getElementById('highway'));
window.highway.init(document.getElementById('highway'));
const wsParams = new URLSearchParams();
if (arrangement !== undefined) wsParams.set('arrangement', arrangement);
wsParams.set('naming_mode', _getArrangementNamingMode());
const wsUrl = `${location.protocol === 'https:' ? 'wss:' : 'ws:'}//${location.host}/ws/highway/${decodeURIComponent(filename)}?${wsParams.toString()}`;
highway.connect(wsUrl);
window.highway.connect(wsUrl);
_resetSectionPracticeLog();
_scheduleSectionPracticeRetries();
loadSavedLoops();
document.getElementById('quality-select').value = highway.getRenderScale();
document.getElementById('quality-select').value = window.highway.getRenderScale();
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
@@ -2749,7 +2749,7 @@ async function changeArrangement(index) {
const clearMyCallback = () => {
// Only null out if the slot still points at us; a newer
// 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');
// Don't auto-resume on cancel OR off-target landing — same
@@ -2790,7 +2790,7 @@ async function changeArrangement(index) {
clearBusy();
clearMyCallback();
};
highway._onReady = myCallback;
window.highway._onReady = myCallback;
// Reset the Section Practice bar for the incoming arrangement, mirroring
// playSong(): different arrangements have different section markers, so
@@ -2803,7 +2803,7 @@ async function changeArrangement(index) {
_resetSectionPracticeLog();
invalidateParentCount();
highway.reconnect(currentFilename, index);
window.highway.reconnect(currentFilename, index);
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
// (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() {
// 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.
@@ -3111,8 +3111,8 @@ function _resolveEditRegion() {
if (loopA !== null && loopB !== null) return { a: loopA, b: loopB };
const t = _audioTime();
try {
const secs = (highway && typeof highway.getSections === 'function')
? highway.getSections() : [];
const secs = (window.highway && typeof window.highway.getSections === 'function')
? window.highway.getSections() : [];
if (Array.isArray(secs) && secs.length) {
let start = null, end = null;
for (let i = 0; i < secs.length; i++) {
@@ -3173,7 +3173,7 @@ function editRegionInEditor() {
const region = _resolveEditRegion();
let arrangement = 0;
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) {
arrangement = si.arrangement_index;
}
@@ -3337,7 +3337,7 @@ setInterval(() => {
if (_sectionPracticeBarIsReady() && _sectionPracticeSourceSections().length) {
_updateSectionPracticeHighlight(ct);
}
if (!isCountingIn()) highway.setTime(ct);
if (!isCountingIn()) window.highway.setTime(ct);
}, 1000 / 60);
_installSectionPracticeDrawHook();