vizcore 0.1.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +544 -9
- data/docs/.nojekyll +0 -0
- data/docs/assets/site.css +744 -0
- data/docs/assets/vizcore-demo.gif +0 -0
- data/docs/assets/vizcore-poster.png +0 -0
- data/docs/assets/vj-tunnel.js +159 -0
- data/docs/index.html +224 -0
- data/examples/README.md +59 -0
- data/examples/assets/README.md +19 -0
- data/examples/audio_inspector.rb +34 -0
- data/examples/club_intro_drop.rb +78 -0
- data/examples/kansai_rubykaigi_visual.rb +70 -0
- data/examples/live_coding_minimal.rb +22 -0
- data/examples/midi_controller_show.rb +78 -0
- data/examples/midi_scene_switch.rb +3 -1
- data/examples/parser_visualizer.rb +48 -0
- data/examples/readme_demo.rb +17 -0
- data/examples/rhythm_geometry.rb +34 -0
- data/examples/ruby_crystal_show.rb +35 -0
- data/examples/shader_playground.rb +18 -0
- data/examples/unyo_liquid.rb +59 -0
- data/examples/vj_ambient_chill_room.rb +124 -0
- data/examples/vj_dnb_jungle.rb +170 -0
- data/examples/vj_festival_mainstage.rb +245 -0
- data/examples/vj_festival_mainstage.yml +17 -0
- data/examples/vj_glitch_industrial.rb +164 -0
- data/examples/vj_hiphop_cipher.rb +167 -0
- data/examples/vj_jpop_idol_live.rb +210 -0
- data/examples/vj_synthwave_retro.rb +173 -0
- data/examples/vj_techno_warehouse.rb +195 -0
- data/frontend/index.html +468 -2
- data/frontend/src/audio-inspector.js +40 -0
- data/frontend/src/live-controls.js +131 -0
- data/frontend/src/main.js +792 -16
- data/frontend/src/midi-learn.js +194 -0
- data/frontend/src/performance-monitor.js +183 -0
- data/frontend/src/plugin-runtime.js +130 -0
- data/frontend/src/projector-mode.js +56 -0
- data/frontend/src/renderer/engine.js +148 -3
- data/frontend/src/renderer/layer-manager.js +428 -30
- data/frontend/src/renderer/shader-manager.js +26 -0
- data/frontend/src/runtime-control-preset.js +11 -0
- data/frontend/src/shader-error-overlay.js +29 -0
- data/frontend/src/shader-param-controls.js +93 -0
- data/frontend/src/shaders/builtins.js +380 -2
- data/frontend/src/shaders/post-effects.js +52 -0
- data/frontend/src/visual-regression.js +67 -0
- data/frontend/src/visual-settings-preset.js +103 -0
- data/frontend/src/visuals/geometry.js +268 -0
- data/frontend/src/visuals/image-renderer.js +291 -0
- data/frontend/src/visuals/particle-system.js +56 -10
- data/frontend/src/visuals/spectrogram-renderer.js +226 -0
- data/frontend/src/visuals/text-renderer.js +112 -11
- data/frontend/src/websocket-client.js +12 -1
- data/lib/vizcore/analysis/adaptive_normalizer.rb +70 -0
- data/lib/vizcore/analysis/beat_detector.rb +4 -2
- data/lib/vizcore/analysis/bpm_estimator.rb +8 -0
- data/lib/vizcore/analysis/feature_recorder.rb +159 -0
- data/lib/vizcore/analysis/feature_replay.rb +84 -0
- data/lib/vizcore/analysis/pipeline.rb +235 -11
- data/lib/vizcore/analysis/tap_tempo.rb +74 -0
- data/lib/vizcore/analysis.rb +4 -0
- data/lib/vizcore/audio/dummy_sine_input.rb +1 -1
- data/lib/vizcore/audio/fixture_input.rb +65 -0
- data/lib/vizcore/audio/input_manager.rb +4 -2
- data/lib/vizcore/audio/mic_input.rb +24 -8
- data/lib/vizcore/audio/portaudio_ffi.rb +106 -1
- data/lib/vizcore/audio.rb +1 -0
- data/lib/vizcore/cli/doctor.rb +159 -0
- data/lib/vizcore/cli/dsl_reference.rb +99 -0
- data/lib/vizcore/cli/layer_docs.rb +46 -0
- data/lib/vizcore/cli/scene_diagnostics.rb +23 -0
- data/lib/vizcore/cli/scene_inspector.rb +136 -0
- data/lib/vizcore/cli/scene_validator.rb +245 -0
- data/lib/vizcore/cli/shader_template.rb +68 -0
- data/lib/vizcore/cli/shader_uniform_docs.rb +54 -0
- data/lib/vizcore/cli.rb +689 -18
- data/lib/vizcore/config.rb +103 -2
- data/lib/vizcore/control_preset.rb +68 -0
- data/lib/vizcore/dsl/engine.rb +277 -5
- data/lib/vizcore/dsl/layer_builder.rb +491 -22
- data/lib/vizcore/dsl/layer_group_builder.rb +112 -0
- data/lib/vizcore/dsl/mapping_resolver.rb +132 -3
- data/lib/vizcore/dsl/mapping_transform_builder.rb +71 -0
- data/lib/vizcore/dsl/reaction_builder.rb +44 -0
- data/lib/vizcore/dsl/scene_builder.rb +61 -5
- data/lib/vizcore/dsl/shader_source_resolver.rb +67 -6
- data/lib/vizcore/dsl/style_builder.rb +68 -0
- data/lib/vizcore/dsl/timeline_builder.rb +138 -0
- data/lib/vizcore/dsl/transition_controller.rb +77 -0
- data/lib/vizcore/dsl.rb +5 -1
- data/lib/vizcore/layer_catalog.rb +273 -0
- data/lib/vizcore/project_manifest.rb +152 -0
- data/lib/vizcore/renderer/png_writer.rb +57 -0
- data/lib/vizcore/renderer/render_sequence.rb +153 -0
- data/lib/vizcore/renderer/scene_frame_source.rb +119 -0
- data/lib/vizcore/renderer/scene_serializer.rb +36 -3
- data/lib/vizcore/renderer/snapshot.rb +38 -0
- data/lib/vizcore/renderer/snapshot_renderer.rb +446 -0
- data/lib/vizcore/renderer.rb +5 -0
- data/lib/vizcore/server/frame_broadcaster.rb +91 -5
- data/lib/vizcore/server/gallery_app.rb +155 -0
- data/lib/vizcore/server/gallery_page.rb +100 -0
- data/lib/vizcore/server/gallery_runner.rb +48 -0
- data/lib/vizcore/server/rack_app.rb +203 -4
- data/lib/vizcore/server/runner.rb +370 -22
- data/lib/vizcore/server/scene_dependency_watcher.rb +79 -0
- data/lib/vizcore/server/websocket_handler.rb +60 -10
- data/lib/vizcore/server.rb +4 -0
- data/lib/vizcore/sync/osc_message.rb +103 -0
- data/lib/vizcore/sync/osc_receiver.rb +68 -0
- data/lib/vizcore/sync.rb +4 -0
- data/lib/vizcore/templates/midi_control_scene.rb +3 -1
- data/lib/vizcore/templates/plugin_layer.rb +20 -0
- data/lib/vizcore/templates/plugin_readme.md +23 -0
- data/lib/vizcore/templates/plugin_renderer.js +43 -0
- data/lib/vizcore/templates/plugin_scene.rb +14 -0
- data/lib/vizcore/templates/project_readme.md +7 -23
- data/lib/vizcore/templates/rubykaigi_scene.rb +30 -0
- data/lib/vizcore/version.rb +1 -1
- data/lib/vizcore.rb +27 -0
- data/scripts/browser_capture.mjs +75 -0
- data/sig/vizcore.rbs +362 -0
- metadata +83 -3
- data/docs/GETTING_STARTED.md +0 -105
data/frontend/src/main.js
CHANGED
|
@@ -1,6 +1,53 @@
|
|
|
1
|
+
import { BAND_KEYS, DEFAULT_FFT_BINS, buildAudioInspectorState, formatMeterValue } from "./audio-inspector.js";
|
|
2
|
+
import {
|
|
3
|
+
createLiveControlState,
|
|
4
|
+
isTapTempoShortcut,
|
|
5
|
+
keyboardActionForKey,
|
|
6
|
+
liveControlStatusText,
|
|
7
|
+
normalizeKeyboardMappings,
|
|
8
|
+
shortcutActionForKey,
|
|
9
|
+
shortcutSceneIndexForKey,
|
|
10
|
+
toggleLiveControl,
|
|
11
|
+
} from "./live-controls.js";
|
|
12
|
+
import {
|
|
13
|
+
createPerformanceMonitorState,
|
|
14
|
+
formatPerformanceMonitorText,
|
|
15
|
+
recordConnectionStatus,
|
|
16
|
+
recordLatencyProbe,
|
|
17
|
+
recordRenderFrame,
|
|
18
|
+
recordShaderCompile,
|
|
19
|
+
recordSocketFrame,
|
|
20
|
+
} from "./performance-monitor.js";
|
|
21
|
+
import {
|
|
22
|
+
loadMidiLearnBindings,
|
|
23
|
+
midiLearnActionLabel,
|
|
24
|
+
midiMessageActive,
|
|
25
|
+
midiMessageSignature,
|
|
26
|
+
midiMessageUnitValue,
|
|
27
|
+
midiSignatureLabel,
|
|
28
|
+
saveMidiLearnBindings,
|
|
29
|
+
upsertMidiLearnBinding,
|
|
30
|
+
} from "./midi-learn.js";
|
|
31
|
+
import { applyProjectorMode, resolveProjectorMode } from "./projector-mode.js";
|
|
1
32
|
import { Engine } from "./renderer/engine.js";
|
|
33
|
+
import { SHADER_COMPILE_EVENT } from "./renderer/shader-manager.js";
|
|
34
|
+
import {
|
|
35
|
+
pruneShaderParamOverrides,
|
|
36
|
+
shaderParamControlEntries
|
|
37
|
+
} from "./shader-param-controls.js";
|
|
38
|
+
import { normalizeRuntimeControlPreset } from "./runtime-control-preset.js";
|
|
39
|
+
import { SHADER_ERROR_EVENT, formatShaderErrorMessage, formatShaderErrorTitle } from "./shader-error-overlay.js";
|
|
40
|
+
import {
|
|
41
|
+
exportVisualSettingsPreset,
|
|
42
|
+
importVisualSettingsPreset,
|
|
43
|
+
loadVisualSettingsPreset,
|
|
44
|
+
saveVisualSettingsPreset,
|
|
45
|
+
visualSettingFromUnit
|
|
46
|
+
} from "./visual-settings-preset.js";
|
|
2
47
|
import { WebSocketClient } from "./websocket-client.js";
|
|
3
48
|
|
|
49
|
+
window.__vizcoreMainStarted = true;
|
|
50
|
+
|
|
4
51
|
const canvas = document.querySelector("#vizcore-canvas");
|
|
5
52
|
const wsStatusElement = document.querySelector("#ws-status");
|
|
6
53
|
const sceneStatusElement = document.querySelector("#scene-status");
|
|
@@ -8,29 +55,112 @@ const transitionStatusElement = document.querySelector("#transition-status");
|
|
|
8
55
|
const frameStatusElement = document.querySelector("#frame-status");
|
|
9
56
|
const bpmStatusElement = document.querySelector("#bpm-status");
|
|
10
57
|
const beatStatusElement = document.querySelector("#beat-status");
|
|
58
|
+
const blackoutButton = document.querySelector("#blackout-toggle");
|
|
59
|
+
const freezeButton = document.querySelector("#freeze-toggle");
|
|
60
|
+
const liveControlStatusElement = document.querySelector("#live-control-status");
|
|
61
|
+
const performanceMonitorElement = document.querySelector("#performance-monitor");
|
|
62
|
+
const inspectorPeakElement = document.querySelector("#inspector-peak");
|
|
63
|
+
const inspectorAmplitudeFill = document.querySelector("#inspector-amplitude-fill");
|
|
64
|
+
const inspectorAmplitudeValue = document.querySelector("#inspector-amplitude-value");
|
|
65
|
+
const inspectorBandElements = Object.fromEntries(
|
|
66
|
+
BAND_KEYS.map((key) => [
|
|
67
|
+
key,
|
|
68
|
+
{
|
|
69
|
+
fill: document.querySelector(`#inspector-band-${key}-fill`),
|
|
70
|
+
value: document.querySelector(`#inspector-band-${key}-value`)
|
|
71
|
+
}
|
|
72
|
+
])
|
|
73
|
+
);
|
|
74
|
+
const fftPreviewElement = document.querySelector("#fft-preview");
|
|
11
75
|
const audioSourceStatusElement = document.querySelector("#audio-source-status");
|
|
12
76
|
const audioTrackStatusElement = document.querySelector("#audio-track-status");
|
|
13
77
|
const audioPlaybackStatusElement = document.querySelector("#audio-playback-status");
|
|
14
78
|
const sceneSwitcherElement = document.querySelector("#scene-switcher");
|
|
15
79
|
const audioToggleButton = document.querySelector("#audio-toggle");
|
|
80
|
+
const visualGainControl = document.querySelector("#visual-gain-control");
|
|
81
|
+
const bassBoostControl = document.querySelector("#bass-boost-control");
|
|
82
|
+
const smoothingControl = document.querySelector("#smoothing-control");
|
|
83
|
+
const beatHoldControl = document.querySelector("#beat-hold-control");
|
|
84
|
+
const wobbleControl = document.querySelector("#wobble-control");
|
|
85
|
+
const reactivitySaveButton = document.querySelector("#reactivity-save");
|
|
86
|
+
const reactivityLoadButton = document.querySelector("#reactivity-load");
|
|
87
|
+
const reactivityProjectSaveButton = document.querySelector("#reactivity-project-save");
|
|
88
|
+
const reactivityExportButton = document.querySelector("#reactivity-export");
|
|
89
|
+
const reactivityImportButton = document.querySelector("#reactivity-import");
|
|
90
|
+
const reactivityStatusElement = document.querySelector("#reactivity-status");
|
|
91
|
+
const midiLearnStatusElement = document.querySelector("#midi-learn-status");
|
|
92
|
+
const midiLearnButtons = Array.from(document.querySelectorAll("[data-midi-learn-action]"));
|
|
93
|
+
const shaderParamControlsElement = document.querySelector("#shader-param-controls");
|
|
94
|
+
const shaderErrorOverlay = document.querySelector("#shader-error-overlay");
|
|
95
|
+
const shaderErrorTitleElement = document.querySelector("#shader-error-title");
|
|
96
|
+
const shaderErrorMessageElement = document.querySelector("#shader-error-message");
|
|
97
|
+
const shaderErrorCloseButton = document.querySelector("#shader-error-close");
|
|
98
|
+
const LATENCY_PROBE_INTERVAL_MS = 3000;
|
|
16
99
|
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
100
|
+
const visualSettings = loadVisualSettingsPreset(browserStorage());
|
|
101
|
+
let midiLearnBindings = loadMidiLearnBindings(browserStorage());
|
|
102
|
+
const liveControls = createLiveControlState();
|
|
103
|
+
const performanceMonitor = createPerformanceMonitorState();
|
|
104
|
+
let projectorMode = resolveProjectorMode({ body: document.body, location: window.location });
|
|
21
105
|
let currentSceneName = "unknown";
|
|
22
106
|
let audioElement = null;
|
|
23
107
|
let frameCount = 0;
|
|
24
108
|
let lastConnectedAt = null;
|
|
25
109
|
let lastTransportSyncAt = 0;
|
|
110
|
+
let latencyProbeTimer = null;
|
|
26
111
|
let beatFlashUntil = 0;
|
|
27
112
|
let availableSceneNames = [];
|
|
113
|
+
let keyboardMappings = [];
|
|
28
114
|
let pendingSceneName = null;
|
|
29
115
|
let pendingSceneRequestedAt = 0;
|
|
116
|
+
let tapTempoKey = null;
|
|
117
|
+
let runtimeGlobalsReceived = false;
|
|
118
|
+
let runtimeControlPresetApplied = false;
|
|
119
|
+
let controlPresetSaveUrl = null;
|
|
120
|
+
let shaderParamOverrides = {};
|
|
121
|
+
let shaderParamControlsSignature = "";
|
|
122
|
+
let midiAccess = null;
|
|
123
|
+
let pendingMidiLearnAction = null;
|
|
124
|
+
applyProjectorMode(document.body, projectorMode);
|
|
125
|
+
const engine = new Engine(canvas);
|
|
126
|
+
let rendererReady = false;
|
|
127
|
+
bindShaderCompileMetrics();
|
|
128
|
+
try {
|
|
129
|
+
engine.init();
|
|
130
|
+
rendererReady = true;
|
|
131
|
+
} catch (error) {
|
|
132
|
+
renderShaderError({
|
|
133
|
+
layer: "renderer",
|
|
134
|
+
shader: "webgl2",
|
|
135
|
+
message: error instanceof Error ? error.message : String(error)
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
engine.setVisualSettings(visualSettings);
|
|
139
|
+
engine.setLiveControls(liveControls);
|
|
140
|
+
bindLiveControls();
|
|
141
|
+
bindVisualControl(visualGainControl, "visualGain");
|
|
142
|
+
bindVisualControl(bassBoostControl, "bassBoost");
|
|
143
|
+
bindVisualControl(smoothingControl, "smoothing");
|
|
144
|
+
bindVisualControl(beatHoldControl, "beatHoldMs");
|
|
145
|
+
bindVisualControl(wobbleControl, "wobbleAmount");
|
|
146
|
+
bindVisualPresetControls();
|
|
147
|
+
bindMidiLearnControls();
|
|
148
|
+
renderLiveControlStatus();
|
|
149
|
+
renderPerformanceMonitor();
|
|
150
|
+
syncVisualControls();
|
|
151
|
+
renderReactivityStatus();
|
|
152
|
+
renderMidiLearnStatus();
|
|
153
|
+
bindShaderErrorOverlay();
|
|
154
|
+
const fftBars = initializeFftPreview(fftPreviewElement);
|
|
155
|
+
if (rendererReady) {
|
|
156
|
+
engine.start();
|
|
157
|
+
}
|
|
158
|
+
startPerformanceMonitorLoop();
|
|
30
159
|
|
|
31
160
|
const websocketUrl = buildWebSocketUrl();
|
|
32
161
|
const client = new WebSocketClient(websocketUrl, {
|
|
33
162
|
onFrame: (frame) => {
|
|
163
|
+
updatePerformanceMonitor(recordSocketFrame(performanceMonitor, frame, Date.now()));
|
|
34
164
|
engine.setAudioFrame(frame);
|
|
35
165
|
frameCount += 1;
|
|
36
166
|
let sceneName = String(frame?.scene?.name || currentSceneName);
|
|
@@ -48,12 +178,16 @@ const client = new WebSocketClient(websocketUrl, {
|
|
|
48
178
|
}
|
|
49
179
|
const sceneChanged = sceneName !== currentSceneName;
|
|
50
180
|
currentSceneName = sceneName;
|
|
181
|
+
if (sceneChanged) {
|
|
182
|
+
shaderParamControlsSignature = "";
|
|
183
|
+
}
|
|
184
|
+
updateShaderParamControls(frame?.scene?.layers);
|
|
51
185
|
const amplitude = Number(frame?.audio?.amplitude || 0).toFixed(4);
|
|
52
186
|
const bpm = Number(frame?.audio?.bpm || 0);
|
|
53
187
|
const beat = !!frame?.audio?.beat;
|
|
54
188
|
const beatCount = Math.max(0, Number(frame?.audio?.beat_count || 0) || 0);
|
|
55
189
|
if (beat) {
|
|
56
|
-
beatFlashUntil = performance.now() +
|
|
190
|
+
beatFlashUntil = performance.now() + visualSettings.beatHoldMs;
|
|
57
191
|
}
|
|
58
192
|
const beatVisible = performance.now() < beatFlashUntil;
|
|
59
193
|
sceneStatusElement.textContent = `Scene: ${sceneName}`;
|
|
@@ -64,6 +198,7 @@ const client = new WebSocketClient(websocketUrl, {
|
|
|
64
198
|
bpmStatusElement.textContent = `BPM: ${bpm > 0 ? bpm.toFixed(1) : "--"}`;
|
|
65
199
|
beatStatusElement.textContent = `Beat: ${beatVisible ? "ON" : "off"} | Count: ${beatCount}`;
|
|
66
200
|
beatStatusElement.classList.toggle("is-beat", beatVisible);
|
|
201
|
+
renderAudioInspector(frame?.audio);
|
|
67
202
|
},
|
|
68
203
|
onSceneChange: (payload) => {
|
|
69
204
|
const from = String(payload?.from || "unknown");
|
|
@@ -82,13 +217,37 @@ const client = new WebSocketClient(websocketUrl, {
|
|
|
82
217
|
currentSceneName = String(sceneName);
|
|
83
218
|
sceneStatusElement.textContent = `Scene: ${currentSceneName}`;
|
|
84
219
|
renderSceneButtons();
|
|
220
|
+
shaderParamControlsSignature = "";
|
|
221
|
+
updateShaderParamControls(payload?.scene?.layers);
|
|
222
|
+
}
|
|
223
|
+
if (Object.prototype.hasOwnProperty.call(payload || {}, "tap_tempo_key")) {
|
|
224
|
+
updateTapTempoKey(payload?.tap_tempo_key);
|
|
225
|
+
}
|
|
226
|
+
if (Object.prototype.hasOwnProperty.call(payload || {}, "key_mappings")) {
|
|
227
|
+
updateKeyboardMappings(payload?.key_mappings);
|
|
228
|
+
}
|
|
229
|
+
if (Object.prototype.hasOwnProperty.call(payload || {}, "globals")) {
|
|
230
|
+
runtimeGlobalsReceived = true;
|
|
231
|
+
applyRuntimeGlobals(payload?.globals);
|
|
85
232
|
}
|
|
233
|
+
if (Object.prototype.hasOwnProperty.call(payload || {}, "live_controls")) {
|
|
234
|
+
applyLiveControls({
|
|
235
|
+
...liveControls,
|
|
236
|
+
...normalizeLiveControls(payload?.live_controls),
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
},
|
|
240
|
+
onLatencyProbe: (payload) => {
|
|
241
|
+
updatePerformanceMonitor(recordLatencyProbe(performanceMonitor, payload, Date.now()));
|
|
86
242
|
},
|
|
87
243
|
onStatus: (status) => {
|
|
244
|
+
updatePerformanceMonitor(recordConnectionStatus(performanceMonitor, status));
|
|
88
245
|
if (status === "connected") {
|
|
89
246
|
lastConnectedAt = new Date();
|
|
247
|
+
startLatencyProbeLoop();
|
|
90
248
|
syncAudioTransportToServer({ force: true });
|
|
91
249
|
} else {
|
|
250
|
+
stopLatencyProbeLoop();
|
|
92
251
|
pendingSceneName = null;
|
|
93
252
|
pendingSceneRequestedAt = 0;
|
|
94
253
|
currentSceneName = "unknown";
|
|
@@ -96,7 +255,7 @@ const client = new WebSocketClient(websocketUrl, {
|
|
|
96
255
|
renderSceneButtons();
|
|
97
256
|
}
|
|
98
257
|
const connectedAt = lastConnectedAt ? ` | Last connected: ${formatClock(lastConnectedAt)}` : "";
|
|
99
|
-
wsStatusElement.textContent = `WebSocket: ${status}${connectedAt}`;
|
|
258
|
+
wsStatusElement.textContent = `WebSocket: ${status} (${websocketUrl})${connectedAt}`;
|
|
100
259
|
}
|
|
101
260
|
});
|
|
102
261
|
|
|
@@ -121,9 +280,24 @@ async function fetchRuntime() {
|
|
|
121
280
|
}
|
|
122
281
|
|
|
123
282
|
function applyRuntime(runtime) {
|
|
283
|
+
projectorMode = resolveProjectorMode({
|
|
284
|
+
body: document.body,
|
|
285
|
+
current: projectorMode,
|
|
286
|
+
location: window.location,
|
|
287
|
+
runtime,
|
|
288
|
+
});
|
|
289
|
+
applyProjectorMode(document.body, projectorMode);
|
|
290
|
+
|
|
124
291
|
const source = String(runtime?.audio_source || "unknown");
|
|
125
292
|
audioSourceStatusElement.textContent = `Audio Source: ${source}`;
|
|
126
293
|
updateAvailableScenes(runtime?.scene_names);
|
|
294
|
+
updateTapTempoKey(runtime?.tap_tempo_key);
|
|
295
|
+
updateKeyboardMappings(runtime?.key_mappings);
|
|
296
|
+
updateControlPresetPersistence(runtime);
|
|
297
|
+
if (!runtimeGlobalsReceived) {
|
|
298
|
+
applyRuntimeGlobals(runtime?.globals);
|
|
299
|
+
}
|
|
300
|
+
applyRuntimeControlPreset(runtime?.control_preset);
|
|
127
301
|
|
|
128
302
|
const fileName = runtime?.audio_file_name;
|
|
129
303
|
const fileUrl = runtime?.audio_file_url;
|
|
@@ -139,6 +313,43 @@ function applyRuntime(runtime) {
|
|
|
139
313
|
setupAudioPlayback(fileUrl);
|
|
140
314
|
}
|
|
141
315
|
|
|
316
|
+
function applyRuntimeGlobals(globals) {
|
|
317
|
+
engine.setRuntimeGlobals(globals);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function updateControlPresetPersistence(runtime) {
|
|
321
|
+
const url = String(runtime?.control_preset_url || "").trim();
|
|
322
|
+
controlPresetSaveUrl = runtime?.control_preset_writable && url ? url : null;
|
|
323
|
+
if (reactivityProjectSaveButton) {
|
|
324
|
+
reactivityProjectSaveButton.hidden = !controlPresetSaveUrl;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function applyRuntimeControlPreset(value) {
|
|
329
|
+
if (runtimeControlPresetApplied) {
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const preset = normalizeRuntimeControlPreset(value);
|
|
334
|
+
let applied = false;
|
|
335
|
+
if (preset.visualSettings) {
|
|
336
|
+
const imported = importVisualSettingsPreset({ visual_settings: preset.visualSettings }, { fallback: visualSettings });
|
|
337
|
+
Object.assign(visualSettings, saveVisualSettingsPreset(browserStorage(), imported));
|
|
338
|
+
syncVisualControls();
|
|
339
|
+
engine.setVisualSettings(visualSettings);
|
|
340
|
+
renderReactivityStatus("Project preset");
|
|
341
|
+
applied = true;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
if (preset.midiLearnBindings) {
|
|
345
|
+
midiLearnBindings = saveMidiLearnBindings(browserStorage(), preset.midiLearnBindings);
|
|
346
|
+
renderMidiLearnStatus();
|
|
347
|
+
applied = true;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
runtimeControlPresetApplied = applied;
|
|
351
|
+
}
|
|
352
|
+
|
|
142
353
|
function updateAvailableScenes(sceneValues) {
|
|
143
354
|
const names = normalizeSceneNames(sceneValues);
|
|
144
355
|
if (!names.length) {
|
|
@@ -148,6 +359,16 @@ function updateAvailableScenes(sceneValues) {
|
|
|
148
359
|
renderSceneButtons();
|
|
149
360
|
}
|
|
150
361
|
|
|
362
|
+
function updateTapTempoKey(key) {
|
|
363
|
+
const value = String(key || "").trim().toLowerCase();
|
|
364
|
+
tapTempoKey = value || null;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function updateKeyboardMappings(mappings) {
|
|
368
|
+
keyboardMappings = normalizeKeyboardMappings(mappings);
|
|
369
|
+
renderSceneButtons();
|
|
370
|
+
}
|
|
371
|
+
|
|
151
372
|
function normalizeSceneNames(sceneValues) {
|
|
152
373
|
const seen = new Set();
|
|
153
374
|
const names = [];
|
|
@@ -180,25 +401,247 @@ function renderSceneButtons() {
|
|
|
180
401
|
sceneSwitcherElement.hidden = false;
|
|
181
402
|
const buttons = availableSceneNames.map((sceneName) => {
|
|
182
403
|
const button = document.createElement("button");
|
|
404
|
+
const shortcut = sceneShortcutFor(sceneName);
|
|
183
405
|
button.type = "button";
|
|
184
|
-
button.textContent = sceneName;
|
|
406
|
+
button.textContent = shortcut ? `${sceneName} [${shortcut}]` : sceneName;
|
|
407
|
+
button.title = shortcut ? `Shortcut: ${shortcut}` : "";
|
|
185
408
|
button.classList.toggle("is-active", sceneName === currentSceneName);
|
|
186
409
|
button.onclick = () => {
|
|
187
|
-
|
|
188
|
-
return;
|
|
189
|
-
}
|
|
190
|
-
pendingSceneName = sceneName;
|
|
191
|
-
pendingSceneRequestedAt = performance.now();
|
|
192
|
-
currentSceneName = sceneName;
|
|
193
|
-
sceneStatusElement.textContent = `Scene: ${sceneName}`;
|
|
194
|
-
renderSceneButtons();
|
|
195
|
-
client.send("switch_scene", { scene: sceneName });
|
|
410
|
+
requestSceneSwitch(sceneName);
|
|
196
411
|
};
|
|
197
412
|
return button;
|
|
198
413
|
});
|
|
199
414
|
sceneSwitcherElement.replaceChildren(...buttons);
|
|
200
415
|
}
|
|
201
416
|
|
|
417
|
+
function sceneShortcutFor(sceneName) {
|
|
418
|
+
const mapping = keyboardMappings.find((entry) => (
|
|
419
|
+
entry.action?.type === "switch_scene" && entry.action.scene === sceneName
|
|
420
|
+
));
|
|
421
|
+
return mapping?.key || "";
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function updateShaderParamControls(layers) {
|
|
425
|
+
if (!shaderParamControlsElement) {
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
const entries = shaderParamControlEntries(layers, shaderParamOverrides);
|
|
430
|
+
const signature = shaderParamControlsSignatureFor(entries);
|
|
431
|
+
if (signature === shaderParamControlsSignature) {
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
shaderParamControlsSignature = signature;
|
|
436
|
+
shaderParamOverrides = pruneShaderParamOverrides(shaderParamOverrides, entries);
|
|
437
|
+
engine.setShaderParamOverrides(shaderParamOverrides);
|
|
438
|
+
renderShaderParamControls(entries);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function shaderParamControlsSignatureFor(entries) {
|
|
442
|
+
return entries.map((entry) => (
|
|
443
|
+
`${entry.key}:${entry.min}:${entry.max}:${entry.step}`
|
|
444
|
+
)).join("|");
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function renderShaderParamControls(entries) {
|
|
448
|
+
if (!shaderParamControlsElement) {
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
if (!entries.length) {
|
|
453
|
+
shaderParamControlsElement.hidden = true;
|
|
454
|
+
shaderParamControlsElement.replaceChildren();
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const title = document.createElement("p");
|
|
459
|
+
title.className = "shader-param-controls__title";
|
|
460
|
+
title.textContent = "Shader Params";
|
|
461
|
+
const controls = entries.map((entry) => createShaderParamControl(entry));
|
|
462
|
+
shaderParamControlsElement.replaceChildren(title, ...controls);
|
|
463
|
+
shaderParamControlsElement.hidden = false;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function createShaderParamControl(entry) {
|
|
467
|
+
const label = document.createElement("label");
|
|
468
|
+
const name = document.createElement("span");
|
|
469
|
+
const input = document.createElement("input");
|
|
470
|
+
const value = document.createElement("output");
|
|
471
|
+
name.textContent = entry.label;
|
|
472
|
+
input.type = "range";
|
|
473
|
+
input.min = String(entry.min);
|
|
474
|
+
input.max = String(entry.max);
|
|
475
|
+
input.step = String(entry.step);
|
|
476
|
+
input.value = String(entry.value);
|
|
477
|
+
value.value = formatShaderParamValue(entry.value);
|
|
478
|
+
input.addEventListener("input", () => {
|
|
479
|
+
const numeric = Number(input.value);
|
|
480
|
+
shaderParamOverrides[entry.layerKey] ||= {};
|
|
481
|
+
shaderParamOverrides[entry.layerKey][entry.paramName] = numeric;
|
|
482
|
+
engine.setShaderParamOverrides(shaderParamOverrides);
|
|
483
|
+
value.value = formatShaderParamValue(numeric);
|
|
484
|
+
});
|
|
485
|
+
label.append(name, input, value);
|
|
486
|
+
return label;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function formatShaderParamValue(value) {
|
|
490
|
+
const numeric = Number(value);
|
|
491
|
+
if (!Number.isFinite(numeric)) {
|
|
492
|
+
return "--";
|
|
493
|
+
}
|
|
494
|
+
return Math.abs(numeric) >= 10 ? numeric.toFixed(1) : numeric.toFixed(2);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function requestSceneSwitch(sceneName) {
|
|
498
|
+
if (!sceneName || sceneName === currentSceneName) {
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
pendingSceneName = sceneName;
|
|
503
|
+
pendingSceneRequestedAt = performance.now();
|
|
504
|
+
currentSceneName = sceneName;
|
|
505
|
+
sceneStatusElement.textContent = `Scene: ${sceneName}`;
|
|
506
|
+
renderSceneButtons();
|
|
507
|
+
client.send("switch_scene", { scene: sceneName });
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
function applyKeyboardAction(action) {
|
|
511
|
+
if (action?.type === "switch_scene") {
|
|
512
|
+
requestSceneSwitch(action.scene);
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
if (action?.type === "live_control") {
|
|
517
|
+
applyLiveControls(toggleLiveControl(liveControls, action.control));
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function startPerformanceMonitorLoop() {
|
|
522
|
+
requestAnimationFrame((time) => {
|
|
523
|
+
updatePerformanceMonitor(recordRenderFrame(performanceMonitor, time));
|
|
524
|
+
startPerformanceMonitorLoop();
|
|
525
|
+
});
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function startLatencyProbeLoop() {
|
|
529
|
+
stopLatencyProbeLoop();
|
|
530
|
+
sendLatencyProbe();
|
|
531
|
+
latencyProbeTimer = setInterval(sendLatencyProbe, LATENCY_PROBE_INTERVAL_MS);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
function stopLatencyProbeLoop() {
|
|
535
|
+
if (!latencyProbeTimer) {
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
clearInterval(latencyProbeTimer);
|
|
540
|
+
latencyProbeTimer = null;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
function sendLatencyProbe() {
|
|
544
|
+
client.send("latency_probe", {
|
|
545
|
+
client_sent_at_ms: Date.now()
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
function updatePerformanceMonitor(nextState) {
|
|
550
|
+
Object.assign(performanceMonitor, nextState);
|
|
551
|
+
renderPerformanceMonitor();
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
function renderPerformanceMonitor() {
|
|
555
|
+
if (!performanceMonitorElement) {
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
performanceMonitorElement.textContent = formatPerformanceMonitorText(performanceMonitor);
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
function bindShaderCompileMetrics() {
|
|
563
|
+
window.addEventListener(SHADER_COMPILE_EVENT, (event) => {
|
|
564
|
+
updatePerformanceMonitor(recordShaderCompile(performanceMonitor, event.detail));
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
function bindLiveControls() {
|
|
569
|
+
bindLiveControlButton(blackoutButton, "blackout");
|
|
570
|
+
bindLiveControlButton(freezeButton, "freeze");
|
|
571
|
+
window.addEventListener("keydown", (event) => {
|
|
572
|
+
const keyboardAction = keyboardActionForKey(event, keyboardMappings);
|
|
573
|
+
if (keyboardAction) {
|
|
574
|
+
event.preventDefault();
|
|
575
|
+
applyKeyboardAction(keyboardAction);
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
const action = shortcutActionForKey(event);
|
|
580
|
+
if (action) {
|
|
581
|
+
event.preventDefault();
|
|
582
|
+
applyLiveControls(toggleLiveControl(liveControls, action));
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
const sceneIndex = shortcutSceneIndexForKey(event, availableSceneNames.length);
|
|
587
|
+
if (sceneIndex === null) {
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
event.preventDefault();
|
|
592
|
+
requestSceneSwitch(availableSceneNames[sceneIndex]);
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
window.addEventListener("keydown", (event) => {
|
|
596
|
+
if (!isTapTempoShortcut(event, tapTempoKey)) {
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
event.preventDefault();
|
|
601
|
+
client.send("tap_tempo", { client_tapped_at_ms: Date.now() });
|
|
602
|
+
});
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
function bindLiveControlButton(button, control) {
|
|
606
|
+
if (!button) {
|
|
607
|
+
return;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
button.addEventListener("click", () => {
|
|
611
|
+
applyLiveControls(toggleLiveControl(liveControls, control));
|
|
612
|
+
});
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
function applyLiveControls(nextState) {
|
|
616
|
+
Object.assign(liveControls, nextState);
|
|
617
|
+
engine.setLiveControls(liveControls);
|
|
618
|
+
renderLiveControlStatus();
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
function normalizeLiveControls(value) {
|
|
622
|
+
const input = value && typeof value === "object" ? value : {};
|
|
623
|
+
return {
|
|
624
|
+
blackout: !!input.blackout,
|
|
625
|
+
freeze: !!input.freeze,
|
|
626
|
+
};
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
function renderLiveControlStatus() {
|
|
630
|
+
if (liveControlStatusElement) {
|
|
631
|
+
liveControlStatusElement.textContent = liveControlStatusText(liveControls);
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
if (blackoutButton) {
|
|
635
|
+
blackoutButton.classList.toggle("is-active", liveControls.blackout);
|
|
636
|
+
blackoutButton.setAttribute("aria-pressed", String(liveControls.blackout));
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
if (freezeButton) {
|
|
640
|
+
freezeButton.classList.toggle("is-active", liveControls.freeze);
|
|
641
|
+
freezeButton.setAttribute("aria-pressed", String(liveControls.freeze));
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
|
|
202
645
|
function setupAudioPlayback(audioUrl) {
|
|
203
646
|
if (audioElement) {
|
|
204
647
|
audioElement.pause();
|
|
@@ -298,6 +741,339 @@ function syncAudioTransportToServer({ force = false } = {}) {
|
|
|
298
741
|
}
|
|
299
742
|
}
|
|
300
743
|
|
|
744
|
+
function bindVisualControl(control, key, parser = Number) {
|
|
745
|
+
if (!control) return;
|
|
746
|
+
control.addEventListener("input", () => {
|
|
747
|
+
visualSettings[key] = parser(control.value);
|
|
748
|
+
engine.setVisualSettings(visualSettings);
|
|
749
|
+
renderReactivityStatus();
|
|
750
|
+
});
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
function bindVisualPresetControls() {
|
|
754
|
+
if (reactivitySaveButton) {
|
|
755
|
+
reactivitySaveButton.addEventListener("click", () => {
|
|
756
|
+
Object.assign(visualSettings, saveVisualSettingsPreset(browserStorage(), visualSettings));
|
|
757
|
+
renderReactivityStatus("Saved");
|
|
758
|
+
});
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
if (reactivityLoadButton) {
|
|
762
|
+
reactivityLoadButton.addEventListener("click", () => {
|
|
763
|
+
Object.assign(visualSettings, loadVisualSettingsPreset(browserStorage(), { fallback: visualSettings }));
|
|
764
|
+
syncVisualControls();
|
|
765
|
+
engine.setVisualSettings(visualSettings);
|
|
766
|
+
renderReactivityStatus("Loaded");
|
|
767
|
+
});
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
if (reactivityProjectSaveButton) {
|
|
771
|
+
reactivityProjectSaveButton.addEventListener("click", async () => {
|
|
772
|
+
if (!controlPresetSaveUrl) {
|
|
773
|
+
renderReactivityStatus("Project save unavailable");
|
|
774
|
+
return;
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
const saved = await saveProjectControlPreset();
|
|
778
|
+
renderReactivityStatus(saved ? "Project saved" : "Project save failed");
|
|
779
|
+
});
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
if (reactivityExportButton) {
|
|
783
|
+
reactivityExportButton.addEventListener("click", async () => {
|
|
784
|
+
const payload = exportVisualSettingsPreset(visualSettings);
|
|
785
|
+
const copied = await writeClipboardText(payload);
|
|
786
|
+
if (!copied && typeof window.prompt === "function") {
|
|
787
|
+
window.prompt("Visual preset JSON", payload);
|
|
788
|
+
}
|
|
789
|
+
renderReactivityStatus(copied ? "Exported" : "Export ready");
|
|
790
|
+
});
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
if (reactivityImportButton) {
|
|
794
|
+
reactivityImportButton.addEventListener("click", () => {
|
|
795
|
+
if (typeof window.prompt !== "function") {
|
|
796
|
+
renderReactivityStatus("Import unavailable");
|
|
797
|
+
return;
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
const payload = window.prompt("Paste visual preset JSON");
|
|
801
|
+
if (!payload) {
|
|
802
|
+
renderReactivityStatus();
|
|
803
|
+
return;
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
Object.assign(visualSettings, importVisualSettingsPreset(payload, { fallback: visualSettings }));
|
|
807
|
+
Object.assign(visualSettings, saveVisualSettingsPreset(browserStorage(), visualSettings));
|
|
808
|
+
syncVisualControls();
|
|
809
|
+
engine.setVisualSettings(visualSettings);
|
|
810
|
+
renderReactivityStatus("Imported");
|
|
811
|
+
});
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
async function saveProjectControlPreset() {
|
|
816
|
+
try {
|
|
817
|
+
const response = await fetch(controlPresetSaveUrl, {
|
|
818
|
+
method: "PUT",
|
|
819
|
+
headers: { "content-type": "application/json" },
|
|
820
|
+
body: JSON.stringify({
|
|
821
|
+
visual_settings: visualSettings,
|
|
822
|
+
midi_learn_bindings: midiLearnBindings,
|
|
823
|
+
}),
|
|
824
|
+
});
|
|
825
|
+
return response.ok;
|
|
826
|
+
} catch {
|
|
827
|
+
return false;
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
function bindMidiLearnControls() {
|
|
832
|
+
midiLearnButtons.forEach((button) => {
|
|
833
|
+
button.addEventListener("click", async () => {
|
|
834
|
+
const action = midiLearnActionForButton(button);
|
|
835
|
+
if (!action) {
|
|
836
|
+
renderMidiLearnStatus("No action selected");
|
|
837
|
+
return;
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
const ready = await ensureMidiAccess();
|
|
841
|
+
if (!ready) {
|
|
842
|
+
renderMidiLearnStatus("Web MIDI unavailable");
|
|
843
|
+
return;
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
pendingMidiLearnAction = action;
|
|
847
|
+
renderMidiLearnStatus(`Move a MIDI control for ${midiLearnActionLabel(action)}`);
|
|
848
|
+
});
|
|
849
|
+
});
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
function midiLearnActionForButton(button) {
|
|
853
|
+
const action = String(button?.dataset?.midiLearnAction || "");
|
|
854
|
+
if (action === "current-scene") {
|
|
855
|
+
return currentSceneName && currentSceneName !== "unknown"
|
|
856
|
+
? { type: "switch_scene", scene: currentSceneName }
|
|
857
|
+
: null;
|
|
858
|
+
}
|
|
859
|
+
if (action === "blackout" || action === "freeze") {
|
|
860
|
+
return { type: "live_control", control: action };
|
|
861
|
+
}
|
|
862
|
+
if (action.startsWith("visual:")) {
|
|
863
|
+
return { type: "visual_setting", key: action.slice(7) };
|
|
864
|
+
}
|
|
865
|
+
return null;
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
async function ensureMidiAccess() {
|
|
869
|
+
if (midiAccess) {
|
|
870
|
+
return true;
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
const requestMIDIAccess = typeof navigator === "undefined" ? null : navigator.requestMIDIAccess;
|
|
874
|
+
if (typeof requestMIDIAccess !== "function") {
|
|
875
|
+
return false;
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
try {
|
|
879
|
+
midiAccess = await requestMIDIAccess.call(navigator);
|
|
880
|
+
bindMidiInputs(midiAccess.inputs);
|
|
881
|
+
midiAccess.onstatechange = () => {
|
|
882
|
+
bindMidiInputs(midiAccess.inputs);
|
|
883
|
+
renderMidiLearnStatus();
|
|
884
|
+
};
|
|
885
|
+
return true;
|
|
886
|
+
} catch {
|
|
887
|
+
midiAccess = null;
|
|
888
|
+
return false;
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
function bindMidiInputs(inputs) {
|
|
893
|
+
for (const input of inputs.values()) {
|
|
894
|
+
input.onmidimessage = handleMidiMessage;
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
function handleMidiMessage(event) {
|
|
899
|
+
const signature = midiMessageSignature(event?.data);
|
|
900
|
+
if (!signature) {
|
|
901
|
+
return;
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
if (pendingMidiLearnAction && midiMessageActive(event?.data)) {
|
|
905
|
+
midiLearnBindings = upsertMidiLearnBinding(midiLearnBindings, signature, pendingMidiLearnAction);
|
|
906
|
+
midiLearnBindings = saveMidiLearnBindings(browserStorage(), midiLearnBindings);
|
|
907
|
+
renderMidiLearnStatus(`Learned ${midiSignatureLabel(signature)} -> ${midiLearnActionLabel(pendingMidiLearnAction)}`);
|
|
908
|
+
pendingMidiLearnAction = null;
|
|
909
|
+
return;
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
const action = midiLearnBindings[signature];
|
|
913
|
+
if (!action) {
|
|
914
|
+
return;
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
applyMidiLearnAction(action, midiMessageUnitValue(event?.data), midiMessageActive(event?.data));
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
function applyMidiLearnAction(action, unitValue, active) {
|
|
921
|
+
if (action.type === "visual_setting") {
|
|
922
|
+
visualSettings[action.key] = visualSettingFromUnit(action.key, unitValue, visualSettings[action.key]);
|
|
923
|
+
syncVisualControls();
|
|
924
|
+
engine.setVisualSettings(visualSettings);
|
|
925
|
+
renderReactivityStatus("MIDI");
|
|
926
|
+
return;
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
if (!active || unitValue <= 0) {
|
|
930
|
+
return;
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
if (action.type === "switch_scene") {
|
|
934
|
+
requestSceneSwitch(action.scene);
|
|
935
|
+
renderMidiLearnStatus(`MIDI: ${midiLearnActionLabel(action)}`);
|
|
936
|
+
return;
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
if (action.type === "live_control") {
|
|
940
|
+
applyLiveControls(toggleLiveControl(liveControls, action.control));
|
|
941
|
+
renderMidiLearnStatus(`MIDI: ${midiLearnActionLabel(action)}`);
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
function syncVisualControls() {
|
|
946
|
+
setControlValue(visualGainControl, visualSettings.visualGain);
|
|
947
|
+
setControlValue(bassBoostControl, visualSettings.bassBoost);
|
|
948
|
+
setControlValue(smoothingControl, visualSettings.smoothing);
|
|
949
|
+
setControlValue(beatHoldControl, visualSettings.beatHoldMs);
|
|
950
|
+
setControlValue(wobbleControl, visualSettings.wobbleAmount);
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
function setControlValue(control, value) {
|
|
954
|
+
if (control) {
|
|
955
|
+
control.value = String(value);
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
function browserStorage() {
|
|
960
|
+
try {
|
|
961
|
+
return window.localStorage;
|
|
962
|
+
} catch {
|
|
963
|
+
return null;
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
async function writeClipboardText(value) {
|
|
968
|
+
try {
|
|
969
|
+
const clipboard = typeof navigator === "undefined" ? null : navigator.clipboard;
|
|
970
|
+
if (!clipboard?.writeText) {
|
|
971
|
+
return false;
|
|
972
|
+
}
|
|
973
|
+
await clipboard.writeText(value);
|
|
974
|
+
return true;
|
|
975
|
+
} catch {
|
|
976
|
+
return false;
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
function renderReactivityStatus(prefix = null) {
|
|
981
|
+
if (!reactivityStatusElement) {
|
|
982
|
+
return;
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
const values = [
|
|
986
|
+
`Visual Gain: ${visualSettings.visualGain.toFixed(1)}x`,
|
|
987
|
+
`Bass: ${visualSettings.bassBoost.toFixed(1)}x`,
|
|
988
|
+
`Smooth: ${visualSettings.smoothing.toFixed(2)}`,
|
|
989
|
+
`Beat Hold: ${Math.round(visualSettings.beatHoldMs)}ms`,
|
|
990
|
+
`Wobble: ${visualSettings.wobbleAmount.toFixed(2)}x`,
|
|
991
|
+
].join(" | ");
|
|
992
|
+
|
|
993
|
+
reactivityStatusElement.textContent = prefix ? `${prefix} | ${values}` : values;
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
function renderMidiLearnStatus(prefix = null) {
|
|
997
|
+
if (!midiLearnStatusElement) {
|
|
998
|
+
return;
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
const bindingCount = Object.keys(midiLearnBindings).length;
|
|
1002
|
+
const accessState = midiAccess ? "ready" : "idle";
|
|
1003
|
+
midiLearnStatusElement.textContent = prefix || `MIDI Learn: ${accessState} | Bindings: ${bindingCount}`;
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
function bindShaderErrorOverlay() {
|
|
1007
|
+
window.addEventListener(SHADER_ERROR_EVENT, (event) => {
|
|
1008
|
+
renderShaderError(event.detail);
|
|
1009
|
+
});
|
|
1010
|
+
if (shaderErrorCloseButton) {
|
|
1011
|
+
shaderErrorCloseButton.addEventListener("click", () => {
|
|
1012
|
+
if (shaderErrorOverlay) {
|
|
1013
|
+
shaderErrorOverlay.hidden = true;
|
|
1014
|
+
}
|
|
1015
|
+
});
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
function renderShaderError(detail) {
|
|
1020
|
+
if (!shaderErrorOverlay || !shaderErrorTitleElement || !shaderErrorMessageElement) {
|
|
1021
|
+
return;
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
shaderErrorTitleElement.textContent = formatShaderErrorTitle(detail);
|
|
1025
|
+
shaderErrorMessageElement.textContent = formatShaderErrorMessage(detail);
|
|
1026
|
+
shaderErrorOverlay.hidden = false;
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
function initializeFftPreview(container) {
|
|
1030
|
+
if (!container) {
|
|
1031
|
+
return [];
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
const bars = Array.from({ length: DEFAULT_FFT_BINS }, () => {
|
|
1035
|
+
const bar = document.createElement("span");
|
|
1036
|
+
bar.className = "fft-bar";
|
|
1037
|
+
bar.setAttribute("aria-hidden", "true");
|
|
1038
|
+
return bar;
|
|
1039
|
+
});
|
|
1040
|
+
container.replaceChildren(...bars);
|
|
1041
|
+
return bars;
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
function renderAudioInspector(audio) {
|
|
1045
|
+
const state = buildAudioInspectorState(audio);
|
|
1046
|
+
setMeter(inspectorAmplitudeFill, inspectorAmplitudeValue, state.amplitude, 3);
|
|
1047
|
+
|
|
1048
|
+
for (const key of BAND_KEYS) {
|
|
1049
|
+
const elements = inspectorBandElements[key] || {};
|
|
1050
|
+
setMeter(elements.fill, elements.value, state.bands[key], 2);
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
state.fft.forEach((value, index) => {
|
|
1054
|
+
const bar = fftBars[index];
|
|
1055
|
+
if (!bar) {
|
|
1056
|
+
return;
|
|
1057
|
+
}
|
|
1058
|
+
bar.style.setProperty("--bin-value", value.toFixed(4));
|
|
1059
|
+
});
|
|
1060
|
+
|
|
1061
|
+
if (inspectorPeakElement) {
|
|
1062
|
+
inspectorPeakElement.textContent = state.peakFrequency > 0
|
|
1063
|
+
? `Peak: ${Math.round(state.peakFrequency)} Hz`
|
|
1064
|
+
: "Peak: --";
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
function setMeter(fill, valueElement, value, digits) {
|
|
1069
|
+
if (fill) {
|
|
1070
|
+
fill.style.setProperty("--meter-value", value.toFixed(4));
|
|
1071
|
+
}
|
|
1072
|
+
if (valueElement) {
|
|
1073
|
+
valueElement.textContent = formatMeterValue(value, digits);
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
|
|
301
1077
|
function buildWebSocketUrl() {
|
|
302
1078
|
const protocol = window.location.protocol === "https:" ? "wss" : "ws";
|
|
303
1079
|
return `${protocol}://${window.location.host}/ws`;
|