feat(highway_3d): Butterchurn visualizer background style (#598)

* feat(highway_3d): add Butterchurn visualizer background style

Adds an opt-in "Butterchurn (visualizer)" option to the 3D Highway plugin's
Background-style dropdown. When selected, the highway renders over a WebGL
MilkDrop (Butterchurn) canvas that reacts to your playing (guitar input on
desktop, the song <audio> spectrum in the browser) and the chart (beat/note/
chord accents + instrument-color tint). The default stays 'particles', so
existing users see no change until they pick it.

Integrates the standalone "3D Highway + Butterchurn" mod into the bundled
renderer as the 'butterchurn' bg-style (not a fork):
- a self-contained _bc* controller that lazy-loads the vendored butterchurn
  libs only when the style is selected; mount/unmount is driven idempotently
  by the existing bg-style lifecycle (_bcSyncMode in _bgMountStyle) plus an
  explicit teardown in destroy()
- the renderer uses alpha:true with the transparent clear gated on the mode,
  so every other bg style stays byte-identical (opaque clear)
- the fog-scenery <audio> tap is disabled while active to avoid a double
  createMediaElementSource on #audio
- the mod's slopsmith* globals are adapted to the current feedBack* names and
  the vendored asset URLs repointed to /api/plugins/highway_3d/assets/

Vendors butterchurn.min.js + butterchurnPresets.min.js (MIT) + viz-worklet.js
under assets/vendor/; see plugins/highway_3d/NOTICE for attribution.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Q9BpGYqUaga9ZJyS3dDPq

* fix(highway_3d): anchor Butterchurn panel to the highway, centered

Addresses three issues found testing the visualizer control panel:
- Attach the panel + preset pane to the 3D highway's `wrap` (position:absolute,
  pointer-events:auto) instead of position:fixed on document.body, so they sit
  on the highway's right edge and only exist while the highway is on-screen
  (no longer linger on the main menu / float at the app edge).
- Re-home the singleton panel to the active highway wrap on mount, so it follows
  whichever highway is showing (e.g. moves off Virtuoso's embedded highway onto a
  normal song's highway) instead of sticking to the first one created.
- Center it vertically (top:50% + translateY(-50%), folded into the slide
  transform) so a top overlay element no longer covers it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Q9BpGYqUaga9ZJyS3dDPq

* fix(highway_3d): harden Butterchurn audio + lifecycle (review #597)

Browser audio reactivity now REUSES the highway's existing shared analyser
(the fog scenery's #audio / stems side-chain tap) instead of opening a
second createMediaElementSource on #audio. The old _bcBrowserSource path:
  - threw InvalidStateError when the fog tap already owned #audio (default
    config), leaving the visualizer non-reactive in the browser, and could
    permanently disable fog reactivity if it tapped first (one-shot/element);
  - rerouted the song through a fresh, possibly-suspended AudioContext, which
    could MUTE playback when butterchurn was selected mid-song;
  - ignored the stems analyser, so it saw only silence on sloppak songs.
_bcCreateController now takes an audioProvider (wired to _bgGetAnalyser) and
connectAudio()s the shared AnalyserNode (a passthrough, so the fog's own
reads are undisturbed).

Also:
- destroy() now closes the AudioContext when we own it (desktop / browser
  fallback), fixing a per-mount leak that hit the browser ~6-context cap
  after a few style toggles. The shared (fog-owned) context is never closed.
- _bgApplyVenueSceneFog keeps the clear transparent while butterchurn is
  active, so the venue scene no longer occludes the visualizer.
- _bcLoadLib no longer caches a rejected promise, so a transient vendor-load
  failure can be retried instead of disabling the feature for the session.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(highway_3d): Butterchurn lifecycle + audio re-bind (Codex preflight)

Local Codex preflight on the Butterchurn feature flagged four issues; all fixed:

- WebGL context leak on teardown: destroy() (and the async-init failure path)
  now call _bcReleaseCanvasGL() to force WEBGL_lose_context before dropping the
  canvas, so repeated mount/toggle cycles can't exhaust the browser's WebGL
  context cap.
- Stale shared analyser across songs: the browser path captured the analyser
  once at mount, so a sloppak stems swap (new analyser, often new context) left
  the visualizer reacting to a dead node. update() now compares the live
  _bgGetAnalyser() against what the controller actually bound (boundAnalyser(),
  guarded by ready()) and either reconnects (same context) or rebuilds the
  controller (context changed) via the proven destroy()+_bcSyncMode paths.
- Half-mounted controller on createVisualizer failure: the async .catch now
  cleans up (closes an owned AudioContext, removes layers, marks dead) and
  _bcSyncMode retries when bcCtrl.dead(), instead of leaking and never recovering.
- _bcFfIdx off-by-one dropped accents landing exactly on a seek/loop target
  time; it now uses strict < so the update walkers fire the boundary event.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: ChrisBeWithYou <christian.a.cowan@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Byron Gamatos
2026-06-26 14:07:29 +02:00
committed by GitHub
co-authored by Claude Opus 4.8 ChrisBeWithYou
parent 97dae88860
commit 3bb55ab854
6 changed files with 837 additions and 6 deletions
+30
View File
@@ -0,0 +1,30 @@
3D Highway plugin — third-party notices
=======================================
The "Butterchurn (visualizer)" background style (the 'butterchurn' option in
the plugin's Background-style setting) bundles the third-party components below
under plugins/highway_3d/assets/vendor/. They are lazy-loaded only when a user
selects that background style.
The 3D Highway plugin and the feedBack project are licensed under the GNU Affero
General Public License v3.0 (AGPL-3.0-only). The components below are under their
own permissive (MIT) licenses, which are compatible with AGPL-3.0.
- Butterchurn — a WebGL implementation of the MilkDrop visualizer.
https://github.com/jberg/butterchurn
Copyright (c) Jordan Berg. MIT License.
Vendored at: plugins/highway_3d/assets/vendor/butterchurn.min.js
- butterchurn-presets — MilkDrop preset pack.
https://github.com/jberg/butterchurn-presets
Presets authored by the MilkDrop community (Geiss, Flexi, Martin, Rovastar,
Zylot, Aderrasi, Cope, Eo.S., Unchained, and many others); refer to each
preset's embedded attribution.
Vendored at: plugins/highway_3d/assets/vendor/butterchurnPresets.min.js
- Three.js (r170) — loaded at runtime from feedBack core's vendored copy
(/static/vendor/three/three.module.min.js). https://threejs.org MIT License.
Provenance: the Butterchurn background integration originated as the standalone
"3D Highway + Butterchurn" plugin and was merged into the bundled 3D Highway
renderer as the opt-in 'butterchurn' background style.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+47
View File
@@ -0,0 +1,47 @@
/*
* Slopsmith visualizer — audio feed AudioWorkletProcessor.
* Generates Butterchurn's drive signal off the main thread (replaces the
* deprecated ScriptProcessor): guitar PCM (gained) + a bass-band song-energy
* pulse + a mid-band chart-accent pulse. The main thread pushes the latest
* guitar frame and the song/chart/gain scalars via port messages.
*/
class VizFeedProcessor extends AudioWorkletProcessor {
constructor() {
super();
this.frame = null; // latest guitar PCM (Float32Array)
this.readIdx = 0;
this.song = 0;
this.chart = 0;
this.gain = 6;
this.phase = 0;
this.phase2 = 0;
this.osc = (2 * Math.PI * 90) / sampleRate; // ~90 Hz → bass band (song)
this.osc2 = (2 * Math.PI * 520) / sampleRate; // ~520 Hz → mid band (chart)
this.port.onmessage = (e) => {
const d = e.data;
if (!d) return;
if (d.frame) { this.frame = d.frame; this.readIdx = 0; }
if (typeof d.song === 'number') this.song = d.song;
if (typeof d.chart === 'number') this.chart = d.chart;
if (typeof d.gain === 'number') this.gain = d.gain;
};
}
process(inputs, outputs) {
const out = outputs[0] && outputs[0][0];
if (!out) return true;
const f = this.frame, fl = f ? f.length : 0;
const TWO_PI = 2 * Math.PI;
for (let i = 0; i < out.length; i++) {
const g = (fl ? f[this.readIdx % fl] : 0) * this.gain;
this.readIdx++;
const song = this.song * (0.7 * Math.sin(this.phase) + 0.3 * (Math.random() * 2 - 1)) * 1.4;
const chart = this.chart * (0.5 * Math.sin(this.phase2) + 0.5 * (Math.random() * 2 - 1)) * 1.5;
this.phase += this.osc; if (this.phase > TWO_PI) this.phase -= TWO_PI;
this.phase2 += this.osc2; if (this.phase2 > TWO_PI) this.phase2 -= TWO_PI;
const v = g + song + chart;
out[i] = v > 1 ? 1 : (v < -1 ? -1 : v);
}
return true;
}
}
registerProcessor('viz-feed', VizFeedProcessor);
+755 -5
View File
@@ -20,6 +20,652 @@
const THREE_URL = '/static/vendor/three/three.module.min.js';
const THREE_CDN = 'https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.min.js';
/* Butterchurn audio-reactive background
* Mounts a Butterchurn (WebGL MilkDrop) canvas BEHIND the transparent
* 3D highway. On desktop it's driven by the guitar/mic input (the song
* audio lives in JUCE, not the webview <audio>); in a browser it taps
* the song <audio> directly.
* */
const BC_VENDOR = '/api/plugins/highway_3d/assets/vendor/';
const BC_FRAME = 1024;
const BC_WORKLET = '/api/plugins/highway_3d/assets/viz-worklet.js';
const _bcMeters = { gtr: 0, song: 0 }; // live levels shown in the panel readout
const BC_BTN = 'background:rgba(255,255,255,.09);color:#cfe3ff;border:1px solid rgba(255,255,255,.16);border-radius:5px;padding:3px 8px;cursor:pointer;font:12px system-ui';
let _bcLoading = null;
function _bcLoadLib() {
if (_bcLoading) return _bcLoading;
_bcLoading = new Promise((resolve, reject) => {
const add = (url, next) => {
const s = document.createElement('script');
s.src = url; s.async = true;
s.onload = next; s.onerror = () => reject(new Error('load ' + url));
document.head.appendChild(s);
};
add(BC_VENDOR + 'butterchurn.min.js', () =>
add(BC_VENDOR + 'butterchurnPresets.min.js', resolve));
});
// Don't cache a rejected promise: a transient load failure (network
// hiccup, blocked request) must not permanently disable the feature for
// the session. Clearing _bcLoading lets the next mount retry the load.
_bcLoading.catch(() => { _bcLoading = null; });
return _bcLoading;
}
function _bcResolve() { let b = window.butterchurn; if (b && b.default) b = b.default; return b; }
function _bcPresets() { let p = window.butterchurnPresets; if (p && p.default) p = p.default; return p; }
function _bcIsDesktop() {
const d = window.feedBackDesktop || window.slopsmithDesktop;
return !!(d && d.isDesktop && d.audio && typeof d.audio.getRawAudioFrame === 'function');
}
// Fast-forward an index to the first entry after time `ct` (used on seek/loop).
// Position at the first entry whose time is >= ct (strict <), so an event
// landing exactly on the seek/loop target time is still fired by the update
// walkers (which consume `<= ct`) instead of being skipped past here.
function _bcFfIdx(arr, ct, key) { if (!arr) return 0; let i = 0; while (i < arr.length && (arr[i][key] || 0) < ct) i++; return i; }
// Force-free a canvas's WebGL context so the GPU resources are released
// immediately instead of lingering until GC — repeated Butterchurn
// mount/unmount cycles otherwise pile up live contexts toward the browser cap.
function _bcReleaseCanvasGL(canvas) {
if (!canvas || typeof canvas.getContext !== 'function') return;
let gl = null;
try { gl = canvas.getContext('webgl2') || canvas.getContext('webgl'); } catch (e) { gl = null; }
if (!gl || typeof gl.getExtension !== 'function') return;
try { const lose = gl.getExtension('WEBGL_lose_context'); if (lose) lose.loseContext(); } catch (e) {}
}
// Desktop: bridge GUITAR input PCM + SONG output level into a Web Audio node
// Butterchurn can tap. Guitar gives spectral texture from your playing; the
// song's output meter (getLevels) injects an energy pulse so the visuals also
// react to the backing track (JUCE plays it — there's no song PCM to FFT).
function _bcGuitarFeed(actx, onReady) {
const latest = new Float32Array(BC_FRAME);
let polling = true, songLevel = 0, chartLevel = 0;
let node = null, sp = null, silent = null;
const api = (window.feedBackDesktop || window.slopsmithDesktop).audio;
const gainNow = () => (_bcSettings && _bcSettings.guitarGain) || 6;
// Keep the source node processing (silently — JUCE already monitors the
// guitar), and hand it to Butterchurn via the onReady callback.
function attach(srcNode) {
silent = actx.createGain(); silent.gain.value = 0;
srcNode.connect(silent); silent.connect(actx.destination);
try { if (onReady) onReady(srcNode); } catch (e) {}
}
// Fallback for contexts without AudioWorklet support.
function useScriptProcessor() {
let phase = 0, phase2 = 0;
const TWO_PI = Math.PI * 2;
const oscStep = TWO_PI * (90 / actx.sampleRate);
const oscStep2 = TWO_PI * (520 / actx.sampleRate);
sp = actx.createScriptProcessor(BC_FRAME, 1, 1);
sp.onaudioprocess = (e) => {
const out = e.outputBuffer.getChannelData(0);
const n = Math.min(out.length, latest.length);
const lvl = songLevel, clvl = chartLevel, gg = gainNow();
for (let i = 0; i < out.length; i++) {
const g = (i < n ? latest[i] : 0) * gg;
const song = lvl * (0.7 * Math.sin(phase) + 0.3 * (Math.random() * 2 - 1)) * 1.4;
const chart = clvl * (0.5 * Math.sin(phase2) + 0.5 * (Math.random() * 2 - 1)) * 1.5;
phase += oscStep; if (phase > TWO_PI) phase -= TWO_PI;
phase2 += oscStep2; if (phase2 > TWO_PI) phase2 -= TWO_PI;
const v = g + song + chart;
out[i] = v > 1 ? 1 : (v < -1 ? -1 : v);
}
};
attach(sp);
console.log('[viz3d] audio feed: ScriptProcessor (fallback)');
}
// Preferred path: AudioWorklet (runs off the main thread).
if (actx.audioWorklet && typeof actx.audioWorklet.addModule === 'function' && typeof AudioWorkletNode === 'function') {
actx.audioWorklet.addModule(BC_WORKLET).then(() => {
if (!polling || sp) return;
node = new AudioWorkletNode(actx, 'viz-feed', { numberOfInputs: 0, numberOfOutputs: 1, outputChannelCount: [1] });
attach(node);
console.log('[viz3d] audio feed: AudioWorklet');
}).catch((e) => {
console.warn('[viz3d] AudioWorklet unavailable, using ScriptProcessor:', e && e.message);
if (polling && !sp && !node) useScriptProcessor();
});
} else {
useScriptProcessor();
}
// Guitar PCM poll → waveform + level meter (+ pushed to the worklet).
(function pcmLoop() {
if (!polling) return;
Promise.resolve(api.getRawAudioFrame(BC_FRAME)).then((f) => {
if (f && f.length) {
if (f.length >= BC_FRAME) latest.set(f.subarray(0, BC_FRAME));
else { latest.fill(0); latest.set(f); }
let s = 0; for (let i = 0; i < BC_FRAME; i++) s += latest[i] * latest[i];
_bcMeters.gtr = Math.sqrt(s / BC_FRAME) * gainNow();
if (node) node.port.postMessage({ frame: latest.slice(0), song: songLevel, chart: chartLevel, gain: gainNow() });
}
}).catch(() => {}).then(() => { if (polling) setTimeout(pcmLoop, 16); });
})();
// Song output meter poll → music energy pulse.
(function levelLoop() {
if (!polling) return;
Promise.resolve(api.getLevels && api.getLevels()).then((L) => {
if (L && typeof L.outputLevel === 'number') {
songLevel = Math.min(1, L.outputLevel * ((_bcSettings && _bcSettings.songGain) || 1.8));
_bcMeters.song = songLevel;
if (node) node.port.postMessage({ song: songLevel, chart: chartLevel, gain: gainNow() });
}
}).catch(() => {}).then(() => { if (polling) setTimeout(levelLoop, 40); });
})();
return {
setChart(v) { chartLevel = v; },
stop() {
polling = false;
try { if (sp) { sp.disconnect(); sp.onaudioprocess = null; } } catch (e) {}
try { if (node) node.disconnect(); } catch (e) {}
try { if (silent) silent.disconnect(); } catch (e) {}
}
};
}
// Browser audio is sourced by REUSING the highway's own shared analyser
// (the same #audio / stems side-chain tap the fog scenery uses), passed in
// as `audioProvider` to _bcCreateController. We deliberately do NOT open a
// second createMediaElementSource on #audio here: it can only be called
// once per element (a second tap throws InvalidStateError and permanently
// disables the other consumer), it would route the song through a fresh,
// possibly-suspended context and mute playback, and it would miss the stems
// side-chain that sloppaks expose at window.feedBack.stems.getAnalyser().
/* ── Controls + readability (localStorage-backed, global config) ───── */
const BC_LS = 'viz3d_settings';
const BC_DEFAULTS = { enabled: true, opacity: 1.0, laneDim: true, laneDimStrength: 0.45, chartAccents: true, colorTint: true, chartStrength: 1.0, tintStrength: 0.65, guitarGain: 6, songGain: 1.8, cyclePool: 'all', hold: false };
let _bcSettings = null;
function _bcLoadSettings() {
if (_bcSettings) return _bcSettings;
let saved = {};
try { saved = JSON.parse(localStorage.getItem(BC_LS) || '{}'); } catch (e) {}
_bcSettings = Object.assign({}, BC_DEFAULTS, saved);
return _bcSettings;
}
function _bcSaveSettings() { try { localStorage.setItem(BC_LS, JSON.stringify(_bcSettings)); } catch (e) {} }
const _bcControllers = new Set();
function _bcApplyAll() { _bcControllers.forEach((c) => { try { c.applySettings(); } catch (e) {} }); }
// Preset curation: favorites / bans (persisted globally) + the "primary"
// controller the panel's preset buttons drive.
// Seeded once on first run (reputation-based starter set; user can edit freely).
const BC_DEFAULT_FAVORITES = [
'Flexi, martin + geiss - dedicated to the sherwin maxawow',
'Geiss - Reaction Diffusion 2',
'Geiss - Spiral Artifact',
'Flexi + Martin - cascading decay swing',
'Flexi - mindblob [shiny mix]',
'Geiss - Cauldron - painterly 2 (saturation remix)',
'Zylot - Paint Spill (Music Reactive Paint Mix)',
'Flexi - predator-prey-spirals',
'Rovastar + Loadus + Geiss - FractalDrop (Triple Mix)',
'Flexi, fishbrain, Geiss + Martin - tokamak witchery',
];
const BC_DEFAULT_BANS = [
'martin - mucus cervix',
'Goody - The Wild Vort',
'martin - extreme heat',
'Unchained - Rewop',
'high-altitude basket unraveling - singh grooves nitrogen argon nz+',
'$$$ Royal - Mashup (197)',
'$$$ Royal - Mashup (431)',
'suksma - uninitialized variabowl (hydroponic chronic)',
'shifter - dark tides bdrv mix 2',
'_Mig_049',
];
const _bcFavorites = new Set();
const _bcBanned = new Set();
let _bcListsLoaded = false;
function _bcLoadLists() {
if (_bcListsLoaded) return; _bcListsLoaded = true;
try { (JSON.parse(localStorage.getItem('viz3d_favorites') || '[]') || []).forEach((n) => _bcFavorites.add(n)); } catch (e) {}
try { (JSON.parse(localStorage.getItem('viz3d_banned') || '[]') || []).forEach((n) => _bcBanned.add(n)); } catch (e) {}
let seeded = false;
try { seeded = !!localStorage.getItem('viz3d_seeded'); } catch (e) {}
if (!seeded) {
BC_DEFAULT_FAVORITES.forEach((n) => _bcFavorites.add(n));
BC_DEFAULT_BANS.forEach((n) => _bcBanned.add(n));
try { localStorage.setItem('viz3d_seeded', '1'); } catch (e) {}
_bcSaveLists();
}
}
function _bcSaveLists() {
try { localStorage.setItem('viz3d_favorites', JSON.stringify([..._bcFavorites])); } catch (e) {}
try { localStorage.setItem('viz3d_banned', JSON.stringify([..._bcBanned])); } catch (e) {}
}
// Re-add the bundled defaults anytime (merges; a default-fav un-bans, a default-ban un-favs).
function _bcRestoreDefaults() {
BC_DEFAULT_FAVORITES.forEach((n) => { _bcBanned.delete(n); _bcFavorites.add(n); });
BC_DEFAULT_BANS.forEach((n) => { _bcFavorites.delete(n); _bcBanned.add(n); });
try { localStorage.setItem('viz3d_seeded', '1'); } catch (e) {}
_bcSaveLists(); _bcUpdatePanelPreset(); _bcRenderList();
}
let _bcPrimary = null;
let _bcPane = null, _bcListEl = null, _bcFilterEl = null, _bcPaneOpen = false, _bcCollapsed = false;
function _bcStatusMark(name) {
return _bcFavorites.has(name) ? '★ ' : (_bcBanned.has(name) ? '🚫 ' : '');
}
function _bcSetHold(v) {
const s = _bcLoadSettings();
s.hold = !!v; _bcSaveSettings();
const b = _bcPanel && _bcPanel.querySelector('#vz-hold');
if (b) b.textContent = s.hold ? '▶ Resume' : '⏸ Hold';
}
// Drives both panels off the right edge. Order when both open (L→R):
// visualizer panel → preset pane → window edge. Pane lives off-screen by
// default; opening it shoves the panel LEFT to make room.
function _bcLayout() {
if (_bcPanel) {
let tx = 0;
if (_bcCollapsed) tx = 210; // tuck the whole panel off the right edge
else if (_bcPaneOpen) tx = -248; // slide panel LEFT to make room for the pane
_bcPanel.style.transform = 'translateX(' + tx + 'px) translateY(-50%)';
}
if (_bcPane) {
_bcPane.style.transform = (_bcPaneOpen && !_bcCollapsed) ? 'translateX(0) translateY(-50%)' : 'translateX(calc(100% + 16px)) translateY(-50%)';
}
}
function _bcSetPane(open) {
_bcPaneOpen = !!open && !_bcCollapsed;
const b = _bcPanel && _bcPanel.querySelector('#vz-listbtn');
if (b) b.textContent = _bcPaneOpen ? '>>' : '<<';
if (_bcPaneOpen) _bcRenderList();
_bcLayout();
}
function _bcRenderList() {
if (!_bcListEl) return;
const ctrl = _bcPrimary;
const keys = (ctrl && ctrl.keys) ? ctrl.keys : [];
const filt = ((_bcFilterEl && _bcFilterEl.value) || '').toLowerCase();
const cur = ctrl && ctrl.curName;
const frag = document.createDocumentFragment();
for (let i = 0; i < keys.length; i++) {
const name = keys[i];
if (filt && name.toLowerCase().indexOf(filt) === -1) continue;
const row = document.createElement('div');
row.textContent = _bcStatusMark(name) + name;
row.title = name;
row.style.cssText = 'padding:3px 7px;border-radius:4px;cursor:pointer;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-size:11px;' +
(name === cur ? 'background:rgba(110,160,255,.28);' : '') + (_bcBanned.has(name) ? 'opacity:.55;' : '');
row.addEventListener('click', () => {
if (!_bcPrimary) return;
_bcPrimary.loadByName(name, 1.0);
_bcSetHold(true); // picked from the list → sit on it
});
frag.appendChild(row);
}
_bcListEl.innerHTML = '';
_bcListEl.appendChild(frag);
}
function _bcUpdatePanelPreset() {
if (!_bcPanel) return;
const name = _bcPrimary ? (_bcPrimary.curName || null) : null;
const nameEl = _bcPanel.querySelector('#vz-pname');
const favBtn = _bcPanel.querySelector('#vz-fav');
const banBtn = _bcPanel.querySelector('#vz-ban');
const cntEl = _bcPanel.querySelector('#vz-pcount');
if (nameEl) { nameEl.textContent = (name ? _bcStatusMark(name) : '') + (name || '—'); nameEl.title = name ? (name + ' — click for full list') : ''; }
if (favBtn) favBtn.textContent = (name && _bcFavorites.has(name)) ? '★ Favorited' : '☆ Favorite';
if (banBtn) banBtn.textContent = (name && _bcBanned.has(name)) ? '🚫 Banned' : '🚫 Ban';
if (cntEl) cntEl.textContent = '★ ' + _bcFavorites.size + ' 🚫 ' + _bcBanned.size;
if (_bcPaneOpen) _bcRenderList();
}
let _bcPanel = null, _bcPanelKeyBound = false;
function _bcEnsurePanel(host) {
if (_bcPanel && _bcPanel.isConnected) {
// Singleton panel: follow the active highway. If it's still parented
// to a different wrap (e.g. another mounted highway instance such as
// Virtuoso's embedded one), move it — and the pane — to this wrap so
// it appears on whichever highway is currently on-screen.
if (host && _bcPanel.parentNode !== host) {
host.appendChild(_bcPanel);
if (_bcPane) host.appendChild(_bcPane);
}
return _bcPanel;
}
const s = _bcLoadSettings();
const p = document.createElement('div');
p.id = 'viz3d-panel';
p.style.cssText = 'position:absolute;top:50%;right:10px;z-index:100000;pointer-events:auto;font:12px/1.45 system-ui,sans-serif;' +
'color:#cfe3ff;background:rgba(8,10,20,0.82);padding:9px 11px;border-radius:8px;width:186px;' +
'box-shadow:0 2px 12px rgba(0,0,0,0.5);user-select:none;transition:transform 0.28s ease;';
p.innerHTML =
'<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:7px"><span style="font-weight:600">🌀 Visualizer</span><button id="vz-listbtn" title="Show / hide full preset list" style="' + BC_BTN + ';padding:1px 7px">&lt;&lt;</button></div>' +
'<label style="display:flex;align-items:center;gap:7px;margin:6px 0"><input type="checkbox" id="vz-on"> Background on</label>' +
'<label style="display:block;margin:6px 0">Opacity <span id="vz-opv" style="opacity:.6"></span><input type="range" id="vz-op" min="0" max="100" style="width:100%"></label>' +
'<label style="display:flex;align-items:center;gap:7px;margin:6px 0"><input type="checkbox" id="vz-ld"> Dim behind lane</label>' +
'<label style="display:block;margin:6px 0">Dim strength <input type="range" id="vz-lds" min="0" max="100" style="width:100%"></label>' +
'<label style="display:flex;align-items:center;gap:7px;margin:6px 0"><input type="checkbox" id="vz-ca"> Chart accents</label>' +
'<label style="display:block;margin:6px 0">Chart strength <input type="range" id="vz-cs" min="0" max="200" style="width:100%"></label>' +
'<label style="display:flex;align-items:center;gap:7px;margin:6px 0"><input type="checkbox" id="vz-ct"> Color tint</label>' +
'<label style="display:block;margin:6px 0">Tint strength <input type="range" id="vz-ts" min="0" max="100" style="width:100%"></label>' +
'<label style="display:block;margin:6px 0">Guitar gain <span id="vz-ggv" style="opacity:.6"></span><input type="range" id="vz-gg" min="1" max="12" step="0.5" style="width:100%"></label>' +
'<label style="display:block;margin:6px 0">Song gain <span id="vz-sgv" style="opacity:.6"></span><input type="range" id="vz-sg" min="0" max="5" step="0.1" style="width:100%"></label>' +
'<div style="border-top:1px solid rgba(255,255,255,.12);margin:8px 0 5px"></div>' +
'<div style="display:flex;align-items:center;gap:6px;margin:4px 0">' +
'<button id="vz-prev" style="' + BC_BTN + '">◀</button>' +
'<div id="vz-pname" style="flex:1;text-align:center;font-size:11px;opacity:.9;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;cursor:pointer" title="">—</div>' +
'<button id="vz-next" style="' + BC_BTN + '">▶</button>' +
'</div>' +
'<div style="display:flex;gap:6px;margin:4px 0">' +
'<button id="vz-fav" style="' + BC_BTN + ';flex:1">♡ Favorite</button>' +
'<button id="vz-ban" style="' + BC_BTN + ';flex:1">🚫 Ban</button>' +
'</div>' +
'<div style="display:flex;gap:6px;align-items:flex-end;margin:6px 0">' +
'<label style="flex:1">Cycle <select id="vz-cyc" style="width:100%;background:#11141f;color:#cfe3ff;border:1px solid rgba(255,255,255,.15);border-radius:5px;padding:3px"><option value="all">All</option><option value="favorites">Favorites</option><option value="bans">Bans</option></select></label>' +
'<button id="vz-hold" style="' + BC_BTN + '">⏸ Hold</button>' +
'</div>' +
'<div style="margin:5px 0 4px;font-size:11px;opacity:.75"><span id="vz-pcount">★ 0 🚫 0</span></div>' +
'<div id="vz-meter" style="opacity:.65;margin-top:6px;font:11px/1.3 monospace">gtr — · song —</div>' +
'<div style="opacity:.45;margin-top:4px;font-size:11px">` or ‹‹ to hide</div>';
(host || document.body).appendChild(p);
// Slide handle (<< / >>) so the panel can tuck off the right edge and stop
// covering the Now / Up-Next labels.
const tab = document.createElement('button');
tab.textContent = '>>';
tab.title = 'Hide / show controls';
tab.style.cssText = 'position:absolute;top:6px;left:-23px;width:23px;height:28px;border:none;cursor:pointer;' +
'background:rgba(8,10,20,0.82);color:#cfe3ff;border-radius:7px 0 0 7px;font:12px/1 monospace;padding:0;';
p.appendChild(tab);
tab.addEventListener('click', () => {
_bcCollapsed = !_bcCollapsed;
if (_bcCollapsed) _bcPaneOpen = false; // collapsing the panel hides the pane too
tab.textContent = _bcCollapsed ? '<<' : '>>';
const lb = p.querySelector('#vz-listbtn'); if (lb) lb.textContent = _bcPaneOpen ? '>>' : '<<';
_bcLayout();
});
// Sliding preset-list pane (sits to the LEFT of the control panel)
const pane = document.createElement('div');
pane.id = 'viz3d-listpane';
pane.style.cssText = 'position:absolute;top:50%;right:10px;z-index:99999;pointer-events:auto;width:236px;max-height:74vh;display:flex;flex-direction:column;' +
'background:rgba(8,10,20,0.93);border-radius:8px;box-shadow:0 2px 14px rgba(0,0,0,0.55);color:#cfe3ff;' +
'font:12px system-ui,sans-serif;overflow:hidden;transform:translateX(calc(100% + 16px)) translateY(-50%);transition:transform 0.28s ease;';
pane.innerHTML =
'<div style="display:flex;align-items:center;justify-content:space-between;padding:7px 9px 7px 10px;font-weight:600;border-bottom:1px solid rgba(255,255,255,.1)"><span>Presets</span><button id="vz-defaults" title="Restore the bundled default favorites + bans" style="' + BC_BTN + ';font-weight:400">↺ defaults</button></div>' +
'<input id="vz-filter" placeholder="filter…" spellcheck="false" style="margin:8px 9px 6px;padding:4px 7px;background:#11141f;color:#cfe3ff;border:1px solid rgba(255,255,255,.15);border-radius:5px;outline:none">' +
'<div id="vz-list" style="overflow-y:auto;padding:0 4px 8px"></div>';
(host || document.body).appendChild(pane);
_bcPane = pane;
_bcListEl = pane.querySelector('#vz-list');
_bcFilterEl = pane.querySelector('#vz-filter');
_bcFilterEl.addEventListener('input', _bcRenderList);
pane.querySelector('#vz-defaults').addEventListener('click', _bcRestoreDefaults);
const q = (id) => p.querySelector(id);
const on = q('#vz-on'), op = q('#vz-op'), opv = q('#vz-opv'), ld = q('#vz-ld'), lds = q('#vz-lds');
const ca = q('#vz-ca'), tn = q('#vz-ct'), cs = q('#vz-cs'), ts = q('#vz-ts'), gg = q('#vz-gg'), ggv = q('#vz-ggv'), sg = q('#vz-sg'), sgv = q('#vz-sgv');
on.checked = s.enabled; op.value = Math.round(s.opacity * 100); opv.textContent = op.value + '%';
ld.checked = s.laneDim; lds.value = Math.round(s.laneDimStrength * 100);
ca.checked = s.chartAccents; tn.checked = s.colorTint;
cs.value = Math.round((s.chartStrength != null ? s.chartStrength : 1) * 100);
ts.value = Math.round((s.tintStrength != null ? s.tintStrength : 0.65) * 100);
gg.value = s.guitarGain != null ? s.guitarGain : 6; ggv.textContent = '×' + gg.value;
sg.value = s.songGain != null ? s.songGain : 1.8; sgv.textContent = '×' + sg.value;
const commit = () => {
s.enabled = on.checked; s.opacity = op.value / 100;
s.laneDim = ld.checked; s.laneDimStrength = lds.value / 100;
s.chartAccents = ca.checked; s.colorTint = tn.checked;
s.chartStrength = cs.value / 100; s.tintStrength = ts.value / 100; s.guitarGain = parseFloat(gg.value); s.songGain = parseFloat(sg.value);
opv.textContent = op.value + '%'; ggv.textContent = '×' + gg.value; sgv.textContent = '×' + sg.value;
_bcSaveSettings(); _bcApplyAll();
};
[on, op, ld, lds, ca, tn, cs, ts, gg, sg].forEach((el) => el.addEventListener('input', commit));
_bcPanel = p;
// Preset curation wiring (favorites / bans / cycle / reset)
_bcLoadLists();
const cyc = q('#vz-cyc');
cyc.value = s.cyclePool || 'all';
cyc.addEventListener('change', () => { s.cyclePool = cyc.value; _bcSaveSettings(); });
_bcSetHold(!!s.hold); // sync the Hold button label to the saved state
q('#vz-hold').addEventListener('click', () => _bcSetHold(!_bcLoadSettings().hold));
q('#vz-listbtn').addEventListener('click', () => _bcSetPane(!_bcPaneOpen));
q('#vz-pname').addEventListener('click', () => _bcSetPane(!_bcPaneOpen));
q('#vz-prev').addEventListener('click', () => { if (_bcPrimary) _bcPrimary.step(-1); });
q('#vz-next').addEventListener('click', () => { if (_bcPrimary) _bcPrimary.step(1); });
q('#vz-fav').addEventListener('click', () => { if (_bcPrimary) _bcPrimary.toggleFav(); });
q('#vz-ban').addEventListener('click', () => { if (_bcPrimary) _bcPrimary.banCur(); });
_bcSetPane(false); // start collapsed; sets the list-button label
_bcUpdatePanelPreset();
// Live level readout — proves the song (not just guitar) is driving things.
// Self-stops when the panel is removed (_bcPanel !== p).
(function meterLoop() {
if (_bcPanel !== p) return;
const m = p.querySelector('#vz-meter');
if (m) m.textContent = 'gtr ' + _bcMeters.gtr.toFixed(2) + ' · song ' + _bcMeters.song.toFixed(2);
setTimeout(meterLoop, 150);
})();
if (!_bcPanelKeyBound) {
_bcPanelKeyBound = true;
window.addEventListener('keydown', (e) => {
if (e.key !== '`' || e.metaKey || e.ctrlKey || !_bcPanel) return;
const tag = (e.target && e.target.tagName) || '';
if (tag === 'INPUT' || tag === 'TEXTAREA') return;
const reveal = _bcPanel.style.display === 'none';
_bcPanel.style.display = reveal ? '' : 'none';
if (_bcPane) _bcPane.style.display = reveal ? '' : 'none';
});
}
return _bcPanel;
}
// Create a Butterchurn background controller bound to a wrap element.
function _bcCreateController(wrap, sizeProvider, audioProvider) {
const ctrl = { viz: null, actx: null, guitar: null, map: null, keys: [], cycle: 0, dead: false, lastW: -1, lastH: -1, canvas: null, backdrop: null, scrim: null, tint: null };
// Layered DOM in the wrap, all BEHIND the transparent 3D highway:
// backdrop(z-4 dark) → bc canvas(z-3) → tint(z-2 instrument color) → scrim(z-1 lane dim)
const mkLayer = (cls, css) => { const d = document.createElement('div'); d.className = cls; d.style.cssText = css; wrap.appendChild(d); return d; };
const backdrop = mkLayer('viz3d-backdrop', 'position:absolute;top:0;left:0;right:0;bottom:0;z-index:-4;background:#070710;pointer-events:none;');
const canvas = document.createElement('canvas');
canvas.className = 'viz3d-bc';
canvas.style.cssText = 'position:absolute;top:0;left:0;z-index:-3;pointer-events:none;';
wrap.appendChild(canvas);
const tint = mkLayer('viz3d-tint', 'position:absolute;top:0;left:0;right:0;bottom:0;z-index:-2;pointer-events:none;mix-blend-mode:overlay;background:transparent;');
const scrim = mkLayer('viz3d-scrim', 'position:absolute;top:0;left:0;right:0;bottom:0;z-index:-1;pointer-events:none;');
ctrl.canvas = canvas; ctrl.backdrop = backdrop; ctrl.scrim = scrim; ctrl.tint = tint;
ctrl.applySettings = function () {
const s = _bcLoadSettings();
canvas.style.display = s.enabled ? '' : 'none';
canvas.style.opacity = String(s.enabled ? s.opacity : 0);
if (s.laneDim) {
const a = Math.max(0, Math.min(1, s.laneDimStrength)).toFixed(3);
scrim.style.display = '';
scrim.style.background = 'linear-gradient(to right, rgba(0,0,0,0) 0%, rgba(0,0,0,' + a +
') 30%, rgba(0,0,0,' + a + ') 70%, rgba(0,0,0,0) 100%)';
} else {
scrim.style.display = 'none';
}
};
// ── Preset curation (favorites / bans / cycle mode) ──
ctrl.curName = null; ctrl.lastManual = 0;
ctrl.allList = () => (ctrl.keys || []).filter((k) => !_bcBanned.has(k));
ctrl.pool = () => {
const mode = _bcLoadSettings().cyclePool || 'all';
if (mode === 'bans') return (ctrl.keys || []).filter((k) => _bcBanned.has(k));
if (mode === 'favorites') {
const f = (ctrl.keys || []).filter((k) => _bcFavorites.has(k) && !_bcBanned.has(k));
if (f.length) return f;
}
return ctrl.allList();
};
ctrl.browseArr = () => ctrl.keys || []; // ◀▶ and the list pane walk the full preset list
ctrl.loadByName = (name, blend) => {
if (!ctrl.viz || !name || !ctrl.map || !ctrl.map[name]) return;
try { ctrl.viz.loadPreset(ctrl.map[name], blend || 0); ctrl.curName = name; } catch (e) {}
_bcUpdatePanelPreset();
};
ctrl.autoTick = () => {
if (ctrl.dead || _bcLoadSettings().hold) return;
if (performance.now() - ctrl.lastManual < 8000) return;
const pool = ctrl.pool();
if (!pool.length) return;
let name = pool[(Math.random() * pool.length) | 0];
if (pool.length > 1 && name === ctrl.curName) name = pool[(pool.indexOf(name) + 1) % pool.length];
ctrl.loadByName(name, 2.7);
};
ctrl.step = (dir) => {
const list = ctrl.browseArr();
if (!list.length) return;
let i = list.indexOf(ctrl.curName); if (i < 0) i = (dir > 0 ? -1 : 0);
i = (i + dir + list.length) % list.length;
ctrl.lastManual = performance.now();
ctrl.loadByName(list[i], 1.5);
};
ctrl.toggleFav = () => {
if (!ctrl.curName) return;
if (_bcFavorites.has(ctrl.curName)) _bcFavorites.delete(ctrl.curName);
else { _bcFavorites.add(ctrl.curName); _bcBanned.delete(ctrl.curName); }
_bcSaveLists(); _bcUpdatePanelPreset();
};
ctrl.banCur = () => {
if (!ctrl.curName) return;
if (_bcBanned.has(ctrl.curName)) { // un-ban (two-way) — stay on it
_bcBanned.delete(ctrl.curName);
_bcSaveLists(); _bcUpdatePanelPreset();
} else { // ban + advance off it
_bcBanned.add(ctrl.curName); _bcFavorites.delete(ctrl.curName);
_bcSaveLists(); ctrl.step(1);
}
};
_bcPrimary = ctrl;
_bcControllers.add(ctrl);
_bcEnsurePanel(wrap);
ctrl.applySettings();
_bcLoadLib().then(() => {
if (ctrl.dead) return;
const bc = _bcResolve();
if (!bc || typeof bc.createVisualizer !== 'function') { console.warn('[viz3d] Butterchurn global missing'); return; }
const Ctx = window.AudioContext || window.webkitAudioContext;
const sz = (sizeProvider && sizeProvider()) || { w: 1280, h: 720 };
// Browser (Docker/web app): REUSE the highway's existing shared
// analyser (the fog scenery's #audio / stems tap) via audioProvider,
// and build Butterchurn on that SAME AudioContext so connectAudio()
// doesn't fail cross-context. Desktop uses its own context fed by the
// guitar/mic input. `ownsActx` tracks whether WE created the context
// (so destroy() closes only contexts we own, never the shared one).
const fogAudio = _bcIsDesktop() ? null : (audioProvider ? audioProvider() : null);
ctrl.ownsActx = !(fogAudio && fogAudio.ctx);
ctrl.actx = (fogAudio && fogAudio.ctx) || new Ctx();
if (ctrl.actx.state === 'suspended' && ctrl.actx.resume) ctrl.actx.resume().catch(() => {});
ctrl.viz = bc.createVisualizer(ctrl.actx, canvas, {
width: sz.w || 1280, height: sz.h || 720,
pixelRatio: Math.min(window.devicePixelRatio || 1, 1.5), textureRatio: 1,
});
if (_bcIsDesktop()) {
try {
ctrl.guitar = _bcGuitarFeed(ctrl.actx, (srcNode) => { try { if (ctrl.viz) ctrl.viz.connectAudio(srcNode); } catch (e) {} });
console.log('[viz3d] bg: feeding GUITAR input into Butterchurn');
} catch (e) { console.warn('[viz3d] guitar feed failed', e); }
} else if (fogAudio && fogAudio.analyser) {
// The shared AnalyserNode is a passthrough — connecting it onward
// to Butterchurn's internal analyser doesn't disturb the fog's reads.
try { ctrl.viz.connectAudio(fogAudio.analyser); console.log('[viz3d] browser: Butterchurn tapping shared analyser (' + (fogAudio.source || 'core') + ')'); }
catch (e) { console.warn('[viz3d] shared-analyser connect failed', e); }
}
_bcLoadLists();
const presets = _bcPresets();
if (presets && typeof presets.getPresets === 'function') { ctrl.map = presets.getPresets(); ctrl.keys = Object.keys(ctrl.map); }
const pool0 = ctrl.pool();
ctrl.loadByName(pool0.length ? pool0[(Math.random() * pool0.length) | 0] : (ctrl.keys[0] || null), 0.0);
ctrl.cycle = setInterval(() => ctrl.autoTick(), 30000);
ctrl.connectedAnalyser = (fogAudio && fogAudio.analyser) || null;
console.log('[viz3d] Butterchurn ready, presets:', ctrl.keys.length);
}).catch((e) => {
// Async init failed (lib load, WebGL/context creation, etc.). Clean up
// the half-mounted controller so we don't leak an owned AudioContext /
// DOM layers, and mark it dead so _bcSyncMode can retry on a later
// mount instead of seeing a live-looking but non-functional bcCtrl.
console.error('[viz3d] Butterchurn load/init failed', e);
try { _bcReleaseCanvasGL(ctrl.canvas); } catch (_) {}
try { if (ctrl.guitar) { ctrl.guitar.stop(); ctrl.guitar = null; } } catch (_) {}
try { [ctrl.canvas, ctrl.backdrop, ctrl.scrim, ctrl.tint].forEach((el) => { if (el && el.parentNode) el.parentNode.removeChild(el); }); } catch (_) {}
if (ctrl.ownsActx && ctrl.actx && typeof ctrl.actx.close === 'function') { try { ctrl.actx.close(); } catch (_) {} }
ctrl.actx = null; ctrl.viz = null; ctrl.dead = true;
_bcControllers.delete(ctrl);
});
return {
applySettings() { ctrl.applySettings(); },
dead() { return ctrl.dead; },
ready() { return !!ctrl.viz; },
boundAnalyser() { return ctrl.connectedAnalyser || null; },
audioCtx() { return ctrl.actx; },
// Re-bind audio when the shared analyser changes (e.g. a stems song
// swap replaces the analyser). Same context → cheap reconnect; the
// caller handles a context change with a full rebuild (cross-context
// connectAudio is impossible — the visualizer is bound to one ctx).
reconnectAudio(a) {
if (!a || !a.analyser || !ctrl.viz) return false;
if (a.analyser === ctrl.connectedAnalyser) return true;
if (a.ctx && a.ctx !== ctrl.actx) return false; // needs rebuild
try { ctrl.viz.connectAudio(a.analyser); ctrl.connectedAnalyser = a.analyser; return true; } catch (e) { return false; }
},
chart(v) { if (ctrl.guitar && ctrl.guitar.setChart) ctrl.guitar.setChart(v); },
tint(hex, alpha) {
if (!ctrl.tint) return;
if (hex == null) { ctrl.tint.style.background = 'transparent'; return; }
const r = (hex >> 16) & 255, g = (hex >> 8) & 255, b = hex & 255;
ctrl.tint.style.background = 'rgba(' + r + ',' + g + ',' + b + ',' + (alpha || 0).toFixed(3) + ')';
},
render() {
const s = _bcLoadSettings();
if (!ctrl.viz || !s.enabled) return; // skip GPU work when the bg is off
const sz = sizeProvider && sizeProvider();
if (sz && sz.w > 0 && sz.h > 0 && (sz.w !== ctrl.lastW || sz.h !== ctrl.lastH)) {
ctrl.lastW = sz.w; ctrl.lastH = sz.h;
const wpx = sz.w + 'px', hpx = sz.h + 'px';
// Confine ALL layers to exactly the highway-canvas rect so the opaque
// backdrop can't bleed over the transport bar above the highway.
[ctrl.canvas, ctrl.backdrop, ctrl.scrim, ctrl.tint].forEach((el) => {
if (el) { el.style.width = wpx; el.style.height = hpx; el.style.right = 'auto'; el.style.bottom = 'auto'; }
});
try { ctrl.viz.setRendererSize(sz.w, sz.h); } catch (e) {}
}
try { ctrl.viz.render(); } catch (e) {}
},
resize(w, h) { if (ctrl.viz && ctrl.viz.setRendererSize) { try { ctrl.viz.setRendererSize(w, h); } catch (e) {} ctrl.lastW = w; ctrl.lastH = h; } },
destroy() {
ctrl.dead = true;
_bcControllers.delete(ctrl);
if (_bcPrimary === ctrl) { _bcPrimary = _bcControllers.values().next().value || null; _bcUpdatePanelPreset(); }
if (ctrl.cycle) { clearInterval(ctrl.cycle); ctrl.cycle = 0; }
if (ctrl.guitar) { ctrl.guitar.stop(); ctrl.guitar = null; }
// Release the Butterchurn WebGL context deterministically (don't
// wait for GC) so repeated mounts/toggles can't exhaust the
// browser's WebGL context cap (~16). Do it before removing the
// canvas from the DOM.
_bcReleaseCanvasGL(ctrl.canvas);
[ctrl.canvas, ctrl.backdrop, ctrl.scrim, ctrl.tint].forEach((el) => { if (el && el.parentNode) el.parentNode.removeChild(el); });
ctrl.viz = null; ctrl.connectedAnalyser = null;
// Close the AudioContext only if we own it (desktop, or the
// browser fallback). The browser path normally reuses the
// highway's shared context, which the fog system owns — never
// close that. Without this, desktop leaks a new AudioContext per
// mount and hits the browser's ~6-context cap after a few toggles.
if (ctrl.ownsActx && ctrl.actx && typeof ctrl.actx.close === 'function') {
try { ctrl.actx.close(); } catch (e) {}
}
ctrl.actx = null;
if (_bcControllers.size === 0) {
if (_bcPanel && _bcPanel.parentNode) _bcPanel.parentNode.removeChild(_bcPanel);
if (_bcPane && _bcPane.parentNode) _bcPane.parentNode.removeChild(_bcPane);
_bcPanel = null; _bcPane = null; _bcListEl = null; _bcFilterEl = null; _bcPaneOpen = false;
}
},
};
}
// Selectable per-string color palettes (issue #10). Each palette has
// 8 entries to match MAX_RENDER_STRINGS so 6/7/8-string arrangements
// all index safely. Default is the canonical chart-format classic
@@ -1130,7 +1776,7 @@
// _bgCoerce must reject a stored h3d_bg_style='venue' — otherwise venue could
// mount outside that flow and settings.html (which can't represent 'venue')
// would be unable to switch back. BG_STYLES still has a 'venue' renderer entry.
const BG_STYLE_IDS = ['off', 'particles', 'silhouettes', 'lights', 'geometric', 'image', 'video'];
const BG_STYLE_IDS = ['off', 'particles', 'silhouettes', 'lights', 'geometric', 'butterchurn', 'image', 'video'];
// Scene color themes — TWO INDEPENDENT AXES sharing one palette family.
// The combined `BG_THEMES` table below is the single source of truth; each
// entry carries the colors for BOTH axes, but the two axes are selected and
@@ -2573,6 +3219,9 @@
// ── Per-instance Three.js state ───────────────────────────────────
let scene = null, cam = null, ren = null;
let wrap = null;
let bcCtrl = null; // Butterchurn audio-reactive background (the 'butterchurn' bg-style)
let _chartEnv = 0, _chartPrevT = -1, _bcBeatIdx = 0, _bcNoteIdx = 0, _bcChordIdx = 0, _bcTintTarget = null;
let _tintR = 20, _tintG = 24, _tintB = 40; // smoothed instrument-color tint for the bg
// highway:visibility listener (feedBack#246). Hides the .h3d-wrap
// overlay when feedBack's canvas is display:none'd (splitscreen
// case). Without this, the wrap is a *sibling* of #highway so
@@ -5244,9 +5893,9 @@
// steers GPU selection to the dGPU; on single-dGPU desktops it
// requests the high-performance power profile. (It does not by
// itself force NVIDIA's utilisation-driven clock ramp on Linux.)
ren = new T.WebGLRenderer({ antialias: true, powerPreference: 'high-performance' });
ren = new T.WebGLRenderer({ antialias: true, powerPreference: 'high-performance', alpha: true });
_probe = new T.Vector3();
ren.setClearColor(0x101820);
ren.setClearColor(0x101820, _bcActive() ? 0 : 1);
wrap.appendChild(ren.domElement);
lyricsCanvas = document.createElement('canvas');
@@ -6635,6 +7284,7 @@
// setting without writing it back. Re-applied here so it sticks across
// setting reloads.
if (_bgReactiveOptOut) bgReactive = false;
if (bgStyleId === 'butterchurn') bgReactive = false; // Butterchurn owns the <audio> tap
const newPaletteId = _bgReadSetting(panelKey, 'palette');
let newPalette;
if (newPaletteId === 'custom') {
@@ -6952,6 +7602,31 @@
function _bgEffectiveStyleId() {
return _venueSceneOverride ? 'venue' : bgStyleId;
}
// The 'butterchurn' bg-style renders a WebGL MilkDrop canvas BEHIND a
// transparent highway via the self-contained _bc* controller (top of file),
// NOT a Three.js fog-scenery style (its scenery falls back to 'off'). Mount
// is idempotent and driven by the bg-style dropdown through _bgMountStyle.
function _bcActive() { return bgStyleId === 'butterchurn'; }
function _bcSyncMode() {
if (_bcActive()) {
// Recreate when there's no controller, or the last one died during
// async init (lib/WebGL failure) — a dead controller self-cleaned,
// so retry here instead of leaving the style permanently broken.
if ((!bcCtrl || (bcCtrl.dead && bcCtrl.dead())) && wrap) {
if (bcCtrl) bcCtrl = null;
// audioProvider reuses this instance's shared analyser (the
// fog scenery's #audio / stems tap) so the browser path never
// opens a second createMediaElementSource on #audio.
try { bcCtrl = _bcCreateController(wrap, () => canvasSize(highwayCanvas), () => { try { return _bgGetAnalyser(); } catch (e) { return null; } }); }
catch (e) { console.warn('[3D-Hwy] Butterchurn init failed', e); }
}
if (ren) ren.setClearColor(0x101820, 0); // transparent so the visualizer shows through
} else if (bcCtrl) {
try { bcCtrl.destroy(); } catch (e) {}
bcCtrl = null;
_applyBgTheme(); // restore the opaque themed clear
}
}
function _bgMountStyle() {
const effectiveId = _bgEffectiveStyleId();
const style = BG_STYLES[effectiveId] || BG_STYLES.off;
@@ -6987,6 +7662,7 @@
bgStage = stage;
bgState = result;
bgMountedStyleId = effectiveId;
_bcSyncMode();
}
function _bgUnmountStyle() {
const mountedId = bgMountedStyleId || _bgEffectiveStyleId();
@@ -7049,7 +7725,9 @@
scene.fog.color.setHex(0x080c12);
scene.fog.near = FOG_START * 0.98;
scene.fog.far = FOG_END * 0.98;
if (ren) ren.setClearColor(0x080c12);
// Keep the clear transparent while Butterchurn is active so the
// venue scene doesn't occlude the visualizer behind the highway.
if (ren) ren.setClearColor(0x080c12, _bcActive() ? 0 : 1);
if (ambLight) ambLight.intensity = 0.68;
} else {
// Restore the user's scene-color theme (clear + fog) rather than
@@ -7083,7 +7761,7 @@
const bg = _bgBackgroundColors(bgThemeId);
if (!_venueSceneOverride) {
if (scene && scene.fog) scene.fog.color.setHex(bg.fog);
if (ren) ren.setClearColor(bg.clear);
if (ren) ren.setClearColor(bg.clear, _bcActive() ? 0 : 1);
}
// --- Highway axis: board plane + lane ---
const hw = _bgHighwayColors(hwThemeId);
@@ -13225,6 +13903,7 @@
_fxElemSeen = new WeakSet();
_fxRingMs = _fxBreakMs = -1e9;
_chordVerdicts = new Map();
if (bcCtrl) { try { bcCtrl.destroy(); } catch (e) {} bcCtrl = null; }
_bgUnmountStyle();
bgGroup = null; _bgLastT = 0;
_diagChord = null; _diagPrev = null; _diagPrevOpacity = 0; _diagPrevStartOpacity = 0; _diagPrevStartT = null;
@@ -13588,6 +14267,77 @@
}
}
// Browser: the shared analyser can change between songs (a sloppak
// stems swap replaces it, often on a new context) — or may not have
// existed when the controller mounted. Keep the visualizer bound to
// the LIVE analyser by comparing against what the controller
// actually bound (boundAnalyser()), not a separately-tracked guess:
// cheap reconnect when it's the same context, full controller
// rebuild when the context changed (cross-context connectAudio is
// impossible). Only act once the viz is ready (ready()), so we
// don't thrash a controller that's still loading async. Done before
// the render block so a rebuild this frame just skips one bc frame
// (bcCtrl goes null) without affecting the highway's own render.
if (bcCtrl && !_bcIsDesktop() && bcCtrl.ready && bcCtrl.ready()) {
let a = null;
try { a = _bgGetAnalyser(); } catch (e) { a = null; }
const an = a && a.analyser;
const bound = bcCtrl.boundAnalyser ? bcCtrl.boundAnalyser() : null;
if (an && an !== bound) {
if (!(bcCtrl.reconnectAudio && bcCtrl.reconnectAudio(a))) {
// Context changed (or reconnect failed) — rebuild via the
// proven destroy/create paths so the new context binds.
try { bcCtrl.destroy(); } catch (e) {}
bcCtrl = null;
_bcSyncMode();
}
}
}
if (bcCtrl) {
const cfg = _bcLoadSettings();
const _ct = bundle.currentTime || 0;
if (cfg.chartAccents) {
if (_ct < _chartPrevT - 0.08 || _ct - _chartPrevT > 1.0) {
_bcBeatIdx = _bcFfIdx(bundle.beats, _ct, 'time');
_bcNoteIdx = _bcFfIdx(bundle.notes, _ct, 't');
_bcChordIdx = _bcFfIdx(bundle.chords, _ct, 't');
}
const _beats = bundle.beats || [];
while (_bcBeatIdx < _beats.length && _beats[_bcBeatIdx].time <= _ct) {
const strong = _beats[_bcBeatIdx].measure !== undefined && _beats[_bcBeatIdx].measure !== -1;
_chartEnv = Math.max(_chartEnv, strong ? 1.0 : 0.6);
_bcBeatIdx++;
}
const _notes = bundle.notes || [];
let _tintS = -1;
while (_bcNoteIdx < _notes.length && _notes[_bcNoteIdx].t <= _ct) {
_chartEnv = Math.max(_chartEnv, 0.6);
_tintS = _notes[_bcNoteIdx].s;
_bcNoteIdx++;
}
const _chords = bundle.chords || [];
while (_bcChordIdx < _chords.length && _chords[_bcChordIdx].t <= _ct) {
_chartEnv = Math.max(_chartEnv, 0.95);
_bcChordIdx++;
}
if (_tintS >= 0 && activePalette && activePalette.length) {
_bcTintTarget = activePalette[((_tintS % activePalette.length) + activePalette.length) % activePalette.length];
}
_chartPrevT = _ct;
_chartEnv *= 0.86;
bcCtrl.chart(_chartEnv * (cfg.chartStrength != null ? cfg.chartStrength : 1));
} else {
bcCtrl.chart(0);
}
if (cfg.colorTint && _bcTintTarget != null) {
const tr = (_bcTintTarget >> 16) & 255, tg = (_bcTintTarget >> 8) & 255, tb = _bcTintTarget & 255;
_tintR += (tr - _tintR) * 0.06; _tintG += (tg - _tintG) * 0.06; _tintB += (tb - _tintB) * 0.06;
bcCtrl.tint((Math.round(_tintR) << 16) | (Math.round(_tintG) << 8) | Math.round(_tintB), cfg.tintStrength != null ? cfg.tintStrength : 0.65);
} else {
bcCtrl.tint(null, 0);
}
bcCtrl.render();
}
pbBeg(6); ren.render(scene, cam); pbEnd(6);
if (lyricsCtx && lyricsCanvas) {
lyricsCtx.clearRect(0, 0, lyricsCanvas.width, lyricsCanvas.height);
+2 -1
View File
@@ -124,6 +124,7 @@
<option value="silhouettes">Silhouettes (parallax)</option>
<option value="lights">Lights (stage glows)</option>
<option value="geometric">Geometric (rotating shapes)</option>
<option value="butterchurn">Butterchurn (visualizer)</option>
<option value="image" disabled>Custom image (none uploaded)</option>
<option value="video" disabled>Custom video (none uploaded)</option>
</select>
@@ -779,7 +780,7 @@
<script>
(function () {
const DEFAULTS = { style: 'particles', intensity: 0.5, reactive: true, palette: 'default', bgTheme: 'default', hwTheme: 'default', showFretOnNote: true, fretNumberGhostScope: 'chords', cameraSmoothing: 0.5, zoomSmoothing: 0.5, tiltSmoothing: 0.5, cameraLockLow: false, cameraLockZoom: 0.5, cameraMode: 'lookahead', nutHeadstockVisible: true, tuningLabelsVisible: true, nutColor: '#f5f3f0', headstockColor: '#d4b48a', textSize: 0.5, vibrancy: 0.85, glow: 0.25, customImageDataUrl: '', customImageName: '', customVideoName: '', chordDiagramVisible: true, chordDiagramSize: 0.5, chordDiagramPosition: 'tl', fretColumnMarkerCadence: 1, projectionVisible: true, inlayLabelsVisible: false, sectionLabelsOnHighway: false, sectionHudVisible: false, sectionHudPosition: 'tr', sectionHudSize: 0.5, toneHudVisible: false, toneHudPosition: 'tl', toneHudSize: 0.5, fpsVisible: false, fretDividersVisible: true, slideArrowApproachVisible: true, slideArrowNeckVisible: true, slideArrowChainPreviewVisible: true };
const VALID_STYLES = new Set(['off', 'particles', 'silhouettes', 'lights', 'geometric', 'image', 'video']);
const VALID_STYLES = new Set(['off', 'particles', 'silhouettes', 'lights', 'geometric', 'butterchurn', 'image', 'video']);
// Scene color themes — the single id+label source for BOTH the
// Background and Highway dropdowns AND the validator below. The
// <option>s are generated from this list (see hydration), so the two