vizcore 0.1.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +544 -9
- data/docs/.nojekyll +0 -0
- data/docs/assets/site.css +744 -0
- data/docs/assets/vizcore-demo.gif +0 -0
- data/docs/assets/vizcore-poster.png +0 -0
- data/docs/assets/vj-tunnel.js +159 -0
- data/docs/index.html +224 -0
- data/examples/README.md +59 -0
- data/examples/assets/README.md +19 -0
- data/examples/audio_inspector.rb +34 -0
- data/examples/club_intro_drop.rb +78 -0
- data/examples/kansai_rubykaigi_visual.rb +70 -0
- data/examples/live_coding_minimal.rb +22 -0
- data/examples/midi_controller_show.rb +78 -0
- data/examples/midi_scene_switch.rb +3 -1
- data/examples/parser_visualizer.rb +48 -0
- data/examples/readme_demo.rb +17 -0
- data/examples/rhythm_geometry.rb +34 -0
- data/examples/ruby_crystal_show.rb +35 -0
- data/examples/shader_playground.rb +18 -0
- data/examples/unyo_liquid.rb +59 -0
- data/examples/vj_ambient_chill_room.rb +124 -0
- data/examples/vj_dnb_jungle.rb +170 -0
- data/examples/vj_festival_mainstage.rb +245 -0
- data/examples/vj_festival_mainstage.yml +17 -0
- data/examples/vj_glitch_industrial.rb +164 -0
- data/examples/vj_hiphop_cipher.rb +167 -0
- data/examples/vj_jpop_idol_live.rb +210 -0
- data/examples/vj_synthwave_retro.rb +173 -0
- data/examples/vj_techno_warehouse.rb +195 -0
- data/frontend/index.html +468 -2
- data/frontend/src/audio-inspector.js +40 -0
- data/frontend/src/live-controls.js +131 -0
- data/frontend/src/main.js +792 -16
- data/frontend/src/midi-learn.js +194 -0
- data/frontend/src/performance-monitor.js +183 -0
- data/frontend/src/plugin-runtime.js +130 -0
- data/frontend/src/projector-mode.js +56 -0
- data/frontend/src/renderer/engine.js +148 -3
- data/frontend/src/renderer/layer-manager.js +428 -30
- data/frontend/src/renderer/shader-manager.js +26 -0
- data/frontend/src/runtime-control-preset.js +11 -0
- data/frontend/src/shader-error-overlay.js +29 -0
- data/frontend/src/shader-param-controls.js +93 -0
- data/frontend/src/shaders/builtins.js +380 -2
- data/frontend/src/shaders/post-effects.js +52 -0
- data/frontend/src/visual-regression.js +67 -0
- data/frontend/src/visual-settings-preset.js +103 -0
- data/frontend/src/visuals/geometry.js +268 -0
- data/frontend/src/visuals/image-renderer.js +291 -0
- data/frontend/src/visuals/particle-system.js +56 -10
- data/frontend/src/visuals/spectrogram-renderer.js +226 -0
- data/frontend/src/visuals/text-renderer.js +112 -11
- data/frontend/src/websocket-client.js +12 -1
- data/lib/vizcore/analysis/adaptive_normalizer.rb +70 -0
- data/lib/vizcore/analysis/beat_detector.rb +4 -2
- data/lib/vizcore/analysis/bpm_estimator.rb +8 -0
- data/lib/vizcore/analysis/feature_recorder.rb +159 -0
- data/lib/vizcore/analysis/feature_replay.rb +84 -0
- data/lib/vizcore/analysis/pipeline.rb +235 -11
- data/lib/vizcore/analysis/tap_tempo.rb +74 -0
- data/lib/vizcore/analysis.rb +4 -0
- data/lib/vizcore/audio/dummy_sine_input.rb +1 -1
- data/lib/vizcore/audio/fixture_input.rb +65 -0
- data/lib/vizcore/audio/input_manager.rb +4 -2
- data/lib/vizcore/audio/mic_input.rb +24 -8
- data/lib/vizcore/audio/portaudio_ffi.rb +106 -1
- data/lib/vizcore/audio.rb +1 -0
- data/lib/vizcore/cli/doctor.rb +159 -0
- data/lib/vizcore/cli/dsl_reference.rb +99 -0
- data/lib/vizcore/cli/layer_docs.rb +46 -0
- data/lib/vizcore/cli/scene_diagnostics.rb +23 -0
- data/lib/vizcore/cli/scene_inspector.rb +136 -0
- data/lib/vizcore/cli/scene_validator.rb +245 -0
- data/lib/vizcore/cli/shader_template.rb +68 -0
- data/lib/vizcore/cli/shader_uniform_docs.rb +54 -0
- data/lib/vizcore/cli.rb +689 -18
- data/lib/vizcore/config.rb +103 -2
- data/lib/vizcore/control_preset.rb +68 -0
- data/lib/vizcore/dsl/engine.rb +277 -5
- data/lib/vizcore/dsl/layer_builder.rb +491 -22
- data/lib/vizcore/dsl/layer_group_builder.rb +112 -0
- data/lib/vizcore/dsl/mapping_resolver.rb +132 -3
- data/lib/vizcore/dsl/mapping_transform_builder.rb +71 -0
- data/lib/vizcore/dsl/reaction_builder.rb +44 -0
- data/lib/vizcore/dsl/scene_builder.rb +61 -5
- data/lib/vizcore/dsl/shader_source_resolver.rb +67 -6
- data/lib/vizcore/dsl/style_builder.rb +68 -0
- data/lib/vizcore/dsl/timeline_builder.rb +138 -0
- data/lib/vizcore/dsl/transition_controller.rb +77 -0
- data/lib/vizcore/dsl.rb +5 -1
- data/lib/vizcore/layer_catalog.rb +273 -0
- data/lib/vizcore/project_manifest.rb +152 -0
- data/lib/vizcore/renderer/png_writer.rb +57 -0
- data/lib/vizcore/renderer/render_sequence.rb +153 -0
- data/lib/vizcore/renderer/scene_frame_source.rb +119 -0
- data/lib/vizcore/renderer/scene_serializer.rb +36 -3
- data/lib/vizcore/renderer/snapshot.rb +38 -0
- data/lib/vizcore/renderer/snapshot_renderer.rb +446 -0
- data/lib/vizcore/renderer.rb +5 -0
- data/lib/vizcore/server/frame_broadcaster.rb +91 -5
- data/lib/vizcore/server/gallery_app.rb +155 -0
- data/lib/vizcore/server/gallery_page.rb +100 -0
- data/lib/vizcore/server/gallery_runner.rb +48 -0
- data/lib/vizcore/server/rack_app.rb +203 -4
- data/lib/vizcore/server/runner.rb +370 -22
- data/lib/vizcore/server/scene_dependency_watcher.rb +79 -0
- data/lib/vizcore/server/websocket_handler.rb +60 -10
- data/lib/vizcore/server.rb +4 -0
- data/lib/vizcore/sync/osc_message.rb +103 -0
- data/lib/vizcore/sync/osc_receiver.rb +68 -0
- data/lib/vizcore/sync.rb +4 -0
- data/lib/vizcore/templates/midi_control_scene.rb +3 -1
- data/lib/vizcore/templates/plugin_layer.rb +20 -0
- data/lib/vizcore/templates/plugin_readme.md +23 -0
- data/lib/vizcore/templates/plugin_renderer.js +43 -0
- data/lib/vizcore/templates/plugin_scene.rb +14 -0
- data/lib/vizcore/templates/project_readme.md +7 -23
- data/lib/vizcore/templates/rubykaigi_scene.rb +30 -0
- data/lib/vizcore/version.rb +1 -1
- data/lib/vizcore.rb +27 -0
- data/scripts/browser_capture.mjs +75 -0
- data/sig/vizcore.rbs +362 -0
- metadata +83 -3
- data/docs/GETTING_STARTED.md +0 -105
|
@@ -2,10 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
require "puma"
|
|
4
4
|
require_relative "../config"
|
|
5
|
+
require_relative "../control_preset"
|
|
5
6
|
require_relative "../dsl"
|
|
6
7
|
require_relative "../errors"
|
|
8
|
+
require_relative "../sync/osc_receiver"
|
|
7
9
|
require_relative "frame_broadcaster"
|
|
8
10
|
require_relative "rack_app"
|
|
11
|
+
require_relative "scene_dependency_watcher"
|
|
9
12
|
require_relative "websocket_handler"
|
|
10
13
|
|
|
11
14
|
module Vizcore
|
|
@@ -20,6 +23,9 @@ module Vizcore
|
|
|
20
23
|
@shader_source_resolver = Vizcore::DSL::ShaderSourceResolver.new
|
|
21
24
|
@scene_catalog_mutex = Mutex.new
|
|
22
25
|
@scene_catalog = []
|
|
26
|
+
@runtime_globals_mutex = Mutex.new
|
|
27
|
+
@runtime_globals = {}
|
|
28
|
+
@live_controls = { "blackout" => false, "freeze" => false }
|
|
23
29
|
end
|
|
24
30
|
|
|
25
31
|
# Run server lifecycle until interrupted.
|
|
@@ -29,51 +35,75 @@ module Vizcore
|
|
|
29
35
|
# @return [void]
|
|
30
36
|
def run
|
|
31
37
|
validate_scene_file!
|
|
38
|
+
validate_feature_settings!
|
|
39
|
+
validate_control_preset_settings!
|
|
40
|
+
validate_plugin_asset_settings!
|
|
32
41
|
validate_audio_settings!
|
|
33
42
|
definition = load_definition!
|
|
43
|
+
control_preset = load_control_preset
|
|
44
|
+
replace_runtime_globals(globals_for(definition))
|
|
45
|
+
@tap_tempo_key = tap_tempo_key(definition)
|
|
34
46
|
scene = first_scene(definition) || fallback_scene
|
|
35
47
|
|
|
36
48
|
app = RackApp.new(
|
|
37
49
|
frontend_root: Vizcore.frontend_root,
|
|
38
|
-
audio_source:
|
|
39
|
-
audio_file:
|
|
40
|
-
scene_names: scene_names_for(definition)
|
|
50
|
+
audio_source: runtime_audio_source,
|
|
51
|
+
audio_file: runtime_audio_file,
|
|
52
|
+
scene_names: scene_names_for(definition),
|
|
53
|
+
tap_tempo_key: @tap_tempo_key,
|
|
54
|
+
key_mappings: key_mappings_for(definition),
|
|
55
|
+
globals: runtime_globals_snapshot,
|
|
56
|
+
control_preset: control_preset,
|
|
57
|
+
control_preset_path: @config.control_preset,
|
|
58
|
+
plugin_assets: @config.plugin_assets,
|
|
59
|
+
projector_mode: @config.projector_mode
|
|
41
60
|
)
|
|
42
61
|
server = Puma::Server.new(app, nil, min_threads: 0, max_threads: 4)
|
|
43
62
|
server.add_tcp_listener(@config.host, @config.port)
|
|
44
63
|
server.run
|
|
45
64
|
|
|
46
|
-
input_manager =
|
|
47
|
-
source: @config.audio_source,
|
|
48
|
-
file_path: @config.audio_file&.to_s
|
|
49
|
-
)
|
|
65
|
+
input_manager = build_input_manager
|
|
50
66
|
broadcaster = FrameBroadcaster.new(
|
|
51
67
|
scene_name: scene[:name].to_s,
|
|
52
68
|
scene_layers: scene[:layers],
|
|
53
69
|
scene_catalog: definition[:scenes],
|
|
54
70
|
transitions: definition[:transitions],
|
|
55
71
|
input_manager: input_manager,
|
|
72
|
+
analysis_pipeline: replay_pipeline,
|
|
73
|
+
noise_gate: @config.noise_gate,
|
|
74
|
+
audio_normalize: audio_normalize_settings(definition),
|
|
75
|
+
bpm: bpm_setting(definition),
|
|
76
|
+
bpm_lock: bpm_lock_setting(definition),
|
|
56
77
|
error_reporter: ->(message) { @output.puts(message) }
|
|
57
78
|
)
|
|
58
79
|
replace_scene_catalog(definition[:scenes])
|
|
59
|
-
if
|
|
80
|
+
if file_transport_enabled?
|
|
60
81
|
broadcaster.sync_transport(playing: false, position_seconds: 0.0)
|
|
61
82
|
end
|
|
62
83
|
broadcaster.start
|
|
63
84
|
register_client_message_handler(broadcaster)
|
|
64
85
|
midi_runtime = start_midi_runtime(definition, broadcaster)
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
86
|
+
osc_runtime = start_osc_runtime(broadcaster)
|
|
87
|
+
watcher = if @config.reload?
|
|
88
|
+
start_scene_watcher(broadcaster, definition: definition) do |updated_definition|
|
|
89
|
+
midi_runtime = refresh_midi_runtime(midi_runtime, updated_definition, broadcaster)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
68
92
|
|
|
69
93
|
@output.puts("Vizcore server listening at http://#{@config.host}:#{@config.port}")
|
|
94
|
+
@output.puts("Projector output: http://#{@config.host}:#{@config.port}/projector")
|
|
95
|
+
@output.puts("Control panel: http://#{@config.host}:#{@config.port}/control")
|
|
70
96
|
@output.puts("Scene: #{scene[:name]}")
|
|
71
|
-
@output.puts("
|
|
97
|
+
@output.puts("Hot reload: #{@config.reload? ? 'enabled' : 'disabled'}")
|
|
98
|
+
@output.puts("Audio playback: http://#{@config.host}:#{@config.port}/audio-file") if file_transport_enabled?
|
|
99
|
+
@output.puts("Feature replay: #{@config.feature_file}") if feature_replay?
|
|
100
|
+
@output.puts("OSC sync: udp://#{@config.host}:#{@config.osc_port}") if osc_runtime
|
|
72
101
|
@output.puts("Press Ctrl+C to stop.")
|
|
73
102
|
|
|
74
103
|
wait_for_interrupt
|
|
75
104
|
ensure
|
|
76
105
|
Vizcore::Server::WebSocketHandler.clear_message_handler
|
|
106
|
+
stop_osc_runtime(osc_runtime)
|
|
77
107
|
stop_midi_runtime(midi_runtime)
|
|
78
108
|
watcher&.stop
|
|
79
109
|
broadcaster&.stop
|
|
@@ -105,12 +135,72 @@ module Vizcore
|
|
|
105
135
|
end
|
|
106
136
|
|
|
107
137
|
def validate_audio_settings!
|
|
138
|
+
return if feature_replay?
|
|
108
139
|
return unless @config.audio_source == :file
|
|
109
140
|
return if @config.audio_file && @config.audio_file.file?
|
|
110
141
|
|
|
111
142
|
raise Vizcore::ConfigurationError, "Audio file not found: #{@config.audio_file || '(nil)'}"
|
|
112
143
|
end
|
|
113
144
|
|
|
145
|
+
def validate_feature_settings!
|
|
146
|
+
return unless feature_replay?
|
|
147
|
+
return if @config.feature_file.file?
|
|
148
|
+
|
|
149
|
+
raise Vizcore::ConfigurationError, "Feature file not found: #{@config.feature_file}"
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def validate_control_preset_settings!
|
|
153
|
+
return unless @config.control_preset
|
|
154
|
+
return if @config.control_preset.file?
|
|
155
|
+
|
|
156
|
+
raise Vizcore::ConfigurationError, "Control preset file not found: #{@config.control_preset}"
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def validate_plugin_asset_settings!
|
|
160
|
+
missing = @config.plugin_assets.find { |path| !path.file? }
|
|
161
|
+
return unless missing
|
|
162
|
+
|
|
163
|
+
raise Vizcore::ConfigurationError, "Plugin asset file not found: #{missing}"
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def load_control_preset
|
|
167
|
+
return nil unless @config.control_preset
|
|
168
|
+
|
|
169
|
+
Vizcore::ControlPreset.load(@config.control_preset)
|
|
170
|
+
rescue ArgumentError => e
|
|
171
|
+
raise Vizcore::ConfigurationError, e.message
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def build_input_manager
|
|
175
|
+
Vizcore::Audio::InputManager.new(
|
|
176
|
+
source: feature_replay? ? :dummy : @config.audio_source,
|
|
177
|
+
file_path: runtime_audio_file&.to_s,
|
|
178
|
+
audio_device: feature_replay? ? nil : @config.audio_device
|
|
179
|
+
)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def replay_pipeline
|
|
183
|
+
return nil unless feature_replay?
|
|
184
|
+
|
|
185
|
+
Vizcore::Analysis::FeatureReplay.new(path: @config.feature_file)
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def feature_replay?
|
|
189
|
+
!!@config.feature_file
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def file_transport_enabled?
|
|
193
|
+
@config.audio_source == :file && !feature_replay?
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def runtime_audio_source
|
|
197
|
+
feature_replay? ? :features : @config.audio_source
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def runtime_audio_file
|
|
201
|
+
feature_replay? ? nil : @config.audio_file
|
|
202
|
+
end
|
|
203
|
+
|
|
114
204
|
def wait_for_interrupt
|
|
115
205
|
stop_requested = false
|
|
116
206
|
%w[INT TERM].each do |signal_name|
|
|
@@ -121,22 +211,32 @@ module Vizcore
|
|
|
121
211
|
sleep(0.1) until stop_requested
|
|
122
212
|
end
|
|
123
213
|
|
|
124
|
-
def start_scene_watcher(broadcaster, &on_reload)
|
|
125
|
-
watcher = Vizcore::
|
|
214
|
+
def start_scene_watcher(broadcaster, definition:, &on_reload)
|
|
215
|
+
watcher = Vizcore::Server::SceneDependencyWatcher.new(scene_file: @config.scene_file.to_s, definition: definition) do |definition, _changed_path|
|
|
126
216
|
definition = resolve_shader_sources(definition)
|
|
127
217
|
replace_scene_catalog(definition[:scenes])
|
|
218
|
+
replace_runtime_globals(globals_for(definition))
|
|
219
|
+
@tap_tempo_key = tap_tempo_key(definition)
|
|
128
220
|
scene = first_scene(definition) || fallback_scene
|
|
129
221
|
broadcaster.update_transition_definition(
|
|
130
222
|
scenes: Array(definition[:scenes]),
|
|
131
223
|
transitions: Array(definition[:transitions])
|
|
132
224
|
)
|
|
225
|
+
broadcaster.update_analysis_settings(
|
|
226
|
+
audio_normalize: audio_normalize_settings(definition),
|
|
227
|
+
bpm: bpm_setting(definition),
|
|
228
|
+
bpm_lock: bpm_lock_setting(definition)
|
|
229
|
+
)
|
|
133
230
|
broadcaster.update_scene(scene_name: scene[:name], scene_layers: scene[:layers])
|
|
134
231
|
on_reload&.call(definition)
|
|
135
232
|
WebSocketHandler.broadcast(
|
|
136
233
|
type: "config_update",
|
|
137
234
|
payload: {
|
|
138
235
|
scene: scene,
|
|
139
|
-
scenes: scene_names_for(definition)
|
|
236
|
+
scenes: scene_names_for(definition),
|
|
237
|
+
tap_tempo_key: @tap_tempo_key,
|
|
238
|
+
key_mappings: key_mappings_for(definition),
|
|
239
|
+
globals: runtime_globals_snapshot
|
|
140
240
|
}
|
|
141
241
|
)
|
|
142
242
|
@output.puts("Scene reloaded: #{scene[:name]}")
|
|
@@ -216,6 +316,53 @@ module Vizcore
|
|
|
216
316
|
nil
|
|
217
317
|
end
|
|
218
318
|
|
|
319
|
+
def start_osc_runtime(broadcaster)
|
|
320
|
+
return nil unless @config.osc_port
|
|
321
|
+
|
|
322
|
+
receiver = Vizcore::Sync::OscReceiver.new(
|
|
323
|
+
host: @config.host,
|
|
324
|
+
port: @config.osc_port,
|
|
325
|
+
handler: ->(message) { handle_osc_message(message, broadcaster) },
|
|
326
|
+
error_reporter: ->(message) { @output.puts(message) }
|
|
327
|
+
)
|
|
328
|
+
receiver.start
|
|
329
|
+
rescue StandardError => e
|
|
330
|
+
@output.puts(Vizcore::ErrorFormatting.summarize(e, context: "OSC runtime disabled"))
|
|
331
|
+
receiver&.stop
|
|
332
|
+
nil
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
def stop_osc_runtime(runtime)
|
|
336
|
+
runtime&.stop
|
|
337
|
+
nil
|
|
338
|
+
rescue StandardError => e
|
|
339
|
+
@output.puts(Vizcore::ErrorFormatting.summarize(e, context: "OSC runtime shutdown failed"))
|
|
340
|
+
nil
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
def handle_osc_message(message, broadcaster)
|
|
344
|
+
case message.address
|
|
345
|
+
when "/vizcore/scene"
|
|
346
|
+
switch_scene_from_client(message.arguments.first, broadcaster, source: "osc")
|
|
347
|
+
when "/vizcore/tap"
|
|
348
|
+
apply_tap_tempo({ "client_tapped_at_ms" => wall_clock_ms }, broadcaster)
|
|
349
|
+
when "/vizcore/bpm"
|
|
350
|
+
apply_osc_bpm(message.arguments.first, broadcaster)
|
|
351
|
+
when "/vizcore/bpm_unlock"
|
|
352
|
+
apply_osc_bpm_unlock(broadcaster)
|
|
353
|
+
when %r{\A/vizcore/global/([^/]+)\z}
|
|
354
|
+
apply_osc_global(Regexp.last_match(1), message.arguments.first)
|
|
355
|
+
when %r{\A/vizcore/live/(blackout|freeze)\z}
|
|
356
|
+
apply_osc_live_control(Regexp.last_match(1), message.arguments.first)
|
|
357
|
+
when "/vizcore/transport/play", "/vizcore/transport/position"
|
|
358
|
+
apply_osc_transport(broadcaster, playing: true, position_seconds: message.arguments.first)
|
|
359
|
+
when "/vizcore/transport/stop"
|
|
360
|
+
apply_osc_transport(broadcaster, playing: false, position_seconds: message.arguments.first)
|
|
361
|
+
end
|
|
362
|
+
rescue StandardError => e
|
|
363
|
+
@output.puts(Vizcore::ErrorFormatting.summarize(e, context: "OSC control message failed"))
|
|
364
|
+
end
|
|
365
|
+
|
|
219
366
|
def handle_midi_event(executor, event, broadcaster)
|
|
220
367
|
actions = executor.handle_event(event)
|
|
221
368
|
actions.each do |action|
|
|
@@ -226,17 +373,19 @@ module Vizcore
|
|
|
226
373
|
end
|
|
227
374
|
|
|
228
375
|
def register_client_message_handler(broadcaster)
|
|
229
|
-
Vizcore::Server::WebSocketHandler.on_message do |message|
|
|
230
|
-
handle_client_message(message, broadcaster)
|
|
376
|
+
Vizcore::Server::WebSocketHandler.on_message do |message, socket|
|
|
377
|
+
handle_client_message(message, broadcaster, socket)
|
|
231
378
|
end
|
|
232
379
|
end
|
|
233
380
|
|
|
234
|
-
def handle_client_message(message, broadcaster)
|
|
381
|
+
def handle_client_message(message, broadcaster, socket = nil)
|
|
235
382
|
type = message["type"] || message[:type]
|
|
236
383
|
payload = message["payload"] || message[:payload]
|
|
237
384
|
case type.to_s
|
|
385
|
+
when "latency_probe"
|
|
386
|
+
respond_to_latency_probe(socket, payload)
|
|
238
387
|
when "transport_sync"
|
|
239
|
-
return unless
|
|
388
|
+
return unless file_transport_enabled?
|
|
240
389
|
|
|
241
390
|
values = Hash(payload)
|
|
242
391
|
broadcaster.sync_transport(
|
|
@@ -247,11 +396,28 @@ module Vizcore
|
|
|
247
396
|
values = Hash(payload)
|
|
248
397
|
target_name = values.fetch("scene", values.fetch(:scene, values.fetch("scene_name", values.fetch(:scene_name, nil))))
|
|
249
398
|
switch_scene_from_client(target_name, broadcaster)
|
|
399
|
+
when "tap_tempo"
|
|
400
|
+
apply_tap_tempo(payload, broadcaster)
|
|
250
401
|
end
|
|
251
402
|
rescue StandardError => e
|
|
252
403
|
@output.puts(Vizcore::ErrorFormatting.summarize(e, context: "Client control message failed"))
|
|
253
404
|
end
|
|
254
405
|
|
|
406
|
+
def respond_to_latency_probe(socket, payload)
|
|
407
|
+
return unless socket
|
|
408
|
+
|
|
409
|
+
received_at_ms = wall_clock_ms
|
|
410
|
+
values = Hash(payload)
|
|
411
|
+
response = {
|
|
412
|
+
server_received_at_ms: received_at_ms,
|
|
413
|
+
server_sent_at_ms: wall_clock_ms
|
|
414
|
+
}
|
|
415
|
+
client_sent_at_ms = finite_float(values["client_sent_at_ms"] || values[:client_sent_at_ms])
|
|
416
|
+
response[:client_sent_at_ms] = client_sent_at_ms if client_sent_at_ms
|
|
417
|
+
|
|
418
|
+
WebSocketHandler.send_to(socket, type: "latency_probe", payload: response)
|
|
419
|
+
end
|
|
420
|
+
|
|
255
421
|
def apply_midi_action(action, executor, broadcaster)
|
|
256
422
|
case action[:type]
|
|
257
423
|
when :switch_scene
|
|
@@ -287,11 +453,59 @@ module Vizcore
|
|
|
287
453
|
enabled: !Array(definition[:midi_maps]).empty?,
|
|
288
454
|
midi_maps: Array(definition[:midi_maps]),
|
|
289
455
|
scenes: Array(definition[:scenes]),
|
|
290
|
-
globals:
|
|
456
|
+
globals: globals_for(definition),
|
|
291
457
|
device: midi_inputs.first&.dig(:options, :device)
|
|
292
458
|
}
|
|
293
459
|
end
|
|
294
460
|
|
|
461
|
+
def globals_for(definition)
|
|
462
|
+
Hash(definition[:globals] || {})
|
|
463
|
+
rescue StandardError
|
|
464
|
+
{}
|
|
465
|
+
end
|
|
466
|
+
|
|
467
|
+
def replace_runtime_globals(values)
|
|
468
|
+
@runtime_globals_mutex.synchronize do
|
|
469
|
+
@runtime_globals = normalize_runtime_globals(values)
|
|
470
|
+
end
|
|
471
|
+
end
|
|
472
|
+
|
|
473
|
+
def set_runtime_global(name, value)
|
|
474
|
+
key = name.to_s.strip
|
|
475
|
+
return runtime_globals_snapshot if key.empty?
|
|
476
|
+
|
|
477
|
+
@runtime_globals_mutex.synchronize do
|
|
478
|
+
@runtime_globals[key] = value
|
|
479
|
+
@runtime_globals.dup
|
|
480
|
+
end
|
|
481
|
+
end
|
|
482
|
+
|
|
483
|
+
def runtime_globals_snapshot
|
|
484
|
+
@runtime_globals_mutex.synchronize { @runtime_globals.dup }
|
|
485
|
+
end
|
|
486
|
+
|
|
487
|
+
def normalize_runtime_globals(values)
|
|
488
|
+
Hash(values || {}).each_with_object({}) do |(key, value), output|
|
|
489
|
+
name = key.to_s.strip
|
|
490
|
+
output[name] = value unless name.empty?
|
|
491
|
+
end
|
|
492
|
+
rescue StandardError
|
|
493
|
+
{}
|
|
494
|
+
end
|
|
495
|
+
|
|
496
|
+
def key_mappings_for(definition)
|
|
497
|
+
Array(definition[:key_mappings]).map do |mapping|
|
|
498
|
+
key = mapping[:key] || mapping["key"]
|
|
499
|
+
action = mapping[:action] || mapping["action"]
|
|
500
|
+
{
|
|
501
|
+
key: key.to_s,
|
|
502
|
+
action: action
|
|
503
|
+
}
|
|
504
|
+
end
|
|
505
|
+
rescue StandardError
|
|
506
|
+
[]
|
|
507
|
+
end
|
|
508
|
+
|
|
295
509
|
def resolve_shader_sources(definition)
|
|
296
510
|
@shader_source_resolver.resolve(definition: definition, scene_file: @config.scene_file.to_s)
|
|
297
511
|
end
|
|
@@ -316,7 +530,128 @@ module Vizcore
|
|
|
316
530
|
[]
|
|
317
531
|
end
|
|
318
532
|
|
|
319
|
-
def
|
|
533
|
+
def audio_normalize_settings(definition)
|
|
534
|
+
Hash(definition[:analysis] || {})[:audio_normalize]
|
|
535
|
+
rescue StandardError
|
|
536
|
+
nil
|
|
537
|
+
end
|
|
538
|
+
|
|
539
|
+
def bpm_setting(definition)
|
|
540
|
+
@config.bpm || Hash(definition[:analysis] || {})[:bpm]
|
|
541
|
+
rescue StandardError
|
|
542
|
+
@config.bpm
|
|
543
|
+
end
|
|
544
|
+
|
|
545
|
+
def bpm_lock_setting(definition)
|
|
546
|
+
@config.bpm_lock? || !!Hash(definition[:analysis] || {})[:bpm_lock]
|
|
547
|
+
rescue StandardError
|
|
548
|
+
@config.bpm_lock?
|
|
549
|
+
end
|
|
550
|
+
|
|
551
|
+
def tap_tempo_key(definition)
|
|
552
|
+
settings = Hash(definition.dig(:analysis, :tap_tempo) || {})
|
|
553
|
+
key = settings[:key] || settings["key"]
|
|
554
|
+
key.to_s unless key.nil? || key.to_s.empty?
|
|
555
|
+
rescue StandardError
|
|
556
|
+
nil
|
|
557
|
+
end
|
|
558
|
+
|
|
559
|
+
def apply_tap_tempo(payload, broadcaster)
|
|
560
|
+
return unless @tap_tempo_key
|
|
561
|
+
|
|
562
|
+
values = Hash(payload)
|
|
563
|
+
tapped_at_ms = finite_float(values["client_tapped_at_ms"] || values[:client_tapped_at_ms]) || wall_clock_ms
|
|
564
|
+
bpm = broadcaster.tap_tempo(timestamp_ms: tapped_at_ms)
|
|
565
|
+
return unless bpm
|
|
566
|
+
|
|
567
|
+
WebSocketHandler.broadcast(
|
|
568
|
+
type: "config_update",
|
|
569
|
+
payload: {
|
|
570
|
+
bpm: bpm,
|
|
571
|
+
bpm_lock: true,
|
|
572
|
+
source: "tap_tempo"
|
|
573
|
+
}
|
|
574
|
+
)
|
|
575
|
+
end
|
|
576
|
+
|
|
577
|
+
def apply_osc_bpm(value, broadcaster)
|
|
578
|
+
bpm = finite_float(value)
|
|
579
|
+
return unless bpm&.positive?
|
|
580
|
+
return unless broadcaster.respond_to?(:lock_bpm)
|
|
581
|
+
|
|
582
|
+
locked_bpm = broadcaster.lock_bpm(bpm)
|
|
583
|
+
return unless locked_bpm
|
|
584
|
+
|
|
585
|
+
WebSocketHandler.broadcast(
|
|
586
|
+
type: "config_update",
|
|
587
|
+
payload: {
|
|
588
|
+
bpm: locked_bpm,
|
|
589
|
+
bpm_lock: true,
|
|
590
|
+
source: "osc"
|
|
591
|
+
}
|
|
592
|
+
)
|
|
593
|
+
end
|
|
594
|
+
|
|
595
|
+
def apply_osc_bpm_unlock(broadcaster)
|
|
596
|
+
return unless broadcaster.respond_to?(:unlock_bpm)
|
|
597
|
+
|
|
598
|
+
broadcaster.unlock_bpm
|
|
599
|
+
WebSocketHandler.broadcast(
|
|
600
|
+
type: "config_update",
|
|
601
|
+
payload: {
|
|
602
|
+
bpm_lock: false,
|
|
603
|
+
source: "osc"
|
|
604
|
+
}
|
|
605
|
+
)
|
|
606
|
+
end
|
|
607
|
+
|
|
608
|
+
def apply_osc_global(name, value)
|
|
609
|
+
globals = set_runtime_global(name, normalize_osc_value(value))
|
|
610
|
+
WebSocketHandler.broadcast(
|
|
611
|
+
type: "config_update",
|
|
612
|
+
payload: {
|
|
613
|
+
globals: globals,
|
|
614
|
+
source: "osc"
|
|
615
|
+
}
|
|
616
|
+
)
|
|
617
|
+
end
|
|
618
|
+
|
|
619
|
+
def apply_osc_live_control(control, value)
|
|
620
|
+
@live_controls[control] = osc_truthy?(value)
|
|
621
|
+
WebSocketHandler.broadcast(
|
|
622
|
+
type: "config_update",
|
|
623
|
+
payload: {
|
|
624
|
+
live_controls: @live_controls.dup,
|
|
625
|
+
source: "osc"
|
|
626
|
+
}
|
|
627
|
+
)
|
|
628
|
+
end
|
|
629
|
+
|
|
630
|
+
def apply_osc_transport(broadcaster, playing:, position_seconds:)
|
|
631
|
+
return unless file_transport_enabled?
|
|
632
|
+
|
|
633
|
+
broadcaster.sync_transport(
|
|
634
|
+
playing: playing,
|
|
635
|
+
position_seconds: finite_float(position_seconds) || 0.0
|
|
636
|
+
)
|
|
637
|
+
end
|
|
638
|
+
|
|
639
|
+
def normalize_osc_value(value)
|
|
640
|
+
numeric = finite_float(value)
|
|
641
|
+
numeric.nil? ? value : numeric
|
|
642
|
+
end
|
|
643
|
+
|
|
644
|
+
def osc_truthy?(value)
|
|
645
|
+
return true if value.nil?
|
|
646
|
+
return value if value == true || value == false
|
|
647
|
+
|
|
648
|
+
numeric = finite_float(value)
|
|
649
|
+
return numeric.positive? unless numeric.nil?
|
|
650
|
+
|
|
651
|
+
%w[true on yes 1].include?(value.to_s.strip.downcase)
|
|
652
|
+
end
|
|
653
|
+
|
|
654
|
+
def switch_scene_from_client(target_name, broadcaster, source: "ui")
|
|
320
655
|
requested = target_name.to_s.strip
|
|
321
656
|
return if requested.empty?
|
|
322
657
|
|
|
@@ -332,7 +667,7 @@ module Vizcore
|
|
|
332
667
|
from: from_scene.to_s,
|
|
333
668
|
to: target_scene[:name].to_s,
|
|
334
669
|
effect: nil,
|
|
335
|
-
source:
|
|
670
|
+
source: source
|
|
336
671
|
}
|
|
337
672
|
)
|
|
338
673
|
end
|
|
@@ -352,6 +687,19 @@ module Vizcore
|
|
|
352
687
|
rescue StandardError
|
|
353
688
|
nil
|
|
354
689
|
end
|
|
690
|
+
|
|
691
|
+
def wall_clock_ms
|
|
692
|
+
Time.now.to_f * 1000.0
|
|
693
|
+
end
|
|
694
|
+
|
|
695
|
+
def finite_float(value)
|
|
696
|
+
numeric = Float(value)
|
|
697
|
+
return nil unless numeric.finite?
|
|
698
|
+
|
|
699
|
+
numeric
|
|
700
|
+
rescue StandardError
|
|
701
|
+
nil
|
|
702
|
+
end
|
|
355
703
|
end
|
|
356
704
|
end
|
|
357
705
|
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pathname"
|
|
4
|
+
require_relative "../dsl"
|
|
5
|
+
|
|
6
|
+
module Vizcore
|
|
7
|
+
module Server
|
|
8
|
+
# Watches a scene file and the custom GLSL files referenced by that scene.
|
|
9
|
+
class SceneDependencyWatcher
|
|
10
|
+
# @param scene_file [String, Pathname]
|
|
11
|
+
# @param definition [Hash] current scene definition
|
|
12
|
+
# @param watcher_factory [Class] file watcher class or compatible factory
|
|
13
|
+
# @yieldparam definition [Hash] reloaded scene definition
|
|
14
|
+
# @yieldparam changed_path [Pathname] changed scene or shader file
|
|
15
|
+
def initialize(scene_file:, definition:, watcher_factory: Vizcore::DSL::FileWatcher, &on_change)
|
|
16
|
+
@scene_file = Pathname.new(scene_file.to_s).expand_path
|
|
17
|
+
@definition = definition
|
|
18
|
+
@watcher_factory = watcher_factory
|
|
19
|
+
@on_change = on_change
|
|
20
|
+
@scene_watcher = nil
|
|
21
|
+
@shader_watchers = []
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# @return [Vizcore::Server::SceneDependencyWatcher]
|
|
25
|
+
def start
|
|
26
|
+
@scene_watcher = build_watcher(@scene_file)
|
|
27
|
+
@scene_watcher.start
|
|
28
|
+
refresh_shader_watchers(@definition)
|
|
29
|
+
self
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# @param timeout [Float]
|
|
33
|
+
# @return [void]
|
|
34
|
+
def stop(timeout: 1.0)
|
|
35
|
+
@scene_watcher&.stop(timeout: timeout)
|
|
36
|
+
stop_shader_watchers(timeout: timeout)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def build_watcher(path)
|
|
42
|
+
@watcher_factory.new(path: path) do |changed_path|
|
|
43
|
+
definition = Vizcore::DSL::Engine.load_file(@scene_file.to_s)
|
|
44
|
+
@on_change&.call(definition, changed_path)
|
|
45
|
+
@definition = definition
|
|
46
|
+
refresh_shader_watchers(definition)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def refresh_shader_watchers(definition)
|
|
51
|
+
paths = shader_paths(definition)
|
|
52
|
+
stop_shader_watchers
|
|
53
|
+
@shader_watchers = paths.map do |path|
|
|
54
|
+
build_watcher(path).tap(&:start)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def stop_shader_watchers(timeout: 1.0)
|
|
59
|
+
@shader_watchers.each { |watcher| watcher.stop(timeout: timeout) }
|
|
60
|
+
@shader_watchers = []
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def shader_paths(definition)
|
|
64
|
+
Array(definition[:scenes]).flat_map do |scene|
|
|
65
|
+
Array(scene[:layers]).filter_map { |layer| shader_path(layer) }
|
|
66
|
+
end.uniq
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def shader_path(layer)
|
|
70
|
+
glsl = layer[:glsl] || layer["glsl"]
|
|
71
|
+
return nil unless glsl
|
|
72
|
+
|
|
73
|
+
path = Pathname.new(glsl.to_s)
|
|
74
|
+
path = @scene_file.dirname.join(path) unless path.absolute?
|
|
75
|
+
path.expand_path
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|