vizcore 1.1.0 → 1.2.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 (78) hide show
  1. checksums.yaml +4 -4
  2. data/frontend/index.html +24 -2
  3. data/frontend/src/audio-inspector.js +9 -0
  4. data/frontend/src/live-controls.js +219 -7
  5. data/frontend/src/main.js +447 -57
  6. data/frontend/src/midi-learn.js +22 -2
  7. data/frontend/src/performance-monitor.js +137 -1
  8. data/frontend/src/renderer/engine.js +391 -10
  9. data/frontend/src/renderer/layer-manager.js +472 -71
  10. data/frontend/src/runtime-control-preset.js +44 -0
  11. data/frontend/src/scene-patches.js +159 -0
  12. data/frontend/src/shader-error-overlay.js +1 -0
  13. data/frontend/src/visuals/image-renderer.js +19 -0
  14. data/frontend/src/visuals/particle-system.js +10 -0
  15. data/frontend/src/visuals/shape-renderer.js +13 -0
  16. data/frontend/src/visuals/spectrogram-renderer.js +14 -0
  17. data/frontend/src/visuals/text-renderer.js +13 -0
  18. data/frontend/src/websocket-client.js +6 -0
  19. data/lib/vizcore/analysis/adaptive_normalizer.rb +20 -2
  20. data/lib/vizcore/analysis/bpm_estimator.rb +18 -8
  21. data/lib/vizcore/analysis/feature_recorder.rb +117 -7
  22. data/lib/vizcore/analysis/feature_replay.rb +48 -9
  23. data/lib/vizcore/analysis/pipeline.rb +258 -9
  24. data/lib/vizcore/analysis/tap_tempo.rb +17 -2
  25. data/lib/vizcore/audio/calibration.rb +156 -0
  26. data/lib/vizcore/audio/file_input.rb +28 -0
  27. data/lib/vizcore/audio/input_manager.rb +36 -1
  28. data/lib/vizcore/audio/midi_input.rb +5 -0
  29. data/lib/vizcore/audio/ring_buffer.rb +22 -0
  30. data/lib/vizcore/audio.rb +1 -0
  31. data/lib/vizcore/cli/dsl_reference.rb +64 -8
  32. data/lib/vizcore/cli/plugin_checker.rb +93 -0
  33. data/lib/vizcore/cli/scene_diagnostics.rb +2 -2
  34. data/lib/vizcore/cli/scene_inspector.rb +35 -1
  35. data/lib/vizcore/cli/scene_validator.rb +487 -39
  36. data/lib/vizcore/cli/shader_template.rb +7 -2
  37. data/lib/vizcore/cli/shader_uniform_docs.rb +11 -0
  38. data/lib/vizcore/cli.rb +268 -15
  39. data/lib/vizcore/config.rb +40 -3
  40. data/lib/vizcore/control_preset.rb +29 -0
  41. data/lib/vizcore/deep_copy.rb +21 -0
  42. data/lib/vizcore/dsl/color_helpers.rb +155 -0
  43. data/lib/vizcore/dsl/engine.rb +219 -23
  44. data/lib/vizcore/dsl/layer_builder.rb +278 -15
  45. data/lib/vizcore/dsl/layer_group_builder.rb +10 -12
  46. data/lib/vizcore/dsl/layout_helpers.rb +290 -0
  47. data/lib/vizcore/dsl/mapping_preset_builder.rb +41 -0
  48. data/lib/vizcore/dsl/mapping_resolver.rb +404 -22
  49. data/lib/vizcore/dsl/mapping_transform_builder.rb +50 -0
  50. data/lib/vizcore/dsl/midi_map_executor.rb +219 -23
  51. data/lib/vizcore/dsl/reaction_builder.rb +1 -0
  52. data/lib/vizcore/dsl/scene_builder.rb +83 -13
  53. data/lib/vizcore/dsl/shader_source_resolver.rb +1 -10
  54. data/lib/vizcore/dsl/style_builder.rb +3 -0
  55. data/lib/vizcore/dsl/timeline_builder.rb +91 -8
  56. data/lib/vizcore/dsl/transition_controller.rb +157 -18
  57. data/lib/vizcore/dsl.rb +2 -0
  58. data/lib/vizcore/layer_catalog.rb +1 -0
  59. data/lib/vizcore/plugin_asset_policy.rb +55 -0
  60. data/lib/vizcore/project_manifest.rb +12 -2
  61. data/lib/vizcore/renderer/render_sequence.rb +104 -13
  62. data/lib/vizcore/renderer/scene_frame_source.rb +179 -14
  63. data/lib/vizcore/renderer/scene_serializer.rb +38 -0
  64. data/lib/vizcore/renderer/snapshot.rb +4 -3
  65. data/lib/vizcore/renderer/snapshot_renderer.rb +134 -8
  66. data/lib/vizcore/scene_trust.rb +31 -0
  67. data/lib/vizcore/server/frame_broadcaster.rb +469 -23
  68. data/lib/vizcore/server/rack_app.rb +151 -4
  69. data/lib/vizcore/server/runner.rb +676 -82
  70. data/lib/vizcore/server/websocket_handler.rb +236 -14
  71. data/lib/vizcore/server.rb +21 -0
  72. data/lib/vizcore/shape.rb +39 -16
  73. data/lib/vizcore/sync/osc_message.rb +66 -9
  74. data/lib/vizcore/version.rb +1 -1
  75. data/lib/vizcore.rb +33 -0
  76. data/scripts/browser_capture.mjs +31 -2
  77. data/sig/vizcore.rbs +55 -4
  78. metadata +18 -3
@@ -15,17 +15,35 @@ module Vizcore
15
15
  module Server
16
16
  # Bootstraps Rack/Puma, audio pipeline, scene reload, and MIDI runtime.
17
17
  class Runner
18
+ DEFAULT_PROFILE_NAME = "default".freeze
19
+
18
20
  # @param config [Vizcore::Config]
21
+ # @param manifest [Vizcore::ProjectManifest, nil]
22
+ # @param initial_profile [String, nil]
19
23
  # @param output [#puts]
20
- def initialize(config, output: $stdout)
24
+ def initialize(config, manifest: nil, initial_profile: nil, output: $stdout)
21
25
  @config = config
26
+ @manifest = manifest
27
+ @available_profiles = derive_available_profiles
28
+ @active_profile = normalize_profile_name(initial_profile)
29
+ @active_scene_file = active_scene_file_for_profile(@active_profile)
22
30
  @output = output
23
31
  @shader_source_resolver = Vizcore::DSL::ShaderSourceResolver.new
24
32
  @scene_catalog_mutex = Mutex.new
25
33
  @scene_catalog = []
34
+ @scene_watcher = nil
35
+ @osc_schedule_mutex = Mutex.new
36
+ @osc_schedule_threads = []
37
+ @osc_runtime_active = false
38
+ @midi_runtime = nil
39
+ @osc_runtime = nil
40
+ @broadcaster = nil
26
41
  @runtime_globals_mutex = Mutex.new
27
42
  @runtime_globals = {}
28
- @live_controls = { "blackout" => false, "freeze" => false }
43
+ @live_controls = {
44
+ "blackout" => default_live_control_state,
45
+ "freeze" => default_live_control_state
46
+ }
29
47
  end
30
48
 
31
49
  # Run server lifecycle until interrupted.
@@ -35,16 +53,16 @@ module Vizcore
35
53
  # @return [void]
36
54
  def run
37
55
  validate_scene_file!
56
+ validate_public_bind_settings!
38
57
  validate_feature_settings!
39
58
  validate_control_preset_settings!
40
59
  validate_plugin_asset_settings!
41
60
  validate_audio_settings!
42
- definition = load_definition!
61
+ definition = load_definition_for_profile(@active_profile)
43
62
  control_preset = load_control_preset
44
- replace_runtime_globals(globals_for(definition))
45
- @tap_tempo_key = tap_tempo_key(definition)
46
- scene = first_scene(definition) || fallback_scene
47
-
63
+ timeline_entry = initial_timeline_entry(definition)
64
+ scene = initial_scene(definition) || fallback_scene
65
+ broadcaster = nil
48
66
  app = RackApp.new(
49
67
  frontend_root: Vizcore.frontend_root,
50
68
  audio_source: runtime_audio_source,
@@ -56,39 +74,50 @@ module Vizcore
56
74
  control_preset: control_preset,
57
75
  control_preset_path: @config.control_preset,
58
76
  plugin_assets: @config.plugin_assets,
59
- projector_mode: @config.projector_mode
77
+ projector_mode: @config.projector_mode,
78
+ runtime_status_provider: -> { runtime_status_payload }
60
79
  )
61
80
  server = Puma::Server.new(app, nil, min_threads: 0, max_threads: 4)
62
81
  server.add_tcp_listener(@config.host, @config.port)
63
82
  server.run
64
83
 
65
84
  input_manager = build_input_manager
85
+ warn_if_sample_rate_mismatch(input_manager)
66
86
  broadcaster = FrameBroadcaster.new(
67
87
  scene_name: scene[:name].to_s,
68
88
  scene_layers: scene[:layers],
69
89
  scene_catalog: definition[:scenes],
70
90
  transitions: definition[:transitions],
91
+ initial_timeline_entry: timeline_entry,
71
92
  input_manager: input_manager,
72
93
  analysis_pipeline: replay_pipeline,
73
94
  noise_gate: @config.noise_gate,
74
95
  audio_normalize: audio_normalize_settings(definition),
75
96
  bpm: bpm_setting(definition),
76
97
  bpm_lock: bpm_lock_setting(definition),
98
+ onset_sensitivity: analysis_setting(definition, :onset_sensitivity, 1.0),
99
+ fft_preview_bins: analysis_setting(definition, :fft_bins, Vizcore::Analysis::Pipeline::DEFAULT_FFT_PREVIEW_BINS),
100
+ peak_hold_frames: analysis_setting(definition, :peak_hold_frames, 0),
101
+ silence_reset_frames: analysis_setting(definition, :silence_reset_frames, Vizcore::Analysis::Pipeline::SILENCE_RESET_FRAMES),
77
102
  error_reporter: ->(message) { @output.puts(message) }
78
103
  )
104
+ @broadcaster = broadcaster
105
+ configure_runtime_for_definition(definition: definition, broadcaster: broadcaster)
79
106
  replace_scene_catalog(definition[:scenes])
80
107
  if file_transport_enabled?
81
108
  broadcaster.sync_transport(playing: false, position_seconds: 0.0)
82
109
  end
83
110
  broadcaster.start
84
111
  register_client_message_handler(broadcaster)
85
- midi_runtime = start_midi_runtime(definition, broadcaster)
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
112
+ @midi_runtime = start_midi_runtime(definition, broadcaster)
113
+ @osc_runtime = start_osc_runtime(broadcaster)
114
+ @scene_watcher = start_scene_watcher(
115
+ broadcaster,
116
+ definition: definition,
117
+ scene_file: active_scene_file
118
+ ) do |reloaded_definition|
119
+ @midi_runtime = refresh_midi_runtime(@midi_runtime, reloaded_definition, broadcaster)
120
+ end if @config.reload?
92
121
 
93
122
  @output.puts("Vizcore server listening at http://#{@config.host}:#{@config.port}")
94
123
  @output.puts("Projector output: http://#{@config.host}:#{@config.port}/projector")
@@ -97,26 +126,84 @@ module Vizcore
97
126
  @output.puts("Hot reload: #{@config.reload? ? 'enabled' : 'disabled'}")
98
127
  @output.puts("Audio playback: http://#{@config.host}:#{@config.port}/audio-file") if file_transport_enabled?
99
128
  @output.puts("Feature replay: #{@config.feature_file}") if feature_replay?
100
- @output.puts("OSC sync: udp://#{@config.host}:#{@config.osc_port}") if osc_runtime
129
+ @output.puts("OSC sync: udp://#{@config.host}:#{@config.osc_port}") if @osc_runtime
101
130
  @output.puts("Press Ctrl+C to stop.")
102
131
 
103
132
  wait_for_interrupt
104
133
  ensure
105
134
  Vizcore::Server::WebSocketHandler.clear_message_handler
106
- stop_osc_runtime(osc_runtime)
107
- stop_midi_runtime(midi_runtime)
108
- watcher&.stop
135
+ stop_osc_runtime(@osc_runtime)
136
+ stop_midi_runtime(@midi_runtime)
137
+ @scene_watcher&.stop
109
138
  broadcaster&.stop
110
139
  server&.stop(true)
111
140
  end
112
141
 
142
+ def warn_if_sample_rate_mismatch(input_manager)
143
+ return unless input_manager.respond_to?(:status)
144
+
145
+ status = input_manager.status
146
+ return unless status[:sample_rate_mismatch]
147
+
148
+ requested = status[:requested_sample_rate]
149
+ actual = status[:sample_rate]
150
+ return unless requested && actual
151
+
152
+ @output.puts(
153
+ "Warning: requested audio sample rate #{requested} does not match device sample rate #{actual}; " \
154
+ "analysis will use #{actual}."
155
+ )
156
+ end
157
+
113
158
  private
114
159
 
160
+ def derive_available_profiles
161
+ base_profiles = [DEFAULT_PROFILE_NAME]
162
+ manifest_profiles = @manifest ? Array(@manifest.profile_names).map(&:to_s) : []
163
+ all_profiles = (base_profiles + manifest_profiles).map { |profile| normalize_profile_name(profile) }
164
+ all_profiles.uniq
165
+ end
166
+
167
+ def normalize_profile_name(value)
168
+ raw = value.to_s.strip
169
+ raw.empty? ? DEFAULT_PROFILE_NAME : raw
170
+ end
171
+
172
+ def active_profile_for_api
173
+ @active_profile
174
+ end
175
+
176
+ def available_profiles_for_api
177
+ Array(@available_profiles)
178
+ end
179
+
180
+ def active_scene_file_for_profile(profile)
181
+ normalized_profile = normalize_profile_name(profile)
182
+ defaults = manifest_config_defaults_for(normalized_profile)
183
+ defaults.fetch(:scene_file, @config.scene_file)
184
+ rescue StandardError
185
+ @config.scene_file
186
+ end
187
+
188
+ def manifest_config_defaults_for(profile)
189
+ return {} unless @manifest
190
+
191
+ @manifest.config_defaults(profile: profile)
192
+ end
193
+
194
+ def active_scene_file
195
+ @active_scene_file
196
+ end
197
+
198
+ def active_profile? (candidate)
199
+ active_profile_for_api == normalize_profile_name(candidate)
200
+ end
201
+
115
202
  def validate_scene_file!
116
- return if @config.scene_exists?
203
+ return if active_scene_file&.file?
117
204
 
118
- message = if @config.scene_file
119
- "Scene file not found: #{@config.scene_file}"
205
+ message = if active_scene_file
206
+ "Scene file not found: #{active_scene_file}"
120
207
  else
121
208
  "Scene file is required"
122
209
  end
@@ -124,16 +211,21 @@ module Vizcore
124
211
  raise Vizcore::ConfigurationError, message
125
212
  end
126
213
 
127
- def load_definition!
128
- raw_definition = Vizcore::DSL::Engine.load_file(@config.scene_file.to_s)
129
- resolve_shader_sources(raw_definition)
214
+ def load_definition_for_profile(profile)
215
+ scene_file = active_scene_file_for_profile(profile)
216
+ raw_definition = Vizcore::DSL::Engine.load_file(scene_file.to_s)
217
+ resolve_shader_sources(raw_definition, scene_file: scene_file)
130
218
  rescue StandardError => e
131
219
  raise Vizcore::SceneLoadError, Vizcore::ErrorFormatting.summarize(
132
220
  e,
133
- context: "Failed to load scene file #{@config.scene_file}"
221
+ context: "Failed to load scene file #{scene_file}"
134
222
  )
135
223
  end
136
224
 
225
+ def load_definition!
226
+ load_definition_for_profile(active_profile_for_api)
227
+ end
228
+
137
229
  def validate_audio_settings!
138
230
  return if feature_replay?
139
231
  return unless @config.audio_source == :file
@@ -142,6 +234,19 @@ module Vizcore
142
234
  raise Vizcore::ConfigurationError, "Audio file not found: #{@config.audio_file || '(nil)'}"
143
235
  end
144
236
 
237
+ def validate_public_bind_settings!
238
+ return if @config.allow_public_control?
239
+ return unless public_bind_host?(@config.host)
240
+
241
+ raise Vizcore::ConfigurationError,
242
+ "Refusing to expose Vizcore control routes on #{@config.host}; pass --allow-public-control when this is intentional"
243
+ end
244
+
245
+ def public_bind_host?(host)
246
+ value = host.to_s.strip
247
+ value.empty? || value == "0.0.0.0" || value == "::" || value == "[::]"
248
+ end
249
+
145
250
  def validate_feature_settings!
146
251
  return unless feature_replay?
147
252
  return if @config.feature_file.file?
@@ -201,6 +306,59 @@ module Vizcore
201
306
  feature_replay? ? nil : @config.audio_file
202
307
  end
203
308
 
309
+ def runtime_status_payload
310
+ payload = @broadcaster ? @broadcaster.runtime_status : {}
311
+ payload.merge(
312
+ active_profile: active_profile_for_api,
313
+ available_profiles: available_profiles_for_api
314
+ )
315
+ rescue StandardError
316
+ {
317
+ active_profile: active_profile_for_api,
318
+ available_profiles: available_profiles_for_api
319
+ }
320
+ end
321
+
322
+ def configure_runtime_for_definition(definition:, broadcaster: @broadcaster)
323
+ replace_runtime_globals(globals_for(definition))
324
+ @tap_tempo_key = tap_tempo_key(definition)
325
+ broadcaster.update_transition_definition(
326
+ scenes: Array(definition[:scenes]),
327
+ transitions: Array(definition[:transitions])
328
+ )
329
+ broadcaster.update_analysis_settings(
330
+ audio_normalize: audio_normalize_settings(definition),
331
+ bpm: bpm_setting(definition),
332
+ bpm_lock: bpm_lock_setting(definition),
333
+ onset_sensitivity: analysis_setting(definition, :onset_sensitivity, 1.0),
334
+ fft_preview_bins: analysis_setting(definition, :fft_bins, Vizcore::Analysis::Pipeline::DEFAULT_FFT_PREVIEW_BINS),
335
+ peak_hold_frames: analysis_setting(definition, :peak_hold_frames, 0),
336
+ silence_reset_frames: analysis_setting(definition, :silence_reset_frames, Vizcore::Analysis::Pipeline::SILENCE_RESET_FRAMES)
337
+ )
338
+ scene = initial_scene(definition) || fallback_scene
339
+ broadcaster.update_scene(scene_name: scene[:name], scene_layers: scene[:layers])
340
+ scene
341
+ end
342
+
343
+ def runtime_config_update_payload(scene:, definition:)
344
+ {
345
+ scene: scene,
346
+ scenes: scene_names_for(definition),
347
+ tap_tempo_key: @tap_tempo_key,
348
+ key_mappings: key_mappings_for(definition),
349
+ globals: runtime_globals_snapshot,
350
+ active_profile: active_profile_for_api,
351
+ available_profiles: available_profiles_for_api
352
+ }
353
+ end
354
+
355
+ def broadcast_config_update(scene:, definition:)
356
+ WebSocketHandler.broadcast(
357
+ type: "config_update",
358
+ payload: runtime_config_update_payload(scene: scene, definition: definition)
359
+ )
360
+ end
361
+
204
362
  def wait_for_interrupt
205
363
  stop_requested = false
206
364
  %w[INT TERM].each do |signal_name|
@@ -211,37 +369,29 @@ module Vizcore
211
369
  sleep(0.1) until stop_requested
212
370
  end
213
371
 
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|
216
- definition = resolve_shader_sources(definition)
217
- replace_scene_catalog(definition[:scenes])
218
- replace_runtime_globals(globals_for(definition))
219
- @tap_tempo_key = tap_tempo_key(definition)
220
- scene = first_scene(definition) || fallback_scene
221
- broadcaster.update_transition_definition(
222
- scenes: Array(definition[:scenes]),
223
- transitions: Array(definition[:transitions])
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
- )
230
- broadcaster.update_scene(scene_name: scene[:name], scene_layers: scene[:layers])
231
- on_reload&.call(definition)
372
+ def start_scene_watcher(broadcaster, definition:, scene_file: nil, &on_reload)
373
+ watcher = Vizcore::Server::SceneDependencyWatcher.new(
374
+ scene_file: (scene_file || active_scene_file).to_s,
375
+ definition: definition
376
+ ) do |reloaded_definition, _changed_path|
377
+ reloaded_definition = resolve_shader_sources(reloaded_definition, scene_file: (scene_file || active_scene_file))
378
+ scene = configure_runtime_for_definition(definition: reloaded_definition, broadcaster: broadcaster)
379
+ on_reload&.call(reloaded_definition)
380
+ broadcast_config_update(scene: scene, definition: reloaded_definition)
381
+ @output.puts("Scene reloaded: #{scene[:name]}")
382
+ rescue StandardError => e
383
+ message = Vizcore::ErrorFormatting.summarize(e, context: "Scene reload failed")
384
+ @output.puts(message)
232
385
  WebSocketHandler.broadcast(
233
- type: "config_update",
386
+ type: "runtime_error",
234
387
  payload: {
235
- scene: scene,
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
388
+ source: "scene_reload",
389
+ event: "scene_reload_failed",
390
+ context: "Scene reload failed",
391
+ message: message,
392
+ keeping_last_good_scene: true
240
393
  }
241
394
  )
242
- @output.puts("Scene reloaded: #{scene[:name]}")
243
- rescue StandardError => e
244
- @output.puts(Vizcore::ErrorFormatting.summarize(e, context: "Scene reload failed"))
245
395
  end
246
396
  watcher.start
247
397
  watcher
@@ -254,9 +404,31 @@ module Vizcore
254
404
  definition.fetch(:scenes, []).first
255
405
  end
256
406
 
407
+ def initial_timeline_entry(definition)
408
+ Array(definition[:timelines]).each do |timeline|
409
+ entry = Array(timeline).first
410
+ return entry if entry
411
+ end
412
+
413
+ nil
414
+ end
415
+
416
+ def initial_scene(definition)
417
+ entry = initial_timeline_entry(definition)
418
+ scene_name = entry&.dig(:scene)
419
+ return first_scene(definition) unless scene_name
420
+
421
+ Array(definition[:scenes]).find do |candidate|
422
+ candidate[:name].to_s == scene_name.to_s
423
+ end
424
+ end
425
+
257
426
  def fallback_scene
427
+ scene_file = active_scene_file
428
+ scene_name = scene_file ? scene_file.basename(".rb").to_s : ""
429
+
258
430
  {
259
- name: @config.scene_file.basename(".rb").to_sym,
431
+ name: scene_name.empty? ? DEFAULT_PROFILE_NAME.to_sym : scene_name.to_sym,
260
432
  layers: []
261
433
  }
262
434
  end
@@ -319,20 +491,24 @@ module Vizcore
319
491
  def start_osc_runtime(broadcaster)
320
492
  return nil unless @config.osc_port
321
493
 
494
+ @osc_runtime_active = true
322
495
  receiver = Vizcore::Sync::OscReceiver.new(
323
496
  host: @config.host,
324
497
  port: @config.osc_port,
325
- handler: ->(message) { handle_osc_message(message, broadcaster) },
498
+ handler: ->(message) { handle_osc_messages(message, broadcaster) },
326
499
  error_reporter: ->(message) { @output.puts(message) }
327
500
  )
328
501
  receiver.start
329
502
  rescue StandardError => e
330
503
  @output.puts(Vizcore::ErrorFormatting.summarize(e, context: "OSC runtime disabled"))
331
504
  receiver&.stop
505
+ @osc_runtime_active = false
332
506
  nil
333
507
  end
334
508
 
335
509
  def stop_osc_runtime(runtime)
510
+ @osc_runtime_active = false
511
+ clear_scheduled_osc_messages
336
512
  runtime&.stop
337
513
  nil
338
514
  rescue StandardError => e
@@ -340,10 +516,60 @@ module Vizcore
340
516
  nil
341
517
  end
342
518
 
519
+ def handle_osc_messages(messages, broadcaster)
520
+ Array(messages).each do |message|
521
+ next unless message
522
+ handle_osc_message(message, broadcaster)
523
+ end
524
+ end
525
+
343
526
  def handle_osc_message(message, broadcaster)
527
+ return unless @osc_runtime_active
528
+
529
+ target_time = finite_float(message.timetag)
530
+ return process_osc_message(message, broadcaster) if target_time.nil?
531
+
532
+ delay = target_time - wall_clock_seconds
533
+ return process_osc_message(message, broadcaster) if delay <= 0
534
+
535
+ schedule_osc_message(message, broadcaster, delay)
536
+ rescue StandardError => e
537
+ @output.puts(Vizcore::ErrorFormatting.summarize(e, context: "OSC control message failed"))
538
+ end
539
+
540
+ def schedule_osc_message(message, broadcaster, delay)
541
+ thread = Thread.new do
542
+ sleep(delay)
543
+ return unless @osc_runtime_active
544
+
545
+ process_osc_message(message, broadcaster)
546
+ ensure
547
+ @osc_schedule_mutex.synchronize { @osc_schedule_threads.delete(Thread.current) }
548
+ end
549
+ @osc_schedule_mutex.synchronize { @osc_schedule_threads << thread }
550
+ end
551
+
552
+ def clear_scheduled_osc_messages
553
+ threads = @osc_schedule_mutex.synchronize do
554
+ threads = Array(@osc_schedule_threads)
555
+ @osc_schedule_threads.clear
556
+ threads
557
+ end
558
+ threads.each do |thread|
559
+ thread.kill
560
+ thread.join(0.05)
561
+ rescue StandardError
562
+ nil
563
+ end
564
+ end
565
+
566
+ def process_osc_message(message, broadcaster)
344
567
  case message.address
345
568
  when "/vizcore/scene"
346
- switch_scene_from_client(message.arguments.first, broadcaster, source: "osc")
569
+ arguments = Array(message.arguments)
570
+ target_name = arguments.first
571
+ effect = parse_osc_scene_effect(arguments.drop(1))
572
+ switch_scene_from_client(target_name, broadcaster, source: "osc", effect: effect)
347
573
  when "/vizcore/tap"
348
574
  apply_tap_tempo({ "client_tapped_at_ms" => wall_clock_ms }, broadcaster)
349
575
  when "/vizcore/bpm"
@@ -351,16 +577,16 @@ module Vizcore
351
577
  when "/vizcore/bpm_unlock"
352
578
  apply_osc_bpm_unlock(broadcaster)
353
579
  when %r{\A/vizcore/global/([^/]+)\z}
354
- apply_osc_global(Regexp.last_match(1), message.arguments.first)
580
+ apply_osc_global(Regexp.last_match(1), message.arguments)
581
+ when %r{\A/vizcore/layer/([^/]+)/(.+)\z}
582
+ apply_osc_layer_param(broadcaster, Regexp.last_match(1), Regexp.last_match(2), message.arguments)
355
583
  when %r{\A/vizcore/live/(blackout|freeze)\z}
356
- apply_osc_live_control(Regexp.last_match(1), message.arguments.first)
584
+ apply_osc_live_control(Regexp.last_match(1), message.arguments)
357
585
  when "/vizcore/transport/play", "/vizcore/transport/position"
358
586
  apply_osc_transport(broadcaster, playing: true, position_seconds: message.arguments.first)
359
587
  when "/vizcore/transport/stop"
360
588
  apply_osc_transport(broadcaster, playing: false, position_seconds: message.arguments.first)
361
589
  end
362
- rescue StandardError => e
363
- @output.puts(Vizcore::ErrorFormatting.summarize(e, context: "OSC control message failed"))
364
590
  end
365
591
 
366
592
  def handle_midi_event(executor, event, broadcaster)
@@ -378,6 +604,73 @@ module Vizcore
378
604
  end
379
605
  end
380
606
 
607
+ def switch_profile(raw_profile, broadcaster)
608
+ profile = normalize_profile_name(raw_profile)
609
+ return if profile == active_profile_for_api
610
+
611
+ unless available_profiles_for_api.include?(profile)
612
+ WebSocketHandler.broadcast(
613
+ type: "runtime_error",
614
+ payload: {
615
+ source: "profile",
616
+ context: "Unknown profile: #{profile}",
617
+ event: "unknown_profile",
618
+ message: "Profile not found: #{profile}"
619
+ }
620
+ )
621
+ return
622
+ end
623
+
624
+ scene_file = active_scene_file_for_profile(profile)
625
+ if scene_file.nil? || !scene_file.file?
626
+ WebSocketHandler.broadcast(
627
+ type: "runtime_error",
628
+ payload: {
629
+ source: "profile",
630
+ context: "Profile scene file missing",
631
+ event: "profile_scene_missing",
632
+ message: "Missing scene file for profile: #{profile}"
633
+ }
634
+ )
635
+ return
636
+ end
637
+
638
+ definition = load_definition_for_profile(profile)
639
+ scene = configure_runtime_for_definition(definition: definition, broadcaster: broadcaster)
640
+ @midi_runtime = refresh_midi_runtime(@midi_runtime, definition, broadcaster)
641
+ restart_scene_watcher_for_profile(profile_scene_file: scene_file, definition: definition, broadcaster: broadcaster)
642
+ @active_profile = profile
643
+ @active_scene_file = scene_file
644
+ broadcast_config_update(scene: scene, definition: definition)
645
+ rescue StandardError => e
646
+ message = Vizcore::ErrorFormatting.summarize(e, context: "Profile switch failed")
647
+ @output.puts(message)
648
+ WebSocketHandler.broadcast(
649
+ type: "runtime_error",
650
+ payload: {
651
+ source: "profile",
652
+ context: "Profile switch failed",
653
+ event: "profile_switch_failed",
654
+ message: message
655
+ }
656
+ )
657
+ end
658
+
659
+ def restart_scene_watcher_for_profile(profile_scene_file:, definition:, broadcaster:)
660
+ return unless @config.reload?
661
+
662
+ new_watcher = start_scene_watcher(
663
+ broadcaster,
664
+ definition: definition,
665
+ scene_file: profile_scene_file
666
+ )
667
+ return unless new_watcher
668
+
669
+ old_watcher = @scene_watcher
670
+ @scene_watcher = new_watcher
671
+ old_watcher&.stop
672
+ end
673
+
381
674
  def handle_client_message(message, broadcaster, socket = nil)
382
675
  type = message["type"] || message[:type]
383
676
  payload = message["payload"] || message[:payload]
@@ -395,11 +688,16 @@ module Vizcore
395
688
  when "switch_scene"
396
689
  values = Hash(payload)
397
690
  target_name = values.fetch("scene", values.fetch(:scene, values.fetch("scene_name", values.fetch(:scene_name, nil))))
398
- switch_scene_from_client(target_name, broadcaster)
691
+ effect = normalize_transition_effect(values["effect"] || values[:effect])
692
+ switch_scene_from_client(target_name, broadcaster, effect: effect)
693
+ when "switch_profile"
694
+ switch_profile((payload || {})["profile"] || (payload || {})[:profile], broadcaster)
399
695
  when "tap_tempo"
400
696
  apply_tap_tempo(payload, broadcaster)
401
697
  when "custom_shape_param"
402
698
  apply_custom_shape_param(payload, broadcaster)
699
+ when "client_runtime_error"
700
+ report_client_runtime_error(payload)
403
701
  end
404
702
  rescue StandardError => e
405
703
  @output.puts(Vizcore::ErrorFormatting.summarize(e, context: "Client control message failed"))
@@ -420,24 +718,52 @@ module Vizcore
420
718
  WebSocketHandler.send_to(socket, type: "latency_probe", payload: response)
421
719
  end
422
720
 
721
+ def report_client_runtime_error(payload)
722
+ values = Hash(payload)
723
+ message = values["message"] || values[:message]
724
+ return unless message
725
+
726
+ context = values["context"] || values[:context] || "Client runtime error"
727
+ source = normalize_client_error_source(values["source"] || values[:source])
728
+ event = values["event"] || values[:event]
729
+ WebSocketHandler.broadcast(
730
+ type: "runtime_error",
731
+ payload: {
732
+ source: source,
733
+ context: String(context),
734
+ message: String(message),
735
+ frame_id: current_frame_id(values),
736
+ event: event
737
+ }
738
+ )
739
+ end
740
+
741
+ def current_frame_id(values)
742
+ frame_id = values["frame_id"] || values[:frame_id]
743
+ parsed = finite_float(frame_id)
744
+ parsed if parsed
745
+ rescue StandardError
746
+ nil
747
+ end
748
+
749
+ def normalize_client_error_source(value)
750
+ source = String(value || "runtime").strip
751
+ source.empty? ? "runtime" : source
752
+ end
753
+
423
754
  def apply_midi_action(action, executor, broadcaster)
424
755
  case action[:type]
425
756
  when :switch_scene
426
757
  target_scene = action[:scene]
427
758
  return unless target_scene
428
759
 
429
- current = broadcaster.current_scene_snapshot
430
- from_scene = current[:name]
431
- broadcaster.update_scene(scene_name: target_scene[:name], scene_layers: target_scene[:layers])
432
- WebSocketHandler.broadcast(
433
- type: "scene_change",
434
- payload: {
435
- from: from_scene.to_s,
436
- to: target_scene[:name].to_s,
437
- effect: action[:effect],
438
- source: "midi"
439
- }
440
- )
760
+ apply_midi_scene_change(target_scene, action[:effect], broadcaster)
761
+ when :next_scene
762
+ target_scene = adjacent_scene_for(broadcaster.current_scene_snapshot[:name], offset: 1)
763
+ apply_midi_scene_change(target_scene, action[:effect], broadcaster) if target_scene
764
+ when :previous_scene
765
+ target_scene = adjacent_scene_for(broadcaster.current_scene_snapshot[:name], offset: -1)
766
+ apply_midi_scene_change(target_scene, action[:effect], broadcaster) if target_scene
441
767
  when :set_global
442
768
  WebSocketHandler.broadcast(
443
769
  type: "config_update",
@@ -445,9 +771,40 @@ module Vizcore
445
771
  globals: executor.globals
446
772
  }
447
773
  )
774
+ when :live_control
775
+ apply_midi_live_control(action[:control], action)
448
776
  end
449
777
  end
450
778
 
779
+ def apply_midi_live_control(control, action)
780
+ control_name = control.to_s
781
+ return unless @live_controls.key?(control_name)
782
+
783
+ @live_controls[control_name] = normalize_live_control_state(action)
784
+ WebSocketHandler.broadcast(
785
+ type: "config_update",
786
+ payload: {
787
+ live_controls: @live_controls.dup,
788
+ source: "midi"
789
+ }
790
+ )
791
+ end
792
+
793
+ def apply_midi_scene_change(target_scene, effect, broadcaster)
794
+ current = broadcaster.current_scene_snapshot
795
+ from_scene = current[:name]
796
+ broadcaster.update_scene(scene_name: target_scene[:name], scene_layers: target_scene[:layers])
797
+ WebSocketHandler.broadcast(
798
+ type: "scene_change",
799
+ payload: {
800
+ from: from_scene.to_s,
801
+ to: target_scene[:name].to_s,
802
+ effect: effect,
803
+ source: "midi"
804
+ }
805
+ )
806
+ end
807
+
451
808
  def midi_runtime_settings(definition)
452
809
  midi_inputs = Array(definition[:midi])
453
810
 
@@ -508,8 +865,11 @@ module Vizcore
508
865
  []
509
866
  end
510
867
 
511
- def resolve_shader_sources(definition)
512
- @shader_source_resolver.resolve(definition: definition, scene_file: @config.scene_file.to_s)
868
+ def resolve_shader_sources(definition, scene_file: nil)
869
+ @shader_source_resolver.resolve(
870
+ definition: definition,
871
+ scene_file: (scene_file || active_scene_file).to_s
872
+ )
513
873
  end
514
874
 
515
875
  def replace_scene_catalog(scenes)
@@ -538,6 +898,12 @@ module Vizcore
538
898
  nil
539
899
  end
540
900
 
901
+ def analysis_setting(definition, key, fallback)
902
+ Hash(definition[:analysis] || {}).fetch(key, fallback)
903
+ rescue StandardError
904
+ fallback
905
+ end
906
+
541
907
  def bpm_setting(definition)
542
908
  @config.bpm || Hash(definition[:analysis] || {})[:bpm]
543
909
  rescue StandardError
@@ -607,8 +973,8 @@ module Vizcore
607
973
  )
608
974
  end
609
975
 
610
- def apply_osc_global(name, value)
611
- globals = set_runtime_global(name, normalize_osc_value(value))
976
+ def apply_osc_global(name, arguments)
977
+ globals = set_runtime_global(name, normalize_osc_argument(arguments))
612
978
  WebSocketHandler.broadcast(
613
979
  type: "config_update",
614
980
  payload: {
@@ -618,6 +984,23 @@ module Vizcore
618
984
  )
619
985
  end
620
986
 
987
+ def apply_osc_layer_param(broadcaster, layer_name, param, arguments)
988
+ return unless broadcaster.respond_to?(:set_layer_param)
989
+
990
+ overrides = broadcaster.set_layer_param(
991
+ layer_name: layer_name,
992
+ param: param.to_s.tr("/", "."),
993
+ value: normalize_osc_argument(arguments)
994
+ )
995
+ WebSocketHandler.broadcast(
996
+ type: "config_update",
997
+ payload: {
998
+ layer_params: overrides,
999
+ source: "osc"
1000
+ }
1001
+ )
1002
+ end
1003
+
621
1004
  def apply_custom_shape_param(payload, broadcaster)
622
1005
  return unless broadcaster.respond_to?(:set_custom_shape_param)
623
1006
 
@@ -638,7 +1021,13 @@ module Vizcore
638
1021
  end
639
1022
 
640
1023
  def apply_osc_live_control(control, value)
641
- @live_controls[control] = osc_truthy?(value)
1024
+ values = Array(value)
1025
+ @live_controls[control] = default_live_control_state(
1026
+ enabled: osc_truthy?(values.first),
1027
+ fade: values[1],
1028
+ release: values[2],
1029
+ color: values[3]
1030
+ )
642
1031
  WebSocketHandler.broadcast(
643
1032
  type: "config_update",
644
1033
  payload: {
@@ -657,11 +1046,73 @@ module Vizcore
657
1046
  )
658
1047
  end
659
1048
 
660
- def normalize_osc_value(value)
1049
+ def normalize_osc_argument(arguments)
1050
+ values = Array(arguments)
1051
+ normalize_osc_value(values.first, input_min: values[1], input_max: values[2])
1052
+ end
1053
+
1054
+ def normalize_osc_value(value, input_min: nil, input_max: nil)
661
1055
  numeric = finite_float(value)
1056
+ normalized_range = parse_osc_range(input_min, input_max)
1057
+ normalized = normalize_osc_range(numeric, input_min: normalized_range[:min], input_max: normalized_range[:max]) unless numeric.nil? || normalized_range.nil?
1058
+ return normalized unless normalized.nil?
1059
+
662
1060
  numeric.nil? ? value : numeric
663
1061
  end
664
1062
 
1063
+ def parse_osc_range(input_min, input_max)
1064
+ if input_max.nil?
1065
+ return parse_osc_range_preset(input_min) if input_min
1066
+ return nil
1067
+ end
1068
+
1069
+ {
1070
+ min: input_min,
1071
+ max: input_max
1072
+ }
1073
+ end
1074
+
1075
+ def parse_osc_range_preset(value)
1076
+ return nil unless value
1077
+
1078
+ symbol = value.to_s.strip.downcase
1079
+ case symbol
1080
+ when "0..1", "01", "unit", "unit01", "unit_01", "unit_0_1", "normalized"
1081
+ { min: 0.0, max: 1.0 }
1082
+ when "-1..1", "bipolar", "bip", "minus1..1", "minus1_1", "-1_1", "-1,1", "-1 to 1"
1083
+ { min: -1.0, max: 1.0 }
1084
+ when "midi", "midicc", "cc", "midi_cc", "0..127", "0..128", "127"
1085
+ { min: 0.0, max: 127.0 }
1086
+ else
1087
+ parse_range_expression(value)
1088
+ end
1089
+ end
1090
+
1091
+ def parse_range_expression(value)
1092
+ text = value.to_s.strip
1093
+ from, to = text.split("..", 2)
1094
+ return nil if to.nil?
1095
+
1096
+ min = finite_float(from)
1097
+ max = finite_float(to)
1098
+ return nil if min.nil? || max.nil?
1099
+
1100
+ {
1101
+ min: min,
1102
+ max: max
1103
+ }
1104
+ end
1105
+
1106
+ def normalize_osc_range(value, input_min:, input_max:)
1107
+ return nil if input_min.nil? || input_max.nil?
1108
+
1109
+ min = finite_float(input_min)
1110
+ max = finite_float(input_max)
1111
+ return nil if min.nil? || max.nil? || min == max
1112
+
1113
+ ((value - min) / (max - min)).clamp(0.0, 1.0)
1114
+ end
1115
+
665
1116
  def osc_truthy?(value)
666
1117
  return true if value.nil?
667
1118
  return value if value == true || value == false
@@ -672,7 +1123,30 @@ module Vizcore
672
1123
  %w[true on yes 1].include?(value.to_s.strip.downcase)
673
1124
  end
674
1125
 
675
- def switch_scene_from_client(target_name, broadcaster, source: "ui")
1126
+ def default_live_control_state(enabled: false, fade: nil, release: nil, color: nil)
1127
+ {
1128
+ "enabled" => !!enabled,
1129
+ "fade" => finite_float(fade),
1130
+ "release" => finite_float(release),
1131
+ "color" => normalize_control_color(color)
1132
+ }.compact
1133
+ end
1134
+
1135
+ def normalize_live_control_state(value)
1136
+ return default_live_control_state(enabled: !!value) unless value.is_a?(Hash)
1137
+
1138
+ values = value.each_with_object({}) do |(entry_key, entry_value), output|
1139
+ output[entry_key.to_s] = entry_value
1140
+ end
1141
+ default_live_control_state(
1142
+ enabled: values.fetch("value", values.fetch("enabled", false)),
1143
+ fade: values["fade"],
1144
+ release: values["release"],
1145
+ color: values["color"]
1146
+ )
1147
+ end
1148
+
1149
+ def switch_scene_from_client(target_name, broadcaster, source: "ui", effect: nil)
676
1150
  requested = target_name.to_s.strip
677
1151
  return if requested.empty?
678
1152
 
@@ -682,17 +1156,66 @@ module Vizcore
682
1156
  current = broadcaster.current_scene_snapshot
683
1157
  from_scene = current[:name]
684
1158
  broadcaster.update_scene(scene_name: target_scene[:name], scene_layers: target_scene[:layers])
1159
+ resolved_effect = resolve_manual_scene_effect(effect)
685
1160
  WebSocketHandler.broadcast(
686
1161
  type: "scene_change",
687
1162
  payload: {
688
1163
  from: from_scene.to_s,
689
1164
  to: target_scene[:name].to_s,
690
- effect: nil,
1165
+ effect: resolved_effect,
691
1166
  source: source
692
1167
  }
693
1168
  )
694
1169
  end
695
1170
 
1171
+ def resolve_manual_scene_effect(effect)
1172
+ normalized = normalize_transition_effect(effect)
1173
+ return normalized unless normalized.nil?
1174
+ return nil unless @config.respond_to?(:scene_switch_effect)
1175
+
1176
+ deep_dup(@config.scene_switch_effect)
1177
+ rescue StandardError
1178
+ nil
1179
+ end
1180
+
1181
+ def normalize_transition_effect(value)
1182
+ return nil unless value
1183
+ return nil if value.is_a?(Array)
1184
+ return value unless value.is_a?(Hash)
1185
+
1186
+ if value[:name] || value["name"]
1187
+ {
1188
+ name: value[:name] || value["name"],
1189
+ options: value[:options] || value["options"] || {}
1190
+ }
1191
+ else
1192
+ value
1193
+ end
1194
+ rescue StandardError
1195
+ nil
1196
+ end
1197
+
1198
+ def parse_osc_scene_effect(arguments)
1199
+ return nil if arguments.empty?
1200
+
1201
+ name = arguments[0]
1202
+ return nil unless name
1203
+
1204
+ effect_name = name.to_s.strip
1205
+ return nil if effect_name.empty?
1206
+
1207
+ duration = finite_float(arguments[1])
1208
+ return { name: effect_name.to_sym } if duration.nil?
1209
+
1210
+ { name: effect_name.to_sym, options: { duration: duration } }
1211
+ rescue StandardError
1212
+ nil
1213
+ end
1214
+
1215
+ def deep_dup(value)
1216
+ Vizcore::DeepCopy.copy(value)
1217
+ end
1218
+
696
1219
  def find_scene_catalog_scene(name)
697
1220
  @scene_catalog_mutex.synchronize do
698
1221
  Array(@scene_catalog).each do |scene|
@@ -709,8 +1232,32 @@ module Vizcore
709
1232
  nil
710
1233
  end
711
1234
 
1235
+ def adjacent_scene_for(current_name, offset:)
1236
+ @scene_catalog_mutex.synchronize do
1237
+ scenes = Array(@scene_catalog)
1238
+ return nil if scenes.empty?
1239
+
1240
+ current_index = scenes.index do |scene|
1241
+ raw_name = scene.dig(:name) || scene["name"]
1242
+ raw_name.to_s == current_name.to_s
1243
+ end
1244
+ return nil unless current_index
1245
+
1246
+ target = scenes[(current_index + Integer(offset)) % scenes.length]
1247
+ raw_name = target.dig(:name) || target["name"]
1248
+ layers = target.dig(:layers) || target["layers"]
1249
+ { name: raw_name.to_sym, layers: Array(layers) }
1250
+ end
1251
+ rescue StandardError
1252
+ nil
1253
+ end
1254
+
712
1255
  def wall_clock_ms
713
- Time.now.to_f * 1000.0
1256
+ wall_clock_seconds * 1000.0
1257
+ end
1258
+
1259
+ def wall_clock_seconds
1260
+ Time.now.to_f
714
1261
  end
715
1262
 
716
1263
  def finite_float(value)
@@ -721,6 +1268,53 @@ module Vizcore
721
1268
  rescue StandardError
722
1269
  nil
723
1270
  end
1271
+
1272
+ def normalize_control_color(value)
1273
+ return nil if value.nil?
1274
+
1275
+ if value.is_a?(Array)
1276
+ return nil unless (3..4).cover?(value.length)
1277
+
1278
+ channels = Array(value).map { |entry| Float(entry, exception: false) }
1279
+ return nil if channels.include?(nil)
1280
+
1281
+ rgb = channels.take(3)
1282
+ alpha = channels[3]
1283
+ normalized_rgb = if rgb.all? { |channel| channel.between?(0.0, 1.0) }
1284
+ rgb
1285
+ else
1286
+ rgb.map { |channel| channel / 255.0 }
1287
+ end
1288
+ normalized = normalized_rgb.map { |channel| [0.0, [1.0, channel].min].max }
1289
+ return alpha.nil? ? normalized : normalized + [normalize_control_alpha(alpha)]
1290
+ end
1291
+
1292
+ raw = value.to_s.strip
1293
+ match = raw.match(/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/)
1294
+ return nil unless match
1295
+
1296
+ raw_hex = match[1]
1297
+ hex = raw_hex.length == 3 || raw_hex.length == 4 ? raw_hex.chars.map { |entry| "#{entry}#{entry}" }.join("") : raw_hex
1298
+
1299
+ [
1300
+ Integer("0x#{hex[0, 2]}", 16),
1301
+ Integer("0x#{hex[2, 2]}", 16),
1302
+ Integer("0x#{hex[4, 2]}", 16),
1303
+ Integer("0x#{hex[6, 2]}", 16)
1304
+ ].take(raw_hex.length > 4 ? 4 : 3).map { |channel| [0.0, [1.0, channel / 255.0].min].max }
1305
+ rescue StandardError
1306
+ nil
1307
+ end
1308
+
1309
+ def normalize_control_alpha(value)
1310
+ return nil if value.nil?
1311
+
1312
+ alpha = Float(value, exception: false)
1313
+ return nil if alpha.nil?
1314
+ return [0.0, [1.0, alpha].min].max if alpha.between?(0.0, 1.0)
1315
+
1316
+ [0.0, [1.0, alpha / 255.0].min].max
1317
+ end
724
1318
  end
725
1319
  end
726
1320
  end