vizcore 0.1.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (137) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +70 -117
  3. data/docs/.nojekyll +0 -0
  4. data/docs/assets/playground-worker.js +373 -0
  5. data/docs/assets/playground.css +440 -0
  6. data/docs/assets/playground.js +652 -0
  7. data/docs/assets/site.css +744 -0
  8. data/docs/assets/vizcore-demo.gif +0 -0
  9. data/docs/assets/vizcore-poster.png +0 -0
  10. data/docs/assets/vj-tunnel.js +159 -0
  11. data/docs/index.html +225 -0
  12. data/docs/playground.html +81 -0
  13. data/docs/shape_dsl.md +269 -0
  14. data/examples/README.md +59 -0
  15. data/examples/assets/README.md +19 -0
  16. data/examples/audio_inspector.rb +34 -0
  17. data/examples/club_intro_drop.rb +78 -0
  18. data/examples/kansai_rubykaigi_visual.rb +70 -0
  19. data/examples/live_coding_minimal.rb +22 -0
  20. data/examples/midi_controller_show.rb +78 -0
  21. data/examples/midi_scene_switch.rb +3 -1
  22. data/examples/parser_visualizer.rb +48 -0
  23. data/examples/readme_demo.rb +17 -0
  24. data/examples/rhythm_geometry.rb +34 -0
  25. data/examples/ruby_crystal_show.rb +35 -0
  26. data/examples/shader_playground.rb +18 -0
  27. data/examples/unyo_liquid.rb +59 -0
  28. data/examples/vj_ambient_chill_room.rb +124 -0
  29. data/examples/vj_dnb_jungle.rb +170 -0
  30. data/examples/vj_festival_mainstage.rb +245 -0
  31. data/examples/vj_festival_mainstage.yml +17 -0
  32. data/examples/vj_glitch_industrial.rb +164 -0
  33. data/examples/vj_hiphop_cipher.rb +167 -0
  34. data/examples/vj_jpop_idol_live.rb +210 -0
  35. data/examples/vj_synthwave_retro.rb +173 -0
  36. data/examples/vj_techno_warehouse.rb +195 -0
  37. data/frontend/index.html +494 -2
  38. data/frontend/src/audio-inspector.js +40 -0
  39. data/frontend/src/custom-shape-param-controls.js +106 -0
  40. data/frontend/src/live-controls.js +131 -0
  41. data/frontend/src/main.js +1060 -16
  42. data/frontend/src/mapping-target-selector.js +109 -0
  43. data/frontend/src/midi-learn.js +194 -0
  44. data/frontend/src/performance-monitor.js +183 -0
  45. data/frontend/src/plugin-runtime.js +130 -0
  46. data/frontend/src/projector-mode.js +56 -0
  47. data/frontend/src/renderer/engine.js +157 -3
  48. data/frontend/src/renderer/layer-manager.js +442 -30
  49. data/frontend/src/renderer/shader-manager.js +26 -0
  50. data/frontend/src/runtime-control-preset.js +11 -0
  51. data/frontend/src/shader-error-overlay.js +29 -0
  52. data/frontend/src/shader-param-controls.js +93 -0
  53. data/frontend/src/shaders/builtins.js +380 -2
  54. data/frontend/src/shaders/post-effects.js +52 -0
  55. data/frontend/src/shape-editor-controls.js +157 -0
  56. data/frontend/src/visual-regression.js +67 -0
  57. data/frontend/src/visual-settings-preset.js +103 -0
  58. data/frontend/src/visuals/geometry.js +666 -0
  59. data/frontend/src/visuals/image-renderer.js +291 -0
  60. data/frontend/src/visuals/particle-system.js +56 -10
  61. data/frontend/src/visuals/shape-renderer.js +475 -0
  62. data/frontend/src/visuals/spectrogram-renderer.js +226 -0
  63. data/frontend/src/visuals/svg-arc.js +104 -0
  64. data/frontend/src/visuals/text-renderer.js +112 -11
  65. data/frontend/src/websocket-client.js +12 -1
  66. data/lib/vizcore/analysis/adaptive_normalizer.rb +70 -0
  67. data/lib/vizcore/analysis/beat_detector.rb +4 -2
  68. data/lib/vizcore/analysis/bpm_estimator.rb +8 -0
  69. data/lib/vizcore/analysis/feature_recorder.rb +159 -0
  70. data/lib/vizcore/analysis/feature_replay.rb +84 -0
  71. data/lib/vizcore/analysis/pipeline.rb +235 -11
  72. data/lib/vizcore/analysis/tap_tempo.rb +74 -0
  73. data/lib/vizcore/analysis.rb +4 -0
  74. data/lib/vizcore/audio/dummy_sine_input.rb +1 -1
  75. data/lib/vizcore/audio/fixture_input.rb +65 -0
  76. data/lib/vizcore/audio/input_manager.rb +4 -2
  77. data/lib/vizcore/audio/mic_input.rb +24 -8
  78. data/lib/vizcore/audio/portaudio_ffi.rb +106 -1
  79. data/lib/vizcore/audio.rb +1 -0
  80. data/lib/vizcore/cli/doctor.rb +159 -0
  81. data/lib/vizcore/cli/dsl_reference.rb +99 -0
  82. data/lib/vizcore/cli/layer_docs.rb +46 -0
  83. data/lib/vizcore/cli/scene_diagnostics.rb +23 -0
  84. data/lib/vizcore/cli/scene_inspector.rb +136 -0
  85. data/lib/vizcore/cli/scene_validator.rb +337 -0
  86. data/lib/vizcore/cli/shader_template.rb +68 -0
  87. data/lib/vizcore/cli/shader_uniform_docs.rb +54 -0
  88. data/lib/vizcore/cli.rb +689 -18
  89. data/lib/vizcore/config.rb +103 -2
  90. data/lib/vizcore/control_preset.rb +68 -0
  91. data/lib/vizcore/dsl/engine.rb +277 -5
  92. data/lib/vizcore/dsl/layer_builder.rb +1280 -23
  93. data/lib/vizcore/dsl/layer_group_builder.rb +112 -0
  94. data/lib/vizcore/dsl/mapping_resolver.rb +290 -7
  95. data/lib/vizcore/dsl/mapping_transform_builder.rb +71 -0
  96. data/lib/vizcore/dsl/reaction_builder.rb +44 -0
  97. data/lib/vizcore/dsl/scene_builder.rb +61 -5
  98. data/lib/vizcore/dsl/shader_source_resolver.rb +67 -6
  99. data/lib/vizcore/dsl/style_builder.rb +68 -0
  100. data/lib/vizcore/dsl/timeline_builder.rb +138 -0
  101. data/lib/vizcore/dsl/transition_controller.rb +77 -0
  102. data/lib/vizcore/dsl.rb +5 -1
  103. data/lib/vizcore/layer_catalog.rb +275 -0
  104. data/lib/vizcore/project_manifest.rb +152 -0
  105. data/lib/vizcore/renderer/png_writer.rb +57 -0
  106. data/lib/vizcore/renderer/render_sequence.rb +153 -0
  107. data/lib/vizcore/renderer/scene_frame_source.rb +132 -0
  108. data/lib/vizcore/renderer/scene_serializer.rb +36 -3
  109. data/lib/vizcore/renderer/snapshot.rb +38 -0
  110. data/lib/vizcore/renderer/snapshot_renderer.rb +938 -0
  111. data/lib/vizcore/renderer.rb +5 -0
  112. data/lib/vizcore/server/frame_broadcaster.rb +143 -8
  113. data/lib/vizcore/server/gallery_app.rb +155 -0
  114. data/lib/vizcore/server/gallery_page.rb +100 -0
  115. data/lib/vizcore/server/gallery_runner.rb +48 -0
  116. data/lib/vizcore/server/rack_app.rb +203 -4
  117. data/lib/vizcore/server/runner.rb +391 -22
  118. data/lib/vizcore/server/scene_dependency_watcher.rb +79 -0
  119. data/lib/vizcore/server/websocket_handler.rb +60 -10
  120. data/lib/vizcore/server.rb +4 -0
  121. data/lib/vizcore/shape.rb +719 -0
  122. data/lib/vizcore/sync/osc_message.rb +103 -0
  123. data/lib/vizcore/sync/osc_receiver.rb +68 -0
  124. data/lib/vizcore/sync.rb +4 -0
  125. data/lib/vizcore/templates/midi_control_scene.rb +3 -1
  126. data/lib/vizcore/templates/plugin_layer.rb +20 -0
  127. data/lib/vizcore/templates/plugin_readme.md +23 -0
  128. data/lib/vizcore/templates/plugin_renderer.js +43 -0
  129. data/lib/vizcore/templates/plugin_scene.rb +14 -0
  130. data/lib/vizcore/templates/project_readme.md +7 -23
  131. data/lib/vizcore/templates/rubykaigi_scene.rb +30 -0
  132. data/lib/vizcore/version.rb +1 -1
  133. data/lib/vizcore.rb +28 -0
  134. data/scripts/browser_capture.mjs +75 -0
  135. data/sig/vizcore.rbs +461 -0
  136. metadata +94 -3
  137. data/docs/GETTING_STARTED.md +0 -105
@@ -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: @config.audio_source,
39
- audio_file: @config.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 = Vizcore::Audio::InputManager.new(
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 @config.audio_source == :file
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
- watcher = start_scene_watcher(broadcaster) do |updated_definition|
66
- midi_runtime = refresh_midi_runtime(midi_runtime, updated_definition, broadcaster)
67
- end
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("Audio playback: http://#{@config.host}:#{@config.port}/audio-file") if @config.audio_source == :file
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::DSL::Engine.watch_file(@config.scene_file.to_s) do |definition, _changed_path|
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 @config.audio_source == :file
388
+ return unless file_transport_enabled?
240
389
 
241
390
  values = Hash(payload)
242
391
  broadcaster.sync_transport(
@@ -247,11 +396,30 @@ 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)
401
+ when "custom_shape_param"
402
+ apply_custom_shape_param(payload, broadcaster)
250
403
  end
251
404
  rescue StandardError => e
252
405
  @output.puts(Vizcore::ErrorFormatting.summarize(e, context: "Client control message failed"))
253
406
  end
254
407
 
408
+ def respond_to_latency_probe(socket, payload)
409
+ return unless socket
410
+
411
+ received_at_ms = wall_clock_ms
412
+ values = Hash(payload)
413
+ response = {
414
+ server_received_at_ms: received_at_ms,
415
+ server_sent_at_ms: wall_clock_ms
416
+ }
417
+ client_sent_at_ms = finite_float(values["client_sent_at_ms"] || values[:client_sent_at_ms])
418
+ response[:client_sent_at_ms] = client_sent_at_ms if client_sent_at_ms
419
+
420
+ WebSocketHandler.send_to(socket, type: "latency_probe", payload: response)
421
+ end
422
+
255
423
  def apply_midi_action(action, executor, broadcaster)
256
424
  case action[:type]
257
425
  when :switch_scene
@@ -287,11 +455,59 @@ module Vizcore
287
455
  enabled: !Array(definition[:midi_maps]).empty?,
288
456
  midi_maps: Array(definition[:midi_maps]),
289
457
  scenes: Array(definition[:scenes]),
290
- globals: Hash(definition[:globals] || {}),
458
+ globals: globals_for(definition),
291
459
  device: midi_inputs.first&.dig(:options, :device)
292
460
  }
293
461
  end
294
462
 
463
+ def globals_for(definition)
464
+ Hash(definition[:globals] || {})
465
+ rescue StandardError
466
+ {}
467
+ end
468
+
469
+ def replace_runtime_globals(values)
470
+ @runtime_globals_mutex.synchronize do
471
+ @runtime_globals = normalize_runtime_globals(values)
472
+ end
473
+ end
474
+
475
+ def set_runtime_global(name, value)
476
+ key = name.to_s.strip
477
+ return runtime_globals_snapshot if key.empty?
478
+
479
+ @runtime_globals_mutex.synchronize do
480
+ @runtime_globals[key] = value
481
+ @runtime_globals.dup
482
+ end
483
+ end
484
+
485
+ def runtime_globals_snapshot
486
+ @runtime_globals_mutex.synchronize { @runtime_globals.dup }
487
+ end
488
+
489
+ def normalize_runtime_globals(values)
490
+ Hash(values || {}).each_with_object({}) do |(key, value), output|
491
+ name = key.to_s.strip
492
+ output[name] = value unless name.empty?
493
+ end
494
+ rescue StandardError
495
+ {}
496
+ end
497
+
498
+ def key_mappings_for(definition)
499
+ Array(definition[:key_mappings]).map do |mapping|
500
+ key = mapping[:key] || mapping["key"]
501
+ action = mapping[:action] || mapping["action"]
502
+ {
503
+ key: key.to_s,
504
+ action: action
505
+ }
506
+ end
507
+ rescue StandardError
508
+ []
509
+ end
510
+
295
511
  def resolve_shader_sources(definition)
296
512
  @shader_source_resolver.resolve(definition: definition, scene_file: @config.scene_file.to_s)
297
513
  end
@@ -316,7 +532,147 @@ module Vizcore
316
532
  []
317
533
  end
318
534
 
319
- def switch_scene_from_client(target_name, broadcaster)
535
+ def audio_normalize_settings(definition)
536
+ Hash(definition[:analysis] || {})[:audio_normalize]
537
+ rescue StandardError
538
+ nil
539
+ end
540
+
541
+ def bpm_setting(definition)
542
+ @config.bpm || Hash(definition[:analysis] || {})[:bpm]
543
+ rescue StandardError
544
+ @config.bpm
545
+ end
546
+
547
+ def bpm_lock_setting(definition)
548
+ @config.bpm_lock? || !!Hash(definition[:analysis] || {})[:bpm_lock]
549
+ rescue StandardError
550
+ @config.bpm_lock?
551
+ end
552
+
553
+ def tap_tempo_key(definition)
554
+ settings = Hash(definition.dig(:analysis, :tap_tempo) || {})
555
+ key = settings[:key] || settings["key"]
556
+ key.to_s unless key.nil? || key.to_s.empty?
557
+ rescue StandardError
558
+ nil
559
+ end
560
+
561
+ def apply_tap_tempo(payload, broadcaster)
562
+ return unless @tap_tempo_key
563
+
564
+ values = Hash(payload)
565
+ tapped_at_ms = finite_float(values["client_tapped_at_ms"] || values[:client_tapped_at_ms]) || wall_clock_ms
566
+ bpm = broadcaster.tap_tempo(timestamp_ms: tapped_at_ms)
567
+ return unless bpm
568
+
569
+ WebSocketHandler.broadcast(
570
+ type: "config_update",
571
+ payload: {
572
+ bpm: bpm,
573
+ bpm_lock: true,
574
+ source: "tap_tempo"
575
+ }
576
+ )
577
+ end
578
+
579
+ def apply_osc_bpm(value, broadcaster)
580
+ bpm = finite_float(value)
581
+ return unless bpm&.positive?
582
+ return unless broadcaster.respond_to?(:lock_bpm)
583
+
584
+ locked_bpm = broadcaster.lock_bpm(bpm)
585
+ return unless locked_bpm
586
+
587
+ WebSocketHandler.broadcast(
588
+ type: "config_update",
589
+ payload: {
590
+ bpm: locked_bpm,
591
+ bpm_lock: true,
592
+ source: "osc"
593
+ }
594
+ )
595
+ end
596
+
597
+ def apply_osc_bpm_unlock(broadcaster)
598
+ return unless broadcaster.respond_to?(:unlock_bpm)
599
+
600
+ broadcaster.unlock_bpm
601
+ WebSocketHandler.broadcast(
602
+ type: "config_update",
603
+ payload: {
604
+ bpm_lock: false,
605
+ source: "osc"
606
+ }
607
+ )
608
+ end
609
+
610
+ def apply_osc_global(name, value)
611
+ globals = set_runtime_global(name, normalize_osc_value(value))
612
+ WebSocketHandler.broadcast(
613
+ type: "config_update",
614
+ payload: {
615
+ globals: globals,
616
+ source: "osc"
617
+ }
618
+ )
619
+ end
620
+
621
+ def apply_custom_shape_param(payload, broadcaster)
622
+ return unless broadcaster.respond_to?(:set_custom_shape_param)
623
+
624
+ values = Hash(payload)
625
+ overrides = broadcaster.set_custom_shape_param(
626
+ layer_name: values["layer"] || values[:layer] || values["layer_name"] || values[:layer_name],
627
+ custom_shape_index: values["custom_shape_index"] || values[:custom_shape_index] || values["index"] || values[:index],
628
+ param: values["param"] || values[:param],
629
+ value: values["value"] || values[:value]
630
+ )
631
+ WebSocketHandler.broadcast(
632
+ type: "config_update",
633
+ payload: {
634
+ custom_shape_params: overrides,
635
+ source: "ui"
636
+ }
637
+ )
638
+ end
639
+
640
+ def apply_osc_live_control(control, value)
641
+ @live_controls[control] = osc_truthy?(value)
642
+ WebSocketHandler.broadcast(
643
+ type: "config_update",
644
+ payload: {
645
+ live_controls: @live_controls.dup,
646
+ source: "osc"
647
+ }
648
+ )
649
+ end
650
+
651
+ def apply_osc_transport(broadcaster, playing:, position_seconds:)
652
+ return unless file_transport_enabled?
653
+
654
+ broadcaster.sync_transport(
655
+ playing: playing,
656
+ position_seconds: finite_float(position_seconds) || 0.0
657
+ )
658
+ end
659
+
660
+ def normalize_osc_value(value)
661
+ numeric = finite_float(value)
662
+ numeric.nil? ? value : numeric
663
+ end
664
+
665
+ def osc_truthy?(value)
666
+ return true if value.nil?
667
+ return value if value == true || value == false
668
+
669
+ numeric = finite_float(value)
670
+ return numeric.positive? unless numeric.nil?
671
+
672
+ %w[true on yes 1].include?(value.to_s.strip.downcase)
673
+ end
674
+
675
+ def switch_scene_from_client(target_name, broadcaster, source: "ui")
320
676
  requested = target_name.to_s.strip
321
677
  return if requested.empty?
322
678
 
@@ -332,7 +688,7 @@ module Vizcore
332
688
  from: from_scene.to_s,
333
689
  to: target_scene[:name].to_s,
334
690
  effect: nil,
335
- source: "ui"
691
+ source: source
336
692
  }
337
693
  )
338
694
  end
@@ -352,6 +708,19 @@ module Vizcore
352
708
  rescue StandardError
353
709
  nil
354
710
  end
711
+
712
+ def wall_clock_ms
713
+ Time.now.to_f * 1000.0
714
+ end
715
+
716
+ def finite_float(value)
717
+ numeric = Float(value)
718
+ return nil unless numeric.finite?
719
+
720
+ numeric
721
+ rescue StandardError
722
+ nil
723
+ end
355
724
  end
356
725
  end
357
726
  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