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.
- checksums.yaml +4 -4
- data/README.md +70 -117
- data/docs/.nojekyll +0 -0
- data/docs/assets/playground-worker.js +373 -0
- data/docs/assets/playground.css +440 -0
- data/docs/assets/playground.js +652 -0
- data/docs/assets/site.css +744 -0
- data/docs/assets/vizcore-demo.gif +0 -0
- data/docs/assets/vizcore-poster.png +0 -0
- data/docs/assets/vj-tunnel.js +159 -0
- data/docs/index.html +225 -0
- data/docs/playground.html +81 -0
- data/docs/shape_dsl.md +269 -0
- data/examples/README.md +59 -0
- data/examples/assets/README.md +19 -0
- data/examples/audio_inspector.rb +34 -0
- data/examples/club_intro_drop.rb +78 -0
- data/examples/kansai_rubykaigi_visual.rb +70 -0
- data/examples/live_coding_minimal.rb +22 -0
- data/examples/midi_controller_show.rb +78 -0
- data/examples/midi_scene_switch.rb +3 -1
- data/examples/parser_visualizer.rb +48 -0
- data/examples/readme_demo.rb +17 -0
- data/examples/rhythm_geometry.rb +34 -0
- data/examples/ruby_crystal_show.rb +35 -0
- data/examples/shader_playground.rb +18 -0
- data/examples/unyo_liquid.rb +59 -0
- data/examples/vj_ambient_chill_room.rb +124 -0
- data/examples/vj_dnb_jungle.rb +170 -0
- data/examples/vj_festival_mainstage.rb +245 -0
- data/examples/vj_festival_mainstage.yml +17 -0
- data/examples/vj_glitch_industrial.rb +164 -0
- data/examples/vj_hiphop_cipher.rb +167 -0
- data/examples/vj_jpop_idol_live.rb +210 -0
- data/examples/vj_synthwave_retro.rb +173 -0
- data/examples/vj_techno_warehouse.rb +195 -0
- data/frontend/index.html +494 -2
- data/frontend/src/audio-inspector.js +40 -0
- data/frontend/src/custom-shape-param-controls.js +106 -0
- data/frontend/src/live-controls.js +131 -0
- data/frontend/src/main.js +1060 -16
- data/frontend/src/mapping-target-selector.js +109 -0
- data/frontend/src/midi-learn.js +194 -0
- data/frontend/src/performance-monitor.js +183 -0
- data/frontend/src/plugin-runtime.js +130 -0
- data/frontend/src/projector-mode.js +56 -0
- data/frontend/src/renderer/engine.js +157 -3
- data/frontend/src/renderer/layer-manager.js +442 -30
- data/frontend/src/renderer/shader-manager.js +26 -0
- data/frontend/src/runtime-control-preset.js +11 -0
- data/frontend/src/shader-error-overlay.js +29 -0
- data/frontend/src/shader-param-controls.js +93 -0
- data/frontend/src/shaders/builtins.js +380 -2
- data/frontend/src/shaders/post-effects.js +52 -0
- data/frontend/src/shape-editor-controls.js +157 -0
- data/frontend/src/visual-regression.js +67 -0
- data/frontend/src/visual-settings-preset.js +103 -0
- data/frontend/src/visuals/geometry.js +666 -0
- data/frontend/src/visuals/image-renderer.js +291 -0
- data/frontend/src/visuals/particle-system.js +56 -10
- data/frontend/src/visuals/shape-renderer.js +475 -0
- data/frontend/src/visuals/spectrogram-renderer.js +226 -0
- data/frontend/src/visuals/svg-arc.js +104 -0
- data/frontend/src/visuals/text-renderer.js +112 -11
- data/frontend/src/websocket-client.js +12 -1
- data/lib/vizcore/analysis/adaptive_normalizer.rb +70 -0
- data/lib/vizcore/analysis/beat_detector.rb +4 -2
- data/lib/vizcore/analysis/bpm_estimator.rb +8 -0
- data/lib/vizcore/analysis/feature_recorder.rb +159 -0
- data/lib/vizcore/analysis/feature_replay.rb +84 -0
- data/lib/vizcore/analysis/pipeline.rb +235 -11
- data/lib/vizcore/analysis/tap_tempo.rb +74 -0
- data/lib/vizcore/analysis.rb +4 -0
- data/lib/vizcore/audio/dummy_sine_input.rb +1 -1
- data/lib/vizcore/audio/fixture_input.rb +65 -0
- data/lib/vizcore/audio/input_manager.rb +4 -2
- data/lib/vizcore/audio/mic_input.rb +24 -8
- data/lib/vizcore/audio/portaudio_ffi.rb +106 -1
- data/lib/vizcore/audio.rb +1 -0
- data/lib/vizcore/cli/doctor.rb +159 -0
- data/lib/vizcore/cli/dsl_reference.rb +99 -0
- data/lib/vizcore/cli/layer_docs.rb +46 -0
- data/lib/vizcore/cli/scene_diagnostics.rb +23 -0
- data/lib/vizcore/cli/scene_inspector.rb +136 -0
- data/lib/vizcore/cli/scene_validator.rb +337 -0
- data/lib/vizcore/cli/shader_template.rb +68 -0
- data/lib/vizcore/cli/shader_uniform_docs.rb +54 -0
- data/lib/vizcore/cli.rb +689 -18
- data/lib/vizcore/config.rb +103 -2
- data/lib/vizcore/control_preset.rb +68 -0
- data/lib/vizcore/dsl/engine.rb +277 -5
- data/lib/vizcore/dsl/layer_builder.rb +1280 -23
- data/lib/vizcore/dsl/layer_group_builder.rb +112 -0
- data/lib/vizcore/dsl/mapping_resolver.rb +290 -7
- data/lib/vizcore/dsl/mapping_transform_builder.rb +71 -0
- data/lib/vizcore/dsl/reaction_builder.rb +44 -0
- data/lib/vizcore/dsl/scene_builder.rb +61 -5
- data/lib/vizcore/dsl/shader_source_resolver.rb +67 -6
- data/lib/vizcore/dsl/style_builder.rb +68 -0
- data/lib/vizcore/dsl/timeline_builder.rb +138 -0
- data/lib/vizcore/dsl/transition_controller.rb +77 -0
- data/lib/vizcore/dsl.rb +5 -1
- data/lib/vizcore/layer_catalog.rb +275 -0
- data/lib/vizcore/project_manifest.rb +152 -0
- data/lib/vizcore/renderer/png_writer.rb +57 -0
- data/lib/vizcore/renderer/render_sequence.rb +153 -0
- data/lib/vizcore/renderer/scene_frame_source.rb +132 -0
- data/lib/vizcore/renderer/scene_serializer.rb +36 -3
- data/lib/vizcore/renderer/snapshot.rb +38 -0
- data/lib/vizcore/renderer/snapshot_renderer.rb +938 -0
- data/lib/vizcore/renderer.rb +5 -0
- data/lib/vizcore/server/frame_broadcaster.rb +143 -8
- data/lib/vizcore/server/gallery_app.rb +155 -0
- data/lib/vizcore/server/gallery_page.rb +100 -0
- data/lib/vizcore/server/gallery_runner.rb +48 -0
- data/lib/vizcore/server/rack_app.rb +203 -4
- data/lib/vizcore/server/runner.rb +391 -22
- data/lib/vizcore/server/scene_dependency_watcher.rb +79 -0
- data/lib/vizcore/server/websocket_handler.rb +60 -10
- data/lib/vizcore/server.rb +4 -0
- data/lib/vizcore/shape.rb +719 -0
- data/lib/vizcore/sync/osc_message.rb +103 -0
- data/lib/vizcore/sync/osc_receiver.rb +68 -0
- data/lib/vizcore/sync.rb +4 -0
- data/lib/vizcore/templates/midi_control_scene.rb +3 -1
- data/lib/vizcore/templates/plugin_layer.rb +20 -0
- data/lib/vizcore/templates/plugin_readme.md +23 -0
- data/lib/vizcore/templates/plugin_renderer.js +43 -0
- data/lib/vizcore/templates/plugin_scene.rb +14 -0
- data/lib/vizcore/templates/project_readme.md +7 -23
- data/lib/vizcore/templates/rubykaigi_scene.rb +30 -0
- data/lib/vizcore/version.rb +1 -1
- data/lib/vizcore.rb +28 -0
- data/scripts/browser_capture.mjs +75 -0
- data/sig/vizcore.rbs +461 -0
- metadata +94 -3
- 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
|
|
18
|
-
|
|
19
|
-
|
|
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() +
|
|
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
|
-
|
|
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`;
|