vizcore 0.1.0 → 1.0.0

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