From c73ff96dfe072454cbaea632a8aa7867387402d1 Mon Sep 17 00:00:00 2001 From: byrongamatos Date: Sun, 12 Jul 2026 20:51:41 +0200 Subject: [PATCH 1/9] fix(venue-crowd): keep rear video layer opaque during crossfades MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two half-transparent layers let the static bg plate bleed through (~25% at mid-fade) — visible as a flash of the old still image on every state transition. The crossfade is now always the front layer fading over an opaque rear layer. Co-Authored-By: Claude Fable 5 --- plugins/highway_3d/screen.js | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/plugins/highway_3d/screen.js b/plugins/highway_3d/screen.js index a070750..8d9ffe4 100644 --- a/plugins/highway_3d/screen.js +++ b/plugins/highway_3d/screen.js @@ -3499,7 +3499,14 @@ layer.lastVidH = el.videoHeight; layer.applyCoverCrop(); } - const opacity = i === 0 ? 1 - _venueCrowdMix : _venueCrowdMix; + // Layer 0 (rear) stays fully opaque whenever any of the + // fade involves it: two half-transparent layers would + // let the static plate behind bleed through (~25% at + // mid-fade). The crossfade is therefore layer 1 (front) + // fading over an opaque layer 0 — in both directions. + const opacity = i === 0 + ? (_venueCrowdMix < 0.999 ? 1 : 0) + : _venueCrowdMix; layer.mat.opacity = opacity; layer.mesh.visible = ready && opacity > 0.01; if (layer.mesh.visible) { From c83fec267e5d0d6892e936daa1587f4c9e022bcb Mon Sep 17 00:00:00 2001 From: byrongamatos Date: Sun, 12 Jul 2026 20:54:20 +0200 Subject: [PATCH 2/9] fix(venue-crowd): reset active layer with mix on stop Codex preflight: stop() zeroed the mix but left _activeLayer at 1, so a restart flashed layer 0's stale frame until the new loop loaded. Co-Authored-By: Claude Fable 5 --- static/v3/venue-crowd.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/static/v3/venue-crowd.js b/static/v3/venue-crowd.js index 2981bf7..5126e11 100644 --- a/static/v3/venue-crowd.js +++ b/static/v3/venue-crowd.js @@ -373,6 +373,10 @@ setVideo(1, null); } _boundToRenderer = false; + // Mix and active layer must reset together: mix 0 shows layer 0, so a + // restart that left _activeLayer at 1 would flash layer 0's stale + // frame until the new loop loads. + _activeLayer = 0; setMix(0); } From f9a57ba044b732975437ae4b9924d0fa25b2dd13 Mon Sep 17 00:00:00 2001 From: byrongamatos Date: Sun, 12 Jul 2026 20:58:15 +0200 Subject: [PATCH 3/9] fix(venue-crowd): reset crowd mood to neutral on song load Codex preflight: a song ending in ecstatic/bored left the next song's crowd stuck in that mood until the hysteresis window passed. Co-Authored-By: Claude Fable 5 --- static/v3/venue-crowd.js | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/static/v3/venue-crowd.js b/static/v3/venue-crowd.js index 5126e11..79b319b 100644 --- a/static/v3/venue-crowd.js +++ b/static/v3/venue-crowd.js @@ -53,6 +53,11 @@ let lastSwitchAt = -Infinity; return { get current() { return current; }, + reset() { + current = "neutral"; + candidate = null; + lastSwitchAt = -Infinity; + }, // Feed the latest perf state; returns the new crowd state when a // transition commits, else null. update(perfState, nowMs) { @@ -328,6 +333,15 @@ } } + function onSongLoaded() { + machine.reset(); + _prevStreak = 0; + _lastAccuracyPct = null; + if (_venueActive && _manifest && !_stingerUntilEnded) { + showLoop(machine.current, FADE_MS); + } + } + function onStatsRecorded() { if (!_venueActive || !_manifest) return; // stats:recorded carries only {filename, arrangement} — the accuracy @@ -422,6 +436,9 @@ if (sm && typeof sm.on === 'function') { sm.on('v3:live-performance-state', onPerformanceState); sm.on('stats:recorded', onStatsRecorded); + // A new song must not inherit the previous song's crowd mood + // through the hysteresis/dwell window. + sm.on('song:loaded', onSongLoaded); } const dev = readDevManifest(); if (dev && !_manifest) setManifest(dev); From be78b4f29e496177faae127569f918a1d10ef3ae Mon Sep 17 00:00:00 2001 From: byrongamatos Date: Sun, 12 Jul 2026 21:01:28 +0200 Subject: [PATCH 4/9] fix(venue-crowd): cancel in-flight fade when a stinger preempts it Codex preflight: the orphaned ramp kept pushing the mix toward the layer whose src the stinger had just replaced. Co-Authored-By: Claude Fable 5 --- static/v3/venue-crowd.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/static/v3/venue-crowd.js b/static/v3/venue-crowd.js index 79b319b..3d6fc87 100644 --- a/static/v3/venue-crowd.js +++ b/static/v3/venue-crowd.js @@ -274,6 +274,10 @@ // stinger ends (the machine already advanced, nothing re-fires it). const interrupted = _loadingLoop || _fadingLoop; if (interrupted) { + // Freeze any in-flight crossfade: its ramp would keep pushing the + // mix toward this layer while the stinger replaces the src (loop + // vanishing / stinger popping in at full opacity). + cancelFade(); _pendingLoop = interrupted; _loadingLoop = null; _fadingLoop = null; From f362c9a0833aed46c7481a0faa8120a6851d8f53 Mon Sep 17 00:00:00 2001 From: byrongamatos Date: Sun, 12 Jul 2026 21:03:48 +0200 Subject: [PATCH 5/9] fix(venue-crowd): don't let null accuracy resets wipe the end-of-song value Codex preflight: Number(null) is 0, so idle HUD resets overwrote _lastAccuracyPct before stats:recorded consumed it, suppressing the end-of-song stinger. Co-Authored-By: Claude Fable 5 --- static/v3/venue-crowd.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/static/v3/venue-crowd.js b/static/v3/venue-crowd.js index 3d6fc87..eae4d30 100644 --- a/static/v3/venue-crowd.js +++ b/static/v3/venue-crowd.js @@ -322,7 +322,11 @@ if (!_venueActive || !_manifest) return; bindVideosToRenderer(); const d = (e && e.detail) || {}; - if (Number.isFinite(Number(d.accuracyPct))) _lastAccuracyPct = Number(d.accuracyPct); + // Number(null) === 0: HUD reset events (accuracyPct: null) must not + // wipe the value the end-of-song stinger reads via stats:recorded. + if (d.accuracyPct != null && Number.isFinite(Number(d.accuracyPct))) { + _lastAccuracyPct = Number(d.accuracyPct); + } const streak = Number(d.streak) || 0; const sting = stingerForStreak(_prevStreak, streak); _prevStreak = streak; From 663e8ff4a1b07ea8f23e4aad1079acc66861c368 Mon Sep 17 00:00:00 2001 From: byrongamatos Date: Sun, 12 Jul 2026 21:06:04 +0200 Subject: [PATCH 6/9] fix(venue-crowd): abort stale stinger state on song load Codex preflight: a stinger straddling a song change could fade back into the previous song's layer or flush its pending loop. Co-Authored-By: Claude Fable 5 --- static/v3/venue-crowd.js | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/static/v3/venue-crowd.js b/static/v3/venue-crowd.js index eae4d30..adbbb53 100644 --- a/static/v3/venue-crowd.js +++ b/static/v3/venue-crowd.js @@ -345,7 +345,15 @@ machine.reset(); _prevStreak = 0; _lastAccuracyPct = null; - if (_venueActive && _manifest && !_stingerUntilEnded) { + // Abort any stinger/pending state from the previous song: its ended + // handler must not fade back into the old song's layers. + cancelFade(); + _stingerGen++; + _stingerUntilEnded = false; + _pendingLoop = null; + _loadingLoop = null; + _fadingLoop = null; + if (_venueActive && _manifest) { showLoop(machine.current, FADE_MS); } } From 9d8fda6d467b960e942862730f7f99f12de842f3 Mon Sep 17 00:00:00 2001 From: byrongamatos Date: Sun, 12 Jul 2026 21:09:00 +0200 Subject: [PATCH 7/9] fix(venue-crowd): always detach load listeners, gate only the callback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex preflight: superseded loads left canplaythrough/error listeners attached to the persistent video elements — unbounded growth over a session. Co-Authored-By: Claude Fable 5 --- static/v3/venue-crowd.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/static/v3/venue-crowd.js b/static/v3/venue-crowd.js index adbbb53..0a64e3c 100644 --- a/static/v3/venue-crowd.js +++ b/static/v3/venue-crowd.js @@ -216,10 +216,14 @@ const gen = _stopGen; let settled = false; const settle = (ok) => { - if (settled || token !== video._fbCrowdToken || gen !== _stopGen) return; + if (settled) return; settled = true; + // Cleanup must run even for superseded loads or stale listeners + // accumulate on the two persistent elements; only the callback + // is gated on still being the current load. video.removeEventListener('canplaythrough', onReady); video.removeEventListener('error', onError); + if (token !== video._fbCrowdToken || gen !== _stopGen) return; cb(ok); }; const onReady = () => settle(true); From 99812b8ed35a9a829669f06a46a96c8f7d3ef59a Mon Sep 17 00:00:00 2001 From: byrongamatos Date: Sun, 12 Jul 2026 21:21:22 +0200 Subject: [PATCH 8/9] feat(venue-crowd): flyover intro with crowd-ambience ducking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On song:loaded, an optional pack intro plays once: a camera flyover video (idle layer, one-shot) with bar-crowd ambience audio that ducks out on song:play, near the flyover's landing, or at handoff — whichever first. Machine commits and stingers defer during the intro; stop()/song-change abort it. Packs without an intro behave as before. Co-Authored-By: Claude Fable 5 --- static/v3/venue-crowd.js | 105 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 100 insertions(+), 5 deletions(-) diff --git a/static/v3/venue-crowd.js b/static/v3/venue-crowd.js index 0a64e3c..108e301 100644 --- a/static/v3/venue-crowd.js +++ b/static/v3/venue-crowd.js @@ -116,6 +116,10 @@ let _fadingLoop = null; // loop currently crossfading in (not yet active) let _stingerUntilEnded = false; let _stingerGen = 0; // identity for ended/timeout handlers + let _introActive = false; + let _introGen = 0; + let _audioEl = null; // crowd ambience during the intro flyover + let _audioFadeTimer = 0; let _lastStingerAt = -Infinity; let _prevStreak = 0; let _lastAccuracyPct = null; // from perf events; stats:recorded carries none @@ -136,7 +140,11 @@ if (!CROWD_STATES.every((s) => loops[s])) return null; const stingers = {}; for (const k of ['clap', 'cheer']) stingers[k] = abs(m.stingers && m.stingers[k]); - return { loops, stingers }; + const intro = { + video: abs(m.intro && m.intro.video), + audio: abs(m.intro && m.intro.audio), + }; + return { loops, stingers, intro }; } function ensureVideos() { @@ -322,6 +330,85 @@ }); } + function ensureAudio() { + if (_audioEl || typeof document === 'undefined') return; + _audioEl = document.createElement('audio'); + _audioEl.preload = 'auto'; + _audioEl.style.display = 'none'; + document.body.appendChild(_audioEl); + } + + function fadeAudioOut(durationMs) { + if (!_audioEl || _audioEl.paused) return; + if (_audioFadeTimer) return; // already fading + const from = _audioEl.volume; + const t0 = now(); + _audioFadeTimer = setInterval(() => { + const k = Math.min(1, (now() - t0) / durationMs); + _audioEl.volume = from * (1 - k); + if (k >= 1) { + clearInterval(_audioFadeTimer); + _audioFadeTimer = 0; + _audioEl.pause(); + } + }, 50); + } + + function stopAudio() { + if (_audioFadeTimer) { clearInterval(_audioFadeTimer); _audioFadeTimer = 0; } + if (_audioEl && !_audioEl.paused) _audioEl.pause(); + } + + // One-shot flyover intro on song load: video flies from the back of the + // room onto the stage, crowd ambience plays and ducks out as the song + // starts (song:play) or as the flyover lands, whichever comes first. + function playIntro() { + if (!_manifest || !_manifest.intro || !_manifest.intro.video || !_videos[0]) { + return false; + } + const myGen = ++_introGen; + _introActive = true; + const layer = idleLayer(); + const video = _videos[layer]; + const land = () => { + if (_introGen !== myGen || !_introActive) return; + _introActive = false; + video.removeEventListener('ended', land); + fadeAudioOut(1200); + const pending = _pendingLoop; + _pendingLoop = null; + showLoop(pending || machine.current, 400); + }; + loadAndPlay(video, _manifest.intro.video, false, (ok) => { + if (_introGen !== myGen) return; + if (!ok || !_venueActive) { _introActive = false; return; } + fadeMixTo(layer === 1 ? 1 : 0, 300); + video.addEventListener('ended', land); + setTimeout(land, 15000); // decode-stall safety + if (_manifest.intro.audio) { + ensureAudio(); + _audioEl.src = _manifest.intro.audio; + _audioEl.volume = 1; + // The user's play gesture precedes song:loaded, so autoplay + // with sound is normally allowed; degrade silently if not. + _audioEl.play().catch(() => { /* no gesture yet */ }); + // start ducking shortly before the flyover lands + video.addEventListener('timeupdate', function duck() { + if (video.duration && video.duration - video.currentTime < 1.5) { + video.removeEventListener('timeupdate', duck); + fadeAudioOut(1400); + } + }); + } + }); + return true; + } + + function onSongPlay() { + // Song audio starting is the hard cue: the ambience must yield. + fadeAudioOut(1000); + } + function onPerformanceState(e) { if (!_venueActive || !_manifest) return; bindVideosToRenderer(); @@ -334,13 +421,13 @@ const streak = Number(d.streak) || 0; const sting = stingerForStreak(_prevStreak, streak); _prevStreak = streak; - if (sting && CROWD_RANK[machine.current] >= CROWD_RANK.neutral) { + if (sting && !_introActive && CROWD_RANK[machine.current] >= CROWD_RANK.neutral) { playStinger(sting); } const next = machine.update(d.state, now()); if (next) { - // A stinger owns the idle layer; defer the switch until it ends. - if (_stingerUntilEnded) _pendingLoop = next; + // A stinger or the intro owns the idle layer; defer the switch. + if (_stingerUntilEnded || _introActive) _pendingLoop = next; else showLoop(next, FADE_MS); } } @@ -353,12 +440,15 @@ // handler must not fade back into the old song's layers. cancelFade(); _stingerGen++; + _introGen++; _stingerUntilEnded = false; + _introActive = false; + stopAudio(); _pendingLoop = null; _loadingLoop = null; _fadingLoop = null; if (_venueActive && _manifest) { - showLoop(machine.current, FADE_MS); + if (!playIntro()) showLoop(machine.current, FADE_MS); } } @@ -390,6 +480,9 @@ cancelFade(); _stopGen++; _stingerGen++; + _introGen++; + _introActive = false; + stopAudio(); _stingerUntilEnded = false; _pendingLoop = null; _loadingLoop = null; @@ -459,6 +552,7 @@ // A new song must not inherit the previous song's crowd mood // through the hysteresis/dwell window. sm.on('song:loaded', onSongLoaded); + sm.on('song:play', onSongPlay); } const dev = readDevManifest(); if (dev && !_manifest) setManifest(dev); @@ -472,6 +566,7 @@ activeLayer: _activeLayer, mix: _mix, stingerActive: _stingerUntilEnded, + introActive: _introActive, }; } From 4abdfb5f5cf3429107789e262dfcf8f21757cacf Mon Sep 17 00:00:00 2001 From: byrongamatos Date: Sun, 12 Jul 2026 21:25:26 +0200 Subject: [PATCH 9/9] fix(venue-crowd): fall back to the loop when the intro fails to load Codex preflight: a failed/timed-out intro left the song with no crowd loop at all. Co-Authored-By: Claude Fable 5 --- static/v3/venue-crowd.js | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/static/v3/venue-crowd.js b/static/v3/venue-crowd.js index 108e301..460656b 100644 --- a/static/v3/venue-crowd.js +++ b/static/v3/venue-crowd.js @@ -381,7 +381,13 @@ }; loadAndPlay(video, _manifest.intro.video, false, (ok) => { if (_introGen !== myGen) return; - if (!ok || !_venueActive) { _introActive = false; return; } + if (!ok || !_venueActive) { + // Failed intro must not leave the song loop-less: fall back + // to the normal loop exactly like the no-intro path. + _introActive = false; + if (_venueActive) showLoop(machine.current, FADE_MS); + return; + } fadeMixTo(layer === 1 ? 1 : 0, 300); video.addEventListener('ended', land); setTimeout(land, 15000); // decode-stall safety