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.
Files changed (126) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +544 -9
  3. data/docs/.nojekyll +0 -0
  4. data/docs/assets/site.css +744 -0
  5. data/docs/assets/vizcore-demo.gif +0 -0
  6. data/docs/assets/vizcore-poster.png +0 -0
  7. data/docs/assets/vj-tunnel.js +159 -0
  8. data/docs/index.html +224 -0
  9. data/examples/README.md +59 -0
  10. data/examples/assets/README.md +19 -0
  11. data/examples/audio_inspector.rb +34 -0
  12. data/examples/club_intro_drop.rb +78 -0
  13. data/examples/kansai_rubykaigi_visual.rb +70 -0
  14. data/examples/live_coding_minimal.rb +22 -0
  15. data/examples/midi_controller_show.rb +78 -0
  16. data/examples/midi_scene_switch.rb +3 -1
  17. data/examples/parser_visualizer.rb +48 -0
  18. data/examples/readme_demo.rb +17 -0
  19. data/examples/rhythm_geometry.rb +34 -0
  20. data/examples/ruby_crystal_show.rb +35 -0
  21. data/examples/shader_playground.rb +18 -0
  22. data/examples/unyo_liquid.rb +59 -0
  23. data/examples/vj_ambient_chill_room.rb +124 -0
  24. data/examples/vj_dnb_jungle.rb +170 -0
  25. data/examples/vj_festival_mainstage.rb +245 -0
  26. data/examples/vj_festival_mainstage.yml +17 -0
  27. data/examples/vj_glitch_industrial.rb +164 -0
  28. data/examples/vj_hiphop_cipher.rb +167 -0
  29. data/examples/vj_jpop_idol_live.rb +210 -0
  30. data/examples/vj_synthwave_retro.rb +173 -0
  31. data/examples/vj_techno_warehouse.rb +195 -0
  32. data/frontend/index.html +468 -2
  33. data/frontend/src/audio-inspector.js +40 -0
  34. data/frontend/src/live-controls.js +131 -0
  35. data/frontend/src/main.js +792 -16
  36. data/frontend/src/midi-learn.js +194 -0
  37. data/frontend/src/performance-monitor.js +183 -0
  38. data/frontend/src/plugin-runtime.js +130 -0
  39. data/frontend/src/projector-mode.js +56 -0
  40. data/frontend/src/renderer/engine.js +148 -3
  41. data/frontend/src/renderer/layer-manager.js +428 -30
  42. data/frontend/src/renderer/shader-manager.js +26 -0
  43. data/frontend/src/runtime-control-preset.js +11 -0
  44. data/frontend/src/shader-error-overlay.js +29 -0
  45. data/frontend/src/shader-param-controls.js +93 -0
  46. data/frontend/src/shaders/builtins.js +380 -2
  47. data/frontend/src/shaders/post-effects.js +52 -0
  48. data/frontend/src/visual-regression.js +67 -0
  49. data/frontend/src/visual-settings-preset.js +103 -0
  50. data/frontend/src/visuals/geometry.js +268 -0
  51. data/frontend/src/visuals/image-renderer.js +291 -0
  52. data/frontend/src/visuals/particle-system.js +56 -10
  53. data/frontend/src/visuals/spectrogram-renderer.js +226 -0
  54. data/frontend/src/visuals/text-renderer.js +112 -11
  55. data/frontend/src/websocket-client.js +12 -1
  56. data/lib/vizcore/analysis/adaptive_normalizer.rb +70 -0
  57. data/lib/vizcore/analysis/beat_detector.rb +4 -2
  58. data/lib/vizcore/analysis/bpm_estimator.rb +8 -0
  59. data/lib/vizcore/analysis/feature_recorder.rb +159 -0
  60. data/lib/vizcore/analysis/feature_replay.rb +84 -0
  61. data/lib/vizcore/analysis/pipeline.rb +235 -11
  62. data/lib/vizcore/analysis/tap_tempo.rb +74 -0
  63. data/lib/vizcore/analysis.rb +4 -0
  64. data/lib/vizcore/audio/dummy_sine_input.rb +1 -1
  65. data/lib/vizcore/audio/fixture_input.rb +65 -0
  66. data/lib/vizcore/audio/input_manager.rb +4 -2
  67. data/lib/vizcore/audio/mic_input.rb +24 -8
  68. data/lib/vizcore/audio/portaudio_ffi.rb +106 -1
  69. data/lib/vizcore/audio.rb +1 -0
  70. data/lib/vizcore/cli/doctor.rb +159 -0
  71. data/lib/vizcore/cli/dsl_reference.rb +99 -0
  72. data/lib/vizcore/cli/layer_docs.rb +46 -0
  73. data/lib/vizcore/cli/scene_diagnostics.rb +23 -0
  74. data/lib/vizcore/cli/scene_inspector.rb +136 -0
  75. data/lib/vizcore/cli/scene_validator.rb +245 -0
  76. data/lib/vizcore/cli/shader_template.rb +68 -0
  77. data/lib/vizcore/cli/shader_uniform_docs.rb +54 -0
  78. data/lib/vizcore/cli.rb +689 -18
  79. data/lib/vizcore/config.rb +103 -2
  80. data/lib/vizcore/control_preset.rb +68 -0
  81. data/lib/vizcore/dsl/engine.rb +277 -5
  82. data/lib/vizcore/dsl/layer_builder.rb +491 -22
  83. data/lib/vizcore/dsl/layer_group_builder.rb +112 -0
  84. data/lib/vizcore/dsl/mapping_resolver.rb +132 -3
  85. data/lib/vizcore/dsl/mapping_transform_builder.rb +71 -0
  86. data/lib/vizcore/dsl/reaction_builder.rb +44 -0
  87. data/lib/vizcore/dsl/scene_builder.rb +61 -5
  88. data/lib/vizcore/dsl/shader_source_resolver.rb +67 -6
  89. data/lib/vizcore/dsl/style_builder.rb +68 -0
  90. data/lib/vizcore/dsl/timeline_builder.rb +138 -0
  91. data/lib/vizcore/dsl/transition_controller.rb +77 -0
  92. data/lib/vizcore/dsl.rb +5 -1
  93. data/lib/vizcore/layer_catalog.rb +273 -0
  94. data/lib/vizcore/project_manifest.rb +152 -0
  95. data/lib/vizcore/renderer/png_writer.rb +57 -0
  96. data/lib/vizcore/renderer/render_sequence.rb +153 -0
  97. data/lib/vizcore/renderer/scene_frame_source.rb +119 -0
  98. data/lib/vizcore/renderer/scene_serializer.rb +36 -3
  99. data/lib/vizcore/renderer/snapshot.rb +38 -0
  100. data/lib/vizcore/renderer/snapshot_renderer.rb +446 -0
  101. data/lib/vizcore/renderer.rb +5 -0
  102. data/lib/vizcore/server/frame_broadcaster.rb +91 -5
  103. data/lib/vizcore/server/gallery_app.rb +155 -0
  104. data/lib/vizcore/server/gallery_page.rb +100 -0
  105. data/lib/vizcore/server/gallery_runner.rb +48 -0
  106. data/lib/vizcore/server/rack_app.rb +203 -4
  107. data/lib/vizcore/server/runner.rb +370 -22
  108. data/lib/vizcore/server/scene_dependency_watcher.rb +79 -0
  109. data/lib/vizcore/server/websocket_handler.rb +60 -10
  110. data/lib/vizcore/server.rb +4 -0
  111. data/lib/vizcore/sync/osc_message.rb +103 -0
  112. data/lib/vizcore/sync/osc_receiver.rb +68 -0
  113. data/lib/vizcore/sync.rb +4 -0
  114. data/lib/vizcore/templates/midi_control_scene.rb +3 -1
  115. data/lib/vizcore/templates/plugin_layer.rb +20 -0
  116. data/lib/vizcore/templates/plugin_readme.md +23 -0
  117. data/lib/vizcore/templates/plugin_renderer.js +43 -0
  118. data/lib/vizcore/templates/plugin_scene.rb +14 -0
  119. data/lib/vizcore/templates/project_readme.md +7 -23
  120. data/lib/vizcore/templates/rubykaigi_scene.rb +30 -0
  121. data/lib/vizcore/version.rb +1 -1
  122. data/lib/vizcore.rb +27 -0
  123. data/scripts/browser_capture.mjs +75 -0
  124. data/sig/vizcore.rbs +362 -0
  125. metadata +83 -3
  126. 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 engine = new Engine(canvas);
18
- engine.init();
19
- engine.start();
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() + 180;
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
- 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 });
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`;