vizcore 0.1.0 → 1.1.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.
Files changed (137) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +70 -117
  3. data/docs/.nojekyll +0 -0
  4. data/docs/assets/playground-worker.js +373 -0
  5. data/docs/assets/playground.css +440 -0
  6. data/docs/assets/playground.js +652 -0
  7. data/docs/assets/site.css +744 -0
  8. data/docs/assets/vizcore-demo.gif +0 -0
  9. data/docs/assets/vizcore-poster.png +0 -0
  10. data/docs/assets/vj-tunnel.js +159 -0
  11. data/docs/index.html +225 -0
  12. data/docs/playground.html +81 -0
  13. data/docs/shape_dsl.md +269 -0
  14. data/examples/README.md +59 -0
  15. data/examples/assets/README.md +19 -0
  16. data/examples/audio_inspector.rb +34 -0
  17. data/examples/club_intro_drop.rb +78 -0
  18. data/examples/kansai_rubykaigi_visual.rb +70 -0
  19. data/examples/live_coding_minimal.rb +22 -0
  20. data/examples/midi_controller_show.rb +78 -0
  21. data/examples/midi_scene_switch.rb +3 -1
  22. data/examples/parser_visualizer.rb +48 -0
  23. data/examples/readme_demo.rb +17 -0
  24. data/examples/rhythm_geometry.rb +34 -0
  25. data/examples/ruby_crystal_show.rb +35 -0
  26. data/examples/shader_playground.rb +18 -0
  27. data/examples/unyo_liquid.rb +59 -0
  28. data/examples/vj_ambient_chill_room.rb +124 -0
  29. data/examples/vj_dnb_jungle.rb +170 -0
  30. data/examples/vj_festival_mainstage.rb +245 -0
  31. data/examples/vj_festival_mainstage.yml +17 -0
  32. data/examples/vj_glitch_industrial.rb +164 -0
  33. data/examples/vj_hiphop_cipher.rb +167 -0
  34. data/examples/vj_jpop_idol_live.rb +210 -0
  35. data/examples/vj_synthwave_retro.rb +173 -0
  36. data/examples/vj_techno_warehouse.rb +195 -0
  37. data/frontend/index.html +494 -2
  38. data/frontend/src/audio-inspector.js +40 -0
  39. data/frontend/src/custom-shape-param-controls.js +106 -0
  40. data/frontend/src/live-controls.js +131 -0
  41. data/frontend/src/main.js +1060 -16
  42. data/frontend/src/mapping-target-selector.js +109 -0
  43. data/frontend/src/midi-learn.js +194 -0
  44. data/frontend/src/performance-monitor.js +183 -0
  45. data/frontend/src/plugin-runtime.js +130 -0
  46. data/frontend/src/projector-mode.js +56 -0
  47. data/frontend/src/renderer/engine.js +157 -3
  48. data/frontend/src/renderer/layer-manager.js +442 -30
  49. data/frontend/src/renderer/shader-manager.js +26 -0
  50. data/frontend/src/runtime-control-preset.js +11 -0
  51. data/frontend/src/shader-error-overlay.js +29 -0
  52. data/frontend/src/shader-param-controls.js +93 -0
  53. data/frontend/src/shaders/builtins.js +380 -2
  54. data/frontend/src/shaders/post-effects.js +52 -0
  55. data/frontend/src/shape-editor-controls.js +157 -0
  56. data/frontend/src/visual-regression.js +67 -0
  57. data/frontend/src/visual-settings-preset.js +103 -0
  58. data/frontend/src/visuals/geometry.js +666 -0
  59. data/frontend/src/visuals/image-renderer.js +291 -0
  60. data/frontend/src/visuals/particle-system.js +56 -10
  61. data/frontend/src/visuals/shape-renderer.js +475 -0
  62. data/frontend/src/visuals/spectrogram-renderer.js +226 -0
  63. data/frontend/src/visuals/svg-arc.js +104 -0
  64. data/frontend/src/visuals/text-renderer.js +112 -11
  65. data/frontend/src/websocket-client.js +12 -1
  66. data/lib/vizcore/analysis/adaptive_normalizer.rb +70 -0
  67. data/lib/vizcore/analysis/beat_detector.rb +4 -2
  68. data/lib/vizcore/analysis/bpm_estimator.rb +8 -0
  69. data/lib/vizcore/analysis/feature_recorder.rb +159 -0
  70. data/lib/vizcore/analysis/feature_replay.rb +84 -0
  71. data/lib/vizcore/analysis/pipeline.rb +235 -11
  72. data/lib/vizcore/analysis/tap_tempo.rb +74 -0
  73. data/lib/vizcore/analysis.rb +4 -0
  74. data/lib/vizcore/audio/dummy_sine_input.rb +1 -1
  75. data/lib/vizcore/audio/fixture_input.rb +65 -0
  76. data/lib/vizcore/audio/input_manager.rb +4 -2
  77. data/lib/vizcore/audio/mic_input.rb +24 -8
  78. data/lib/vizcore/audio/portaudio_ffi.rb +106 -1
  79. data/lib/vizcore/audio.rb +1 -0
  80. data/lib/vizcore/cli/doctor.rb +159 -0
  81. data/lib/vizcore/cli/dsl_reference.rb +99 -0
  82. data/lib/vizcore/cli/layer_docs.rb +46 -0
  83. data/lib/vizcore/cli/scene_diagnostics.rb +23 -0
  84. data/lib/vizcore/cli/scene_inspector.rb +136 -0
  85. data/lib/vizcore/cli/scene_validator.rb +337 -0
  86. data/lib/vizcore/cli/shader_template.rb +68 -0
  87. data/lib/vizcore/cli/shader_uniform_docs.rb +54 -0
  88. data/lib/vizcore/cli.rb +689 -18
  89. data/lib/vizcore/config.rb +103 -2
  90. data/lib/vizcore/control_preset.rb +68 -0
  91. data/lib/vizcore/dsl/engine.rb +277 -5
  92. data/lib/vizcore/dsl/layer_builder.rb +1280 -23
  93. data/lib/vizcore/dsl/layer_group_builder.rb +112 -0
  94. data/lib/vizcore/dsl/mapping_resolver.rb +290 -7
  95. data/lib/vizcore/dsl/mapping_transform_builder.rb +71 -0
  96. data/lib/vizcore/dsl/reaction_builder.rb +44 -0
  97. data/lib/vizcore/dsl/scene_builder.rb +61 -5
  98. data/lib/vizcore/dsl/shader_source_resolver.rb +67 -6
  99. data/lib/vizcore/dsl/style_builder.rb +68 -0
  100. data/lib/vizcore/dsl/timeline_builder.rb +138 -0
  101. data/lib/vizcore/dsl/transition_controller.rb +77 -0
  102. data/lib/vizcore/dsl.rb +5 -1
  103. data/lib/vizcore/layer_catalog.rb +275 -0
  104. data/lib/vizcore/project_manifest.rb +152 -0
  105. data/lib/vizcore/renderer/png_writer.rb +57 -0
  106. data/lib/vizcore/renderer/render_sequence.rb +153 -0
  107. data/lib/vizcore/renderer/scene_frame_source.rb +132 -0
  108. data/lib/vizcore/renderer/scene_serializer.rb +36 -3
  109. data/lib/vizcore/renderer/snapshot.rb +38 -0
  110. data/lib/vizcore/renderer/snapshot_renderer.rb +938 -0
  111. data/lib/vizcore/renderer.rb +5 -0
  112. data/lib/vizcore/server/frame_broadcaster.rb +143 -8
  113. data/lib/vizcore/server/gallery_app.rb +155 -0
  114. data/lib/vizcore/server/gallery_page.rb +100 -0
  115. data/lib/vizcore/server/gallery_runner.rb +48 -0
  116. data/lib/vizcore/server/rack_app.rb +203 -4
  117. data/lib/vizcore/server/runner.rb +391 -22
  118. data/lib/vizcore/server/scene_dependency_watcher.rb +79 -0
  119. data/lib/vizcore/server/websocket_handler.rb +60 -10
  120. data/lib/vizcore/server.rb +4 -0
  121. data/lib/vizcore/shape.rb +719 -0
  122. data/lib/vizcore/sync/osc_message.rb +103 -0
  123. data/lib/vizcore/sync/osc_receiver.rb +68 -0
  124. data/lib/vizcore/sync.rb +4 -0
  125. data/lib/vizcore/templates/midi_control_scene.rb +3 -1
  126. data/lib/vizcore/templates/plugin_layer.rb +20 -0
  127. data/lib/vizcore/templates/plugin_readme.md +23 -0
  128. data/lib/vizcore/templates/plugin_renderer.js +43 -0
  129. data/lib/vizcore/templates/plugin_scene.rb +14 -0
  130. data/lib/vizcore/templates/project_readme.md +7 -23
  131. data/lib/vizcore/templates/rubykaigi_scene.rb +30 -0
  132. data/lib/vizcore/version.rb +1 -1
  133. data/lib/vizcore.rb +28 -0
  134. data/scripts/browser_capture.mjs +75 -0
  135. data/sig/vizcore.rbs +461 -0
  136. metadata +94 -3
  137. data/docs/GETTING_STARTED.md +0 -105
data/frontend/src/main.js CHANGED
@@ -1,6 +1,67 @@
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
+ customShapeParamControlEntries,
36
+ customShapeParamMessage,
37
+ pruneCustomShapeParamOverrides
38
+ } from "./custom-shape-param-controls.js";
39
+ import {
40
+ mappingTargetOptions,
41
+ mappingTargetSignature
42
+ } from "./mapping-target-selector.js";
43
+ import {
44
+ pruneShaderParamOverrides,
45
+ shaderParamControlEntries
46
+ } from "./shader-param-controls.js";
47
+ import {
48
+ normalizeShapeEditorPatch,
49
+ pruneShapeEditorOverrides,
50
+ shapeEditorEntries
51
+ } from "./shape-editor-controls.js";
52
+ import { normalizeRuntimeControlPreset } from "./runtime-control-preset.js";
53
+ import { SHADER_ERROR_EVENT, formatShaderErrorMessage, formatShaderErrorTitle } from "./shader-error-overlay.js";
54
+ import {
55
+ exportVisualSettingsPreset,
56
+ importVisualSettingsPreset,
57
+ loadVisualSettingsPreset,
58
+ saveVisualSettingsPreset,
59
+ visualSettingFromUnit
60
+ } from "./visual-settings-preset.js";
2
61
  import { WebSocketClient } from "./websocket-client.js";
3
62
 
63
+ window.__vizcoreMainStarted = true;
64
+
4
65
  const canvas = document.querySelector("#vizcore-canvas");
5
66
  const wsStatusElement = document.querySelector("#ws-status");
6
67
  const sceneStatusElement = document.querySelector("#scene-status");
@@ -8,29 +69,121 @@ const transitionStatusElement = document.querySelector("#transition-status");
8
69
  const frameStatusElement = document.querySelector("#frame-status");
9
70
  const bpmStatusElement = document.querySelector("#bpm-status");
10
71
  const beatStatusElement = document.querySelector("#beat-status");
72
+ const blackoutButton = document.querySelector("#blackout-toggle");
73
+ const freezeButton = document.querySelector("#freeze-toggle");
74
+ const liveControlStatusElement = document.querySelector("#live-control-status");
75
+ const performanceMonitorElement = document.querySelector("#performance-monitor");
76
+ const inspectorPeakElement = document.querySelector("#inspector-peak");
77
+ const inspectorAmplitudeFill = document.querySelector("#inspector-amplitude-fill");
78
+ const inspectorAmplitudeValue = document.querySelector("#inspector-amplitude-value");
79
+ const inspectorBandElements = Object.fromEntries(
80
+ BAND_KEYS.map((key) => [
81
+ key,
82
+ {
83
+ fill: document.querySelector(`#inspector-band-${key}-fill`),
84
+ value: document.querySelector(`#inspector-band-${key}-value`)
85
+ }
86
+ ])
87
+ );
88
+ const fftPreviewElement = document.querySelector("#fft-preview");
11
89
  const audioSourceStatusElement = document.querySelector("#audio-source-status");
12
90
  const audioTrackStatusElement = document.querySelector("#audio-track-status");
13
91
  const audioPlaybackStatusElement = document.querySelector("#audio-playback-status");
14
92
  const sceneSwitcherElement = document.querySelector("#scene-switcher");
15
93
  const audioToggleButton = document.querySelector("#audio-toggle");
94
+ const visualGainControl = document.querySelector("#visual-gain-control");
95
+ const bassBoostControl = document.querySelector("#bass-boost-control");
96
+ const smoothingControl = document.querySelector("#smoothing-control");
97
+ const beatHoldControl = document.querySelector("#beat-hold-control");
98
+ const wobbleControl = document.querySelector("#wobble-control");
99
+ const reactivitySaveButton = document.querySelector("#reactivity-save");
100
+ const reactivityLoadButton = document.querySelector("#reactivity-load");
101
+ const reactivityProjectSaveButton = document.querySelector("#reactivity-project-save");
102
+ const reactivityExportButton = document.querySelector("#reactivity-export");
103
+ const reactivityImportButton = document.querySelector("#reactivity-import");
104
+ const reactivityStatusElement = document.querySelector("#reactivity-status");
105
+ const midiLearnStatusElement = document.querySelector("#midi-learn-status");
106
+ const midiLearnButtons = Array.from(document.querySelectorAll("[data-midi-learn-action]"));
107
+ const shaderParamControlsElement = document.querySelector("#shader-param-controls");
108
+ const shapeEditorControlsElement = document.querySelector("#shape-editor-controls");
109
+ const customShapeParamControlsElement = document.querySelector("#custom-shape-param-controls");
110
+ const mappingTargetSelectorElement = document.querySelector("#mapping-target-selector");
111
+ const shaderErrorOverlay = document.querySelector("#shader-error-overlay");
112
+ const shaderErrorTitleElement = document.querySelector("#shader-error-title");
113
+ const shaderErrorMessageElement = document.querySelector("#shader-error-message");
114
+ const shaderErrorCloseButton = document.querySelector("#shader-error-close");
115
+ const LATENCY_PROBE_INTERVAL_MS = 3000;
16
116
 
17
- const engine = new Engine(canvas);
18
- engine.init();
19
- engine.start();
20
-
117
+ const visualSettings = loadVisualSettingsPreset(browserStorage());
118
+ let midiLearnBindings = loadMidiLearnBindings(browserStorage());
119
+ const liveControls = createLiveControlState();
120
+ const performanceMonitor = createPerformanceMonitorState();
121
+ let projectorMode = resolveProjectorMode({ body: document.body, location: window.location });
21
122
  let currentSceneName = "unknown";
22
123
  let audioElement = null;
23
124
  let frameCount = 0;
24
125
  let lastConnectedAt = null;
25
126
  let lastTransportSyncAt = 0;
127
+ let latencyProbeTimer = null;
26
128
  let beatFlashUntil = 0;
27
129
  let availableSceneNames = [];
130
+ let keyboardMappings = [];
28
131
  let pendingSceneName = null;
29
132
  let pendingSceneRequestedAt = 0;
133
+ let tapTempoKey = null;
134
+ let runtimeGlobalsReceived = false;
135
+ let runtimeControlPresetApplied = false;
136
+ let controlPresetSaveUrl = null;
137
+ let shaderParamOverrides = {};
138
+ let shaderParamControlsSignature = "";
139
+ let shapeEditorOverrides = {};
140
+ let shapeEditorControlsSignature = "";
141
+ let customShapeParamOverrides = {};
142
+ let customShapeParamControlsSignature = "";
143
+ let mappingTargetSelectorSignature = "";
144
+ let selectedMappingTarget = "";
145
+ let midiAccess = null;
146
+ let pendingMidiLearnAction = null;
147
+ applyProjectorMode(document.body, projectorMode);
148
+ const engine = new Engine(canvas);
149
+ let rendererReady = false;
150
+ bindShaderCompileMetrics();
151
+ try {
152
+ engine.init();
153
+ rendererReady = true;
154
+ } catch (error) {
155
+ renderShaderError({
156
+ layer: "renderer",
157
+ shader: "webgl2",
158
+ message: error instanceof Error ? error.message : String(error)
159
+ });
160
+ }
161
+ engine.setVisualSettings(visualSettings);
162
+ engine.setLiveControls(liveControls);
163
+ bindLiveControls();
164
+ bindVisualControl(visualGainControl, "visualGain");
165
+ bindVisualControl(bassBoostControl, "bassBoost");
166
+ bindVisualControl(smoothingControl, "smoothing");
167
+ bindVisualControl(beatHoldControl, "beatHoldMs");
168
+ bindVisualControl(wobbleControl, "wobbleAmount");
169
+ bindVisualPresetControls();
170
+ bindMidiLearnControls();
171
+ renderLiveControlStatus();
172
+ renderPerformanceMonitor();
173
+ syncVisualControls();
174
+ renderReactivityStatus();
175
+ renderMidiLearnStatus();
176
+ bindShaderErrorOverlay();
177
+ const fftBars = initializeFftPreview(fftPreviewElement);
178
+ if (rendererReady) {
179
+ engine.start();
180
+ }
181
+ startPerformanceMonitorLoop();
30
182
 
31
183
  const websocketUrl = buildWebSocketUrl();
32
184
  const client = new WebSocketClient(websocketUrl, {
33
185
  onFrame: (frame) => {
186
+ updatePerformanceMonitor(recordSocketFrame(performanceMonitor, frame, Date.now()));
34
187
  engine.setAudioFrame(frame);
35
188
  frameCount += 1;
36
189
  let sceneName = String(frame?.scene?.name || currentSceneName);
@@ -48,12 +201,22 @@ const client = new WebSocketClient(websocketUrl, {
48
201
  }
49
202
  const sceneChanged = sceneName !== currentSceneName;
50
203
  currentSceneName = sceneName;
204
+ if (sceneChanged) {
205
+ shaderParamControlsSignature = "";
206
+ shapeEditorControlsSignature = "";
207
+ customShapeParamControlsSignature = "";
208
+ mappingTargetSelectorSignature = "";
209
+ }
210
+ updateShaderParamControls(frame?.scene?.layers);
211
+ updateShapeEditorControls(frame?.scene?.layers);
212
+ updateCustomShapeParamControls(frame?.scene?.layers);
213
+ updateMappingTargetSelector(frame?.scene?.layers);
51
214
  const amplitude = Number(frame?.audio?.amplitude || 0).toFixed(4);
52
215
  const bpm = Number(frame?.audio?.bpm || 0);
53
216
  const beat = !!frame?.audio?.beat;
54
217
  const beatCount = Math.max(0, Number(frame?.audio?.beat_count || 0) || 0);
55
218
  if (beat) {
56
- beatFlashUntil = performance.now() + 180;
219
+ beatFlashUntil = performance.now() + visualSettings.beatHoldMs;
57
220
  }
58
221
  const beatVisible = performance.now() < beatFlashUntil;
59
222
  sceneStatusElement.textContent = `Scene: ${sceneName}`;
@@ -64,6 +227,7 @@ const client = new WebSocketClient(websocketUrl, {
64
227
  bpmStatusElement.textContent = `BPM: ${bpm > 0 ? bpm.toFixed(1) : "--"}`;
65
228
  beatStatusElement.textContent = `Beat: ${beatVisible ? "ON" : "off"} | Count: ${beatCount}`;
66
229
  beatStatusElement.classList.toggle("is-beat", beatVisible);
230
+ renderAudioInspector(frame?.audio);
67
231
  },
68
232
  onSceneChange: (payload) => {
69
233
  const from = String(payload?.from || "unknown");
@@ -82,13 +246,43 @@ const client = new WebSocketClient(websocketUrl, {
82
246
  currentSceneName = String(sceneName);
83
247
  sceneStatusElement.textContent = `Scene: ${currentSceneName}`;
84
248
  renderSceneButtons();
249
+ shaderParamControlsSignature = "";
250
+ shapeEditorControlsSignature = "";
251
+ customShapeParamControlsSignature = "";
252
+ mappingTargetSelectorSignature = "";
253
+ updateShaderParamControls(payload?.scene?.layers);
254
+ updateShapeEditorControls(payload?.scene?.layers);
255
+ updateCustomShapeParamControls(payload?.scene?.layers);
256
+ updateMappingTargetSelector(payload?.scene?.layers);
257
+ }
258
+ if (Object.prototype.hasOwnProperty.call(payload || {}, "tap_tempo_key")) {
259
+ updateTapTempoKey(payload?.tap_tempo_key);
260
+ }
261
+ if (Object.prototype.hasOwnProperty.call(payload || {}, "key_mappings")) {
262
+ updateKeyboardMappings(payload?.key_mappings);
263
+ }
264
+ if (Object.prototype.hasOwnProperty.call(payload || {}, "globals")) {
265
+ runtimeGlobalsReceived = true;
266
+ applyRuntimeGlobals(payload?.globals);
267
+ }
268
+ if (Object.prototype.hasOwnProperty.call(payload || {}, "live_controls")) {
269
+ applyLiveControls({
270
+ ...liveControls,
271
+ ...normalizeLiveControls(payload?.live_controls),
272
+ });
85
273
  }
86
274
  },
275
+ onLatencyProbe: (payload) => {
276
+ updatePerformanceMonitor(recordLatencyProbe(performanceMonitor, payload, Date.now()));
277
+ },
87
278
  onStatus: (status) => {
279
+ updatePerformanceMonitor(recordConnectionStatus(performanceMonitor, status));
88
280
  if (status === "connected") {
89
281
  lastConnectedAt = new Date();
282
+ startLatencyProbeLoop();
90
283
  syncAudioTransportToServer({ force: true });
91
284
  } else {
285
+ stopLatencyProbeLoop();
92
286
  pendingSceneName = null;
93
287
  pendingSceneRequestedAt = 0;
94
288
  currentSceneName = "unknown";
@@ -96,7 +290,7 @@ const client = new WebSocketClient(websocketUrl, {
96
290
  renderSceneButtons();
97
291
  }
98
292
  const connectedAt = lastConnectedAt ? ` | Last connected: ${formatClock(lastConnectedAt)}` : "";
99
- wsStatusElement.textContent = `WebSocket: ${status}${connectedAt}`;
293
+ wsStatusElement.textContent = `WebSocket: ${status} (${websocketUrl})${connectedAt}`;
100
294
  }
101
295
  });
102
296
 
@@ -121,9 +315,24 @@ async function fetchRuntime() {
121
315
  }
122
316
 
123
317
  function applyRuntime(runtime) {
318
+ projectorMode = resolveProjectorMode({
319
+ body: document.body,
320
+ current: projectorMode,
321
+ location: window.location,
322
+ runtime,
323
+ });
324
+ applyProjectorMode(document.body, projectorMode);
325
+
124
326
  const source = String(runtime?.audio_source || "unknown");
125
327
  audioSourceStatusElement.textContent = `Audio Source: ${source}`;
126
328
  updateAvailableScenes(runtime?.scene_names);
329
+ updateTapTempoKey(runtime?.tap_tempo_key);
330
+ updateKeyboardMappings(runtime?.key_mappings);
331
+ updateControlPresetPersistence(runtime);
332
+ if (!runtimeGlobalsReceived) {
333
+ applyRuntimeGlobals(runtime?.globals);
334
+ }
335
+ applyRuntimeControlPreset(runtime?.control_preset);
127
336
 
128
337
  const fileName = runtime?.audio_file_name;
129
338
  const fileUrl = runtime?.audio_file_url;
@@ -139,6 +348,43 @@ function applyRuntime(runtime) {
139
348
  setupAudioPlayback(fileUrl);
140
349
  }
141
350
 
351
+ function applyRuntimeGlobals(globals) {
352
+ engine.setRuntimeGlobals(globals);
353
+ }
354
+
355
+ function updateControlPresetPersistence(runtime) {
356
+ const url = String(runtime?.control_preset_url || "").trim();
357
+ controlPresetSaveUrl = runtime?.control_preset_writable && url ? url : null;
358
+ if (reactivityProjectSaveButton) {
359
+ reactivityProjectSaveButton.hidden = !controlPresetSaveUrl;
360
+ }
361
+ }
362
+
363
+ function applyRuntimeControlPreset(value) {
364
+ if (runtimeControlPresetApplied) {
365
+ return;
366
+ }
367
+
368
+ const preset = normalizeRuntimeControlPreset(value);
369
+ let applied = false;
370
+ if (preset.visualSettings) {
371
+ const imported = importVisualSettingsPreset({ visual_settings: preset.visualSettings }, { fallback: visualSettings });
372
+ Object.assign(visualSettings, saveVisualSettingsPreset(browserStorage(), imported));
373
+ syncVisualControls();
374
+ engine.setVisualSettings(visualSettings);
375
+ renderReactivityStatus("Project preset");
376
+ applied = true;
377
+ }
378
+
379
+ if (preset.midiLearnBindings) {
380
+ midiLearnBindings = saveMidiLearnBindings(browserStorage(), preset.midiLearnBindings);
381
+ renderMidiLearnStatus();
382
+ applied = true;
383
+ }
384
+
385
+ runtimeControlPresetApplied = applied;
386
+ }
387
+
142
388
  function updateAvailableScenes(sceneValues) {
143
389
  const names = normalizeSceneNames(sceneValues);
144
390
  if (!names.length) {
@@ -148,6 +394,16 @@ function updateAvailableScenes(sceneValues) {
148
394
  renderSceneButtons();
149
395
  }
150
396
 
397
+ function updateTapTempoKey(key) {
398
+ const value = String(key || "").trim().toLowerCase();
399
+ tapTempoKey = value || null;
400
+ }
401
+
402
+ function updateKeyboardMappings(mappings) {
403
+ keyboardMappings = normalizeKeyboardMappings(mappings);
404
+ renderSceneButtons();
405
+ }
406
+
151
407
  function normalizeSceneNames(sceneValues) {
152
408
  const seen = new Set();
153
409
  const names = [];
@@ -180,25 +436,480 @@ function renderSceneButtons() {
180
436
  sceneSwitcherElement.hidden = false;
181
437
  const buttons = availableSceneNames.map((sceneName) => {
182
438
  const button = document.createElement("button");
439
+ const shortcut = sceneShortcutFor(sceneName);
183
440
  button.type = "button";
184
- button.textContent = sceneName;
441
+ button.textContent = shortcut ? `${sceneName} [${shortcut}]` : sceneName;
442
+ button.title = shortcut ? `Shortcut: ${shortcut}` : "";
185
443
  button.classList.toggle("is-active", sceneName === currentSceneName);
186
444
  button.onclick = () => {
187
- if (sceneName === currentSceneName) {
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 });
445
+ requestSceneSwitch(sceneName);
196
446
  };
197
447
  return button;
198
448
  });
199
449
  sceneSwitcherElement.replaceChildren(...buttons);
200
450
  }
201
451
 
452
+ function sceneShortcutFor(sceneName) {
453
+ const mapping = keyboardMappings.find((entry) => (
454
+ entry.action?.type === "switch_scene" && entry.action.scene === sceneName
455
+ ));
456
+ return mapping?.key || "";
457
+ }
458
+
459
+ function updateShaderParamControls(layers) {
460
+ if (!shaderParamControlsElement) {
461
+ return;
462
+ }
463
+
464
+ const entries = shaderParamControlEntries(layers, shaderParamOverrides);
465
+ const signature = shaderParamControlsSignatureFor(entries);
466
+ if (signature === shaderParamControlsSignature) {
467
+ return;
468
+ }
469
+
470
+ shaderParamControlsSignature = signature;
471
+ shaderParamOverrides = pruneShaderParamOverrides(shaderParamOverrides, entries);
472
+ engine.setShaderParamOverrides(shaderParamOverrides);
473
+ renderShaderParamControls(entries);
474
+ }
475
+
476
+ function shaderParamControlsSignatureFor(entries) {
477
+ return entries.map((entry) => (
478
+ `${entry.key}:${entry.min}:${entry.max}:${entry.step}`
479
+ )).join("|");
480
+ }
481
+
482
+ function renderShaderParamControls(entries) {
483
+ if (!shaderParamControlsElement) {
484
+ return;
485
+ }
486
+
487
+ if (!entries.length) {
488
+ shaderParamControlsElement.hidden = true;
489
+ shaderParamControlsElement.replaceChildren();
490
+ return;
491
+ }
492
+
493
+ const title = document.createElement("p");
494
+ title.className = "shader-param-controls__title";
495
+ title.textContent = "Shader Params";
496
+ const controls = entries.map((entry) => createShaderParamControl(entry));
497
+ shaderParamControlsElement.replaceChildren(title, ...controls);
498
+ shaderParamControlsElement.hidden = false;
499
+ }
500
+
501
+ function createShaderParamControl(entry) {
502
+ const label = document.createElement("label");
503
+ const name = document.createElement("span");
504
+ const input = document.createElement("input");
505
+ const value = document.createElement("output");
506
+ name.textContent = entry.label;
507
+ input.type = "range";
508
+ input.min = String(entry.min);
509
+ input.max = String(entry.max);
510
+ input.step = String(entry.step);
511
+ input.value = String(entry.value);
512
+ value.value = formatShaderParamValue(entry.value);
513
+ input.addEventListener("input", () => {
514
+ const numeric = Number(input.value);
515
+ shaderParamOverrides[entry.layerKey] ||= {};
516
+ shaderParamOverrides[entry.layerKey][entry.paramName] = numeric;
517
+ engine.setShaderParamOverrides(shaderParamOverrides);
518
+ value.value = formatShaderParamValue(numeric);
519
+ });
520
+ label.append(name, input, value);
521
+ return label;
522
+ }
523
+
524
+ function formatShaderParamValue(value) {
525
+ const numeric = Number(value);
526
+ if (!Number.isFinite(numeric)) {
527
+ return "--";
528
+ }
529
+ return Math.abs(numeric) >= 10 ? numeric.toFixed(1) : numeric.toFixed(2);
530
+ }
531
+
532
+ function updateShapeEditorControls(layers) {
533
+ const entries = shapeEditorEntries(layers, shapeEditorOverrides);
534
+ const signature = shapeEditorControlsSignatureFor(entries);
535
+ if (signature === shapeEditorControlsSignature) {
536
+ return;
537
+ }
538
+
539
+ shapeEditorControlsSignature = signature;
540
+ shapeEditorOverrides = pruneShapeEditorOverrides(shapeEditorOverrides, entries);
541
+ engine.setShapeEditorOverrides(shapeEditorOverrides);
542
+ renderShapeEditorControls(entries);
543
+ }
544
+
545
+ function shapeEditorControlsSignatureFor(entries) {
546
+ return entries.map((entry) => (
547
+ `${entry.key}:${entry.kind}:${JSON.stringify(entry.values)}`
548
+ )).join("|");
549
+ }
550
+
551
+ function renderShapeEditorControls(entries) {
552
+ if (!shapeEditorControlsElement) {
553
+ return;
554
+ }
555
+
556
+ if (!entries.length) {
557
+ shapeEditorControlsElement.hidden = true;
558
+ shapeEditorControlsElement.replaceChildren();
559
+ return;
560
+ }
561
+
562
+ const title = document.createElement("p");
563
+ title.className = "shader-param-controls__title";
564
+ title.textContent = "Shape Editor";
565
+ const controls = entries.map((entry) => createShapeEditorControl(entry));
566
+ shapeEditorControlsElement.replaceChildren(title, ...controls);
567
+ shapeEditorControlsElement.hidden = false;
568
+ }
569
+
570
+ function createShapeEditorControl(entry) {
571
+ const section = document.createElement("details");
572
+ const summary = document.createElement("summary");
573
+ summary.textContent = entry.label;
574
+ section.append(summary);
575
+ section.append(
576
+ createShapeKindControl(entry),
577
+ createShapeNumberControl(entry, "translateX", "Move X", -640, 640, 1),
578
+ createShapeNumberControl(entry, "translateY", "Move Y", -360, 360, 1),
579
+ createShapeNumberControl(entry, "rotate", "Rotate", -180, 180, 1),
580
+ createShapeNumberControl(entry, "scaleX", "Scale X", -4, 4, 0.05),
581
+ createShapeNumberControl(entry, "scaleY", "Scale Y", -4, 4, 0.05),
582
+ createShapeNumberControl(entry, "opacity", "Opacity", 0, 1, 0.05),
583
+ createShapeColorControl(entry, "fill", "Fill"),
584
+ createShapeColorControl(entry, "strokeColor", "Stroke"),
585
+ createShapeNumberControl(entry, "strokeWidth", "Stroke W", 0, 24, 0.5)
586
+ );
587
+ return section;
588
+ }
589
+
590
+ function createShapeKindControl(entry) {
591
+ const label = document.createElement("label");
592
+ const name = document.createElement("span");
593
+ const select = document.createElement("select");
594
+ name.textContent = "Kind";
595
+ ["circle", "line", "rect", "polygon", "polyline", "path", "star"].forEach((kind) => {
596
+ const option = document.createElement("option");
597
+ option.value = kind;
598
+ option.textContent = kind;
599
+ select.append(option);
600
+ });
601
+ select.value = entry.kind;
602
+ select.addEventListener("change", () => {
603
+ writeShapeEditorOverride(entry, { ...entry.values, kind: select.value });
604
+ });
605
+ label.append(name, select);
606
+ return label;
607
+ }
608
+
609
+ function createShapeNumberControl(entry, key, labelText, min, max, step) {
610
+ const label = document.createElement("label");
611
+ const name = document.createElement("span");
612
+ const input = document.createElement("input");
613
+ const value = document.createElement("output");
614
+ name.textContent = labelText;
615
+ input.type = "range";
616
+ input.min = String(min);
617
+ input.max = String(max);
618
+ input.step = String(step);
619
+ input.value = String(entry.values[key]);
620
+ value.value = formatShaderParamValue(entry.values[key]);
621
+ input.addEventListener("input", () => {
622
+ const numeric = Number(input.value);
623
+ writeShapeEditorOverride(entry, { ...entry.values, [key]: numeric });
624
+ value.value = formatShaderParamValue(numeric);
625
+ });
626
+ label.append(name, input, value);
627
+ return label;
628
+ }
629
+
630
+ function createShapeColorControl(entry, key, labelText) {
631
+ const label = document.createElement("label");
632
+ const name = document.createElement("span");
633
+ const input = document.createElement("input");
634
+ const value = document.createElement("output");
635
+ name.textContent = labelText;
636
+ input.type = "color";
637
+ input.value = entry.values[key];
638
+ value.value = entry.values[key];
639
+ input.addEventListener("input", () => {
640
+ writeShapeEditorOverride(entry, { ...entry.values, [key]: input.value, [`${key}Enabled`]: true });
641
+ value.value = input.value;
642
+ });
643
+ label.append(name, input, value);
644
+ return label;
645
+ }
646
+
647
+ function writeShapeEditorOverride(entry, values) {
648
+ shapeEditorOverrides[entry.layerKey] ||= {};
649
+ shapeEditorOverrides[entry.layerKey][entry.shapeIndex] = normalizeShapeEditorPatch(values);
650
+ engine.setShapeEditorOverrides(shapeEditorOverrides);
651
+ }
652
+
653
+ function updateCustomShapeParamControls(layers) {
654
+ const entries = customShapeParamControlEntries(layers, customShapeParamOverrides);
655
+ const signature = customShapeParamControlsSignatureFor(entries);
656
+ if (signature === customShapeParamControlsSignature) {
657
+ return;
658
+ }
659
+
660
+ customShapeParamControlsSignature = signature;
661
+ customShapeParamOverrides = pruneCustomShapeParamOverrides(customShapeParamOverrides, entries);
662
+ renderCustomShapeParamControls(entries);
663
+ }
664
+
665
+ function customShapeParamControlsSignatureFor(entries) {
666
+ return entries.map((entry) => (
667
+ `${entry.key}:${entry.min}:${entry.max}:${entry.step}:${entry.value}`
668
+ )).join("|");
669
+ }
670
+
671
+ function renderCustomShapeParamControls(entries) {
672
+ if (!customShapeParamControlsElement) {
673
+ return;
674
+ }
675
+
676
+ if (!entries.length) {
677
+ customShapeParamControlsElement.hidden = true;
678
+ customShapeParamControlsElement.replaceChildren();
679
+ return;
680
+ }
681
+
682
+ const title = document.createElement("p");
683
+ title.className = "shader-param-controls__title";
684
+ title.textContent = "Custom Shape Params";
685
+ const controls = entries.map((entry) => createCustomShapeParamControl(entry));
686
+ customShapeParamControlsElement.replaceChildren(title, ...controls);
687
+ customShapeParamControlsElement.hidden = false;
688
+ }
689
+
690
+ function createCustomShapeParamControl(entry) {
691
+ const label = document.createElement("label");
692
+ const name = document.createElement("span");
693
+ const input = document.createElement("input");
694
+ const value = document.createElement("output");
695
+ name.textContent = entry.label;
696
+ input.type = "range";
697
+ input.min = String(entry.min);
698
+ input.max = String(entry.max);
699
+ input.step = String(entry.step);
700
+ input.value = String(entry.value);
701
+ value.value = formatShaderParamValue(entry.value);
702
+ input.addEventListener("input", () => {
703
+ const numeric = Number(input.value);
704
+ customShapeParamOverrides[entry.layerKey] ||= {};
705
+ customShapeParamOverrides[entry.layerKey][entry.customShapeIndex] ||= {};
706
+ customShapeParamOverrides[entry.layerKey][entry.customShapeIndex][entry.paramName] = numeric;
707
+ client.send("custom_shape_param", customShapeParamMessage(entry, numeric));
708
+ value.value = formatShaderParamValue(numeric);
709
+ });
710
+ label.append(name, input, value);
711
+ return label;
712
+ }
713
+
714
+ function updateMappingTargetSelector(layers) {
715
+ const options = mappingTargetOptions(layers);
716
+ const signature = mappingTargetSignature(options);
717
+ if (signature === mappingTargetSelectorSignature) {
718
+ return;
719
+ }
720
+
721
+ mappingTargetSelectorSignature = signature;
722
+ renderMappingTargetSelector(options);
723
+ }
724
+
725
+ function renderMappingTargetSelector(options) {
726
+ if (!mappingTargetSelectorElement) {
727
+ return;
728
+ }
729
+
730
+ if (!options.length) {
731
+ selectedMappingTarget = "";
732
+ mappingTargetSelectorElement.hidden = true;
733
+ mappingTargetSelectorElement.replaceChildren();
734
+ return;
735
+ }
736
+
737
+ const title = document.createElement("p");
738
+ title.className = "shader-param-controls__title";
739
+ title.textContent = "Mapping Targets";
740
+ const label = document.createElement("label");
741
+ const name = document.createElement("span");
742
+ const select = document.createElement("select");
743
+ const output = document.createElement("output");
744
+ name.textContent = "Target";
745
+ options.forEach((option) => {
746
+ const item = document.createElement("option");
747
+ item.value = option.target;
748
+ item.textContent = option.label;
749
+ select.append(item);
750
+ });
751
+ if (!options.some((option) => option.target === selectedMappingTarget)) {
752
+ selectedMappingTarget = options[0].target;
753
+ }
754
+ select.value = selectedMappingTarget;
755
+ output.value = selectedMappingTarget;
756
+ select.addEventListener("change", () => {
757
+ selectedMappingTarget = select.value;
758
+ output.value = select.value;
759
+ });
760
+ label.append(name, select, output);
761
+ mappingTargetSelectorElement.replaceChildren(title, label);
762
+ mappingTargetSelectorElement.hidden = false;
763
+ }
764
+
765
+ function requestSceneSwitch(sceneName) {
766
+ if (!sceneName || sceneName === currentSceneName) {
767
+ return;
768
+ }
769
+
770
+ pendingSceneName = sceneName;
771
+ pendingSceneRequestedAt = performance.now();
772
+ currentSceneName = sceneName;
773
+ sceneStatusElement.textContent = `Scene: ${sceneName}`;
774
+ renderSceneButtons();
775
+ client.send("switch_scene", { scene: sceneName });
776
+ }
777
+
778
+ function applyKeyboardAction(action) {
779
+ if (action?.type === "switch_scene") {
780
+ requestSceneSwitch(action.scene);
781
+ return;
782
+ }
783
+
784
+ if (action?.type === "live_control") {
785
+ applyLiveControls(toggleLiveControl(liveControls, action.control));
786
+ }
787
+ }
788
+
789
+ function startPerformanceMonitorLoop() {
790
+ requestAnimationFrame((time) => {
791
+ updatePerformanceMonitor(recordRenderFrame(performanceMonitor, time));
792
+ startPerformanceMonitorLoop();
793
+ });
794
+ }
795
+
796
+ function startLatencyProbeLoop() {
797
+ stopLatencyProbeLoop();
798
+ sendLatencyProbe();
799
+ latencyProbeTimer = setInterval(sendLatencyProbe, LATENCY_PROBE_INTERVAL_MS);
800
+ }
801
+
802
+ function stopLatencyProbeLoop() {
803
+ if (!latencyProbeTimer) {
804
+ return;
805
+ }
806
+
807
+ clearInterval(latencyProbeTimer);
808
+ latencyProbeTimer = null;
809
+ }
810
+
811
+ function sendLatencyProbe() {
812
+ client.send("latency_probe", {
813
+ client_sent_at_ms: Date.now()
814
+ });
815
+ }
816
+
817
+ function updatePerformanceMonitor(nextState) {
818
+ Object.assign(performanceMonitor, nextState);
819
+ renderPerformanceMonitor();
820
+ }
821
+
822
+ function renderPerformanceMonitor() {
823
+ if (!performanceMonitorElement) {
824
+ return;
825
+ }
826
+
827
+ performanceMonitorElement.textContent = formatPerformanceMonitorText(performanceMonitor);
828
+ }
829
+
830
+ function bindShaderCompileMetrics() {
831
+ window.addEventListener(SHADER_COMPILE_EVENT, (event) => {
832
+ updatePerformanceMonitor(recordShaderCompile(performanceMonitor, event.detail));
833
+ });
834
+ }
835
+
836
+ function bindLiveControls() {
837
+ bindLiveControlButton(blackoutButton, "blackout");
838
+ bindLiveControlButton(freezeButton, "freeze");
839
+ window.addEventListener("keydown", (event) => {
840
+ const keyboardAction = keyboardActionForKey(event, keyboardMappings);
841
+ if (keyboardAction) {
842
+ event.preventDefault();
843
+ applyKeyboardAction(keyboardAction);
844
+ return;
845
+ }
846
+
847
+ const action = shortcutActionForKey(event);
848
+ if (action) {
849
+ event.preventDefault();
850
+ applyLiveControls(toggleLiveControl(liveControls, action));
851
+ return;
852
+ }
853
+
854
+ const sceneIndex = shortcutSceneIndexForKey(event, availableSceneNames.length);
855
+ if (sceneIndex === null) {
856
+ return;
857
+ }
858
+
859
+ event.preventDefault();
860
+ requestSceneSwitch(availableSceneNames[sceneIndex]);
861
+ });
862
+
863
+ window.addEventListener("keydown", (event) => {
864
+ if (!isTapTempoShortcut(event, tapTempoKey)) {
865
+ return;
866
+ }
867
+
868
+ event.preventDefault();
869
+ client.send("tap_tempo", { client_tapped_at_ms: Date.now() });
870
+ });
871
+ }
872
+
873
+ function bindLiveControlButton(button, control) {
874
+ if (!button) {
875
+ return;
876
+ }
877
+
878
+ button.addEventListener("click", () => {
879
+ applyLiveControls(toggleLiveControl(liveControls, control));
880
+ });
881
+ }
882
+
883
+ function applyLiveControls(nextState) {
884
+ Object.assign(liveControls, nextState);
885
+ engine.setLiveControls(liveControls);
886
+ renderLiveControlStatus();
887
+ }
888
+
889
+ function normalizeLiveControls(value) {
890
+ const input = value && typeof value === "object" ? value : {};
891
+ return {
892
+ blackout: !!input.blackout,
893
+ freeze: !!input.freeze,
894
+ };
895
+ }
896
+
897
+ function renderLiveControlStatus() {
898
+ if (liveControlStatusElement) {
899
+ liveControlStatusElement.textContent = liveControlStatusText(liveControls);
900
+ }
901
+
902
+ if (blackoutButton) {
903
+ blackoutButton.classList.toggle("is-active", liveControls.blackout);
904
+ blackoutButton.setAttribute("aria-pressed", String(liveControls.blackout));
905
+ }
906
+
907
+ if (freezeButton) {
908
+ freezeButton.classList.toggle("is-active", liveControls.freeze);
909
+ freezeButton.setAttribute("aria-pressed", String(liveControls.freeze));
910
+ }
911
+ }
912
+
202
913
  function setupAudioPlayback(audioUrl) {
203
914
  if (audioElement) {
204
915
  audioElement.pause();
@@ -298,6 +1009,339 @@ function syncAudioTransportToServer({ force = false } = {}) {
298
1009
  }
299
1010
  }
300
1011
 
1012
+ function bindVisualControl(control, key, parser = Number) {
1013
+ if (!control) return;
1014
+ control.addEventListener("input", () => {
1015
+ visualSettings[key] = parser(control.value);
1016
+ engine.setVisualSettings(visualSettings);
1017
+ renderReactivityStatus();
1018
+ });
1019
+ }
1020
+
1021
+ function bindVisualPresetControls() {
1022
+ if (reactivitySaveButton) {
1023
+ reactivitySaveButton.addEventListener("click", () => {
1024
+ Object.assign(visualSettings, saveVisualSettingsPreset(browserStorage(), visualSettings));
1025
+ renderReactivityStatus("Saved");
1026
+ });
1027
+ }
1028
+
1029
+ if (reactivityLoadButton) {
1030
+ reactivityLoadButton.addEventListener("click", () => {
1031
+ Object.assign(visualSettings, loadVisualSettingsPreset(browserStorage(), { fallback: visualSettings }));
1032
+ syncVisualControls();
1033
+ engine.setVisualSettings(visualSettings);
1034
+ renderReactivityStatus("Loaded");
1035
+ });
1036
+ }
1037
+
1038
+ if (reactivityProjectSaveButton) {
1039
+ reactivityProjectSaveButton.addEventListener("click", async () => {
1040
+ if (!controlPresetSaveUrl) {
1041
+ renderReactivityStatus("Project save unavailable");
1042
+ return;
1043
+ }
1044
+
1045
+ const saved = await saveProjectControlPreset();
1046
+ renderReactivityStatus(saved ? "Project saved" : "Project save failed");
1047
+ });
1048
+ }
1049
+
1050
+ if (reactivityExportButton) {
1051
+ reactivityExportButton.addEventListener("click", async () => {
1052
+ const payload = exportVisualSettingsPreset(visualSettings);
1053
+ const copied = await writeClipboardText(payload);
1054
+ if (!copied && typeof window.prompt === "function") {
1055
+ window.prompt("Visual preset JSON", payload);
1056
+ }
1057
+ renderReactivityStatus(copied ? "Exported" : "Export ready");
1058
+ });
1059
+ }
1060
+
1061
+ if (reactivityImportButton) {
1062
+ reactivityImportButton.addEventListener("click", () => {
1063
+ if (typeof window.prompt !== "function") {
1064
+ renderReactivityStatus("Import unavailable");
1065
+ return;
1066
+ }
1067
+
1068
+ const payload = window.prompt("Paste visual preset JSON");
1069
+ if (!payload) {
1070
+ renderReactivityStatus();
1071
+ return;
1072
+ }
1073
+
1074
+ Object.assign(visualSettings, importVisualSettingsPreset(payload, { fallback: visualSettings }));
1075
+ Object.assign(visualSettings, saveVisualSettingsPreset(browserStorage(), visualSettings));
1076
+ syncVisualControls();
1077
+ engine.setVisualSettings(visualSettings);
1078
+ renderReactivityStatus("Imported");
1079
+ });
1080
+ }
1081
+ }
1082
+
1083
+ async function saveProjectControlPreset() {
1084
+ try {
1085
+ const response = await fetch(controlPresetSaveUrl, {
1086
+ method: "PUT",
1087
+ headers: { "content-type": "application/json" },
1088
+ body: JSON.stringify({
1089
+ visual_settings: visualSettings,
1090
+ midi_learn_bindings: midiLearnBindings,
1091
+ }),
1092
+ });
1093
+ return response.ok;
1094
+ } catch {
1095
+ return false;
1096
+ }
1097
+ }
1098
+
1099
+ function bindMidiLearnControls() {
1100
+ midiLearnButtons.forEach((button) => {
1101
+ button.addEventListener("click", async () => {
1102
+ const action = midiLearnActionForButton(button);
1103
+ if (!action) {
1104
+ renderMidiLearnStatus("No action selected");
1105
+ return;
1106
+ }
1107
+
1108
+ const ready = await ensureMidiAccess();
1109
+ if (!ready) {
1110
+ renderMidiLearnStatus("Web MIDI unavailable");
1111
+ return;
1112
+ }
1113
+
1114
+ pendingMidiLearnAction = action;
1115
+ renderMidiLearnStatus(`Move a MIDI control for ${midiLearnActionLabel(action)}`);
1116
+ });
1117
+ });
1118
+ }
1119
+
1120
+ function midiLearnActionForButton(button) {
1121
+ const action = String(button?.dataset?.midiLearnAction || "");
1122
+ if (action === "current-scene") {
1123
+ return currentSceneName && currentSceneName !== "unknown"
1124
+ ? { type: "switch_scene", scene: currentSceneName }
1125
+ : null;
1126
+ }
1127
+ if (action === "blackout" || action === "freeze") {
1128
+ return { type: "live_control", control: action };
1129
+ }
1130
+ if (action.startsWith("visual:")) {
1131
+ return { type: "visual_setting", key: action.slice(7) };
1132
+ }
1133
+ return null;
1134
+ }
1135
+
1136
+ async function ensureMidiAccess() {
1137
+ if (midiAccess) {
1138
+ return true;
1139
+ }
1140
+
1141
+ const requestMIDIAccess = typeof navigator === "undefined" ? null : navigator.requestMIDIAccess;
1142
+ if (typeof requestMIDIAccess !== "function") {
1143
+ return false;
1144
+ }
1145
+
1146
+ try {
1147
+ midiAccess = await requestMIDIAccess.call(navigator);
1148
+ bindMidiInputs(midiAccess.inputs);
1149
+ midiAccess.onstatechange = () => {
1150
+ bindMidiInputs(midiAccess.inputs);
1151
+ renderMidiLearnStatus();
1152
+ };
1153
+ return true;
1154
+ } catch {
1155
+ midiAccess = null;
1156
+ return false;
1157
+ }
1158
+ }
1159
+
1160
+ function bindMidiInputs(inputs) {
1161
+ for (const input of inputs.values()) {
1162
+ input.onmidimessage = handleMidiMessage;
1163
+ }
1164
+ }
1165
+
1166
+ function handleMidiMessage(event) {
1167
+ const signature = midiMessageSignature(event?.data);
1168
+ if (!signature) {
1169
+ return;
1170
+ }
1171
+
1172
+ if (pendingMidiLearnAction && midiMessageActive(event?.data)) {
1173
+ midiLearnBindings = upsertMidiLearnBinding(midiLearnBindings, signature, pendingMidiLearnAction);
1174
+ midiLearnBindings = saveMidiLearnBindings(browserStorage(), midiLearnBindings);
1175
+ renderMidiLearnStatus(`Learned ${midiSignatureLabel(signature)} -> ${midiLearnActionLabel(pendingMidiLearnAction)}`);
1176
+ pendingMidiLearnAction = null;
1177
+ return;
1178
+ }
1179
+
1180
+ const action = midiLearnBindings[signature];
1181
+ if (!action) {
1182
+ return;
1183
+ }
1184
+
1185
+ applyMidiLearnAction(action, midiMessageUnitValue(event?.data), midiMessageActive(event?.data));
1186
+ }
1187
+
1188
+ function applyMidiLearnAction(action, unitValue, active) {
1189
+ if (action.type === "visual_setting") {
1190
+ visualSettings[action.key] = visualSettingFromUnit(action.key, unitValue, visualSettings[action.key]);
1191
+ syncVisualControls();
1192
+ engine.setVisualSettings(visualSettings);
1193
+ renderReactivityStatus("MIDI");
1194
+ return;
1195
+ }
1196
+
1197
+ if (!active || unitValue <= 0) {
1198
+ return;
1199
+ }
1200
+
1201
+ if (action.type === "switch_scene") {
1202
+ requestSceneSwitch(action.scene);
1203
+ renderMidiLearnStatus(`MIDI: ${midiLearnActionLabel(action)}`);
1204
+ return;
1205
+ }
1206
+
1207
+ if (action.type === "live_control") {
1208
+ applyLiveControls(toggleLiveControl(liveControls, action.control));
1209
+ renderMidiLearnStatus(`MIDI: ${midiLearnActionLabel(action)}`);
1210
+ }
1211
+ }
1212
+
1213
+ function syncVisualControls() {
1214
+ setControlValue(visualGainControl, visualSettings.visualGain);
1215
+ setControlValue(bassBoostControl, visualSettings.bassBoost);
1216
+ setControlValue(smoothingControl, visualSettings.smoothing);
1217
+ setControlValue(beatHoldControl, visualSettings.beatHoldMs);
1218
+ setControlValue(wobbleControl, visualSettings.wobbleAmount);
1219
+ }
1220
+
1221
+ function setControlValue(control, value) {
1222
+ if (control) {
1223
+ control.value = String(value);
1224
+ }
1225
+ }
1226
+
1227
+ function browserStorage() {
1228
+ try {
1229
+ return window.localStorage;
1230
+ } catch {
1231
+ return null;
1232
+ }
1233
+ }
1234
+
1235
+ async function writeClipboardText(value) {
1236
+ try {
1237
+ const clipboard = typeof navigator === "undefined" ? null : navigator.clipboard;
1238
+ if (!clipboard?.writeText) {
1239
+ return false;
1240
+ }
1241
+ await clipboard.writeText(value);
1242
+ return true;
1243
+ } catch {
1244
+ return false;
1245
+ }
1246
+ }
1247
+
1248
+ function renderReactivityStatus(prefix = null) {
1249
+ if (!reactivityStatusElement) {
1250
+ return;
1251
+ }
1252
+
1253
+ const values = [
1254
+ `Visual Gain: ${visualSettings.visualGain.toFixed(1)}x`,
1255
+ `Bass: ${visualSettings.bassBoost.toFixed(1)}x`,
1256
+ `Smooth: ${visualSettings.smoothing.toFixed(2)}`,
1257
+ `Beat Hold: ${Math.round(visualSettings.beatHoldMs)}ms`,
1258
+ `Wobble: ${visualSettings.wobbleAmount.toFixed(2)}x`,
1259
+ ].join(" | ");
1260
+
1261
+ reactivityStatusElement.textContent = prefix ? `${prefix} | ${values}` : values;
1262
+ }
1263
+
1264
+ function renderMidiLearnStatus(prefix = null) {
1265
+ if (!midiLearnStatusElement) {
1266
+ return;
1267
+ }
1268
+
1269
+ const bindingCount = Object.keys(midiLearnBindings).length;
1270
+ const accessState = midiAccess ? "ready" : "idle";
1271
+ midiLearnStatusElement.textContent = prefix || `MIDI Learn: ${accessState} | Bindings: ${bindingCount}`;
1272
+ }
1273
+
1274
+ function bindShaderErrorOverlay() {
1275
+ window.addEventListener(SHADER_ERROR_EVENT, (event) => {
1276
+ renderShaderError(event.detail);
1277
+ });
1278
+ if (shaderErrorCloseButton) {
1279
+ shaderErrorCloseButton.addEventListener("click", () => {
1280
+ if (shaderErrorOverlay) {
1281
+ shaderErrorOverlay.hidden = true;
1282
+ }
1283
+ });
1284
+ }
1285
+ }
1286
+
1287
+ function renderShaderError(detail) {
1288
+ if (!shaderErrorOverlay || !shaderErrorTitleElement || !shaderErrorMessageElement) {
1289
+ return;
1290
+ }
1291
+
1292
+ shaderErrorTitleElement.textContent = formatShaderErrorTitle(detail);
1293
+ shaderErrorMessageElement.textContent = formatShaderErrorMessage(detail);
1294
+ shaderErrorOverlay.hidden = false;
1295
+ }
1296
+
1297
+ function initializeFftPreview(container) {
1298
+ if (!container) {
1299
+ return [];
1300
+ }
1301
+
1302
+ const bars = Array.from({ length: DEFAULT_FFT_BINS }, () => {
1303
+ const bar = document.createElement("span");
1304
+ bar.className = "fft-bar";
1305
+ bar.setAttribute("aria-hidden", "true");
1306
+ return bar;
1307
+ });
1308
+ container.replaceChildren(...bars);
1309
+ return bars;
1310
+ }
1311
+
1312
+ function renderAudioInspector(audio) {
1313
+ const state = buildAudioInspectorState(audio);
1314
+ setMeter(inspectorAmplitudeFill, inspectorAmplitudeValue, state.amplitude, 3);
1315
+
1316
+ for (const key of BAND_KEYS) {
1317
+ const elements = inspectorBandElements[key] || {};
1318
+ setMeter(elements.fill, elements.value, state.bands[key], 2);
1319
+ }
1320
+
1321
+ state.fft.forEach((value, index) => {
1322
+ const bar = fftBars[index];
1323
+ if (!bar) {
1324
+ return;
1325
+ }
1326
+ bar.style.setProperty("--bin-value", value.toFixed(4));
1327
+ });
1328
+
1329
+ if (inspectorPeakElement) {
1330
+ inspectorPeakElement.textContent = state.peakFrequency > 0
1331
+ ? `Peak: ${Math.round(state.peakFrequency)} Hz`
1332
+ : "Peak: --";
1333
+ }
1334
+ }
1335
+
1336
+ function setMeter(fill, valueElement, value, digits) {
1337
+ if (fill) {
1338
+ fill.style.setProperty("--meter-value", value.toFixed(4));
1339
+ }
1340
+ if (valueElement) {
1341
+ valueElement.textContent = formatMeterValue(value, digits);
1342
+ }
1343
+ }
1344
+
301
1345
  function buildWebSocketUrl() {
302
1346
  const protocol = window.location.protocol === "https:" ? "wss" : "ws";
303
1347
  return `${protocol}://${window.location.host}/ws`;