From 99812b8ed35a9a829669f06a46a96c8f7d3ef59a Mon Sep 17 00:00:00 2001 From: byrongamatos Date: Sun, 12 Jul 2026 21:21:22 +0200 Subject: [PATCH] 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, }; }