mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-11 03:09:57 +00:00
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:
co-authored by
Claude Opus 4.8
ChrisBeWithYou
parent
97dae88860
commit
3bb55ab854
@@ -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
@@ -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);
|
||||
@@ -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"><<</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);
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user