vizcore 1.0.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 (90) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +66 -648
  3. data/docs/assets/playground-worker.js +373 -0
  4. data/docs/assets/playground.css +440 -0
  5. data/docs/assets/playground.js +652 -0
  6. data/docs/index.html +2 -1
  7. data/docs/playground.html +81 -0
  8. data/docs/shape_dsl.md +269 -0
  9. data/frontend/index.html +50 -2
  10. data/frontend/src/audio-inspector.js +9 -0
  11. data/frontend/src/custom-shape-param-controls.js +106 -0
  12. data/frontend/src/live-controls.js +219 -7
  13. data/frontend/src/main.js +703 -45
  14. data/frontend/src/mapping-target-selector.js +109 -0
  15. data/frontend/src/midi-learn.js +22 -2
  16. data/frontend/src/performance-monitor.js +137 -1
  17. data/frontend/src/renderer/engine.js +401 -11
  18. data/frontend/src/renderer/layer-manager.js +490 -75
  19. data/frontend/src/runtime-control-preset.js +44 -0
  20. data/frontend/src/scene-patches.js +159 -0
  21. data/frontend/src/shader-error-overlay.js +1 -0
  22. data/frontend/src/shape-editor-controls.js +157 -0
  23. data/frontend/src/visuals/geometry.js +425 -27
  24. data/frontend/src/visuals/image-renderer.js +19 -0
  25. data/frontend/src/visuals/particle-system.js +10 -0
  26. data/frontend/src/visuals/shape-renderer.js +488 -0
  27. data/frontend/src/visuals/spectrogram-renderer.js +14 -0
  28. data/frontend/src/visuals/svg-arc.js +104 -0
  29. data/frontend/src/visuals/text-renderer.js +13 -0
  30. data/frontend/src/websocket-client.js +6 -0
  31. data/lib/vizcore/analysis/adaptive_normalizer.rb +20 -2
  32. data/lib/vizcore/analysis/bpm_estimator.rb +18 -8
  33. data/lib/vizcore/analysis/feature_recorder.rb +117 -7
  34. data/lib/vizcore/analysis/feature_replay.rb +48 -9
  35. data/lib/vizcore/analysis/pipeline.rb +258 -9
  36. data/lib/vizcore/analysis/tap_tempo.rb +17 -2
  37. data/lib/vizcore/audio/calibration.rb +156 -0
  38. data/lib/vizcore/audio/file_input.rb +28 -0
  39. data/lib/vizcore/audio/input_manager.rb +36 -1
  40. data/lib/vizcore/audio/midi_input.rb +5 -0
  41. data/lib/vizcore/audio/ring_buffer.rb +22 -0
  42. data/lib/vizcore/audio.rb +1 -0
  43. data/lib/vizcore/cli/dsl_reference.rb +65 -9
  44. data/lib/vizcore/cli/plugin_checker.rb +93 -0
  45. data/lib/vizcore/cli/scene_diagnostics.rb +2 -2
  46. data/lib/vizcore/cli/scene_inspector.rb +35 -1
  47. data/lib/vizcore/cli/scene_validator.rb +573 -33
  48. data/lib/vizcore/cli/shader_template.rb +7 -2
  49. data/lib/vizcore/cli/shader_uniform_docs.rb +11 -0
  50. data/lib/vizcore/cli.rb +268 -15
  51. data/lib/vizcore/config.rb +40 -3
  52. data/lib/vizcore/control_preset.rb +29 -0
  53. data/lib/vizcore/deep_copy.rb +21 -0
  54. data/lib/vizcore/dsl/color_helpers.rb +155 -0
  55. data/lib/vizcore/dsl/engine.rb +219 -23
  56. data/lib/vizcore/dsl/layer_builder.rb +1072 -21
  57. data/lib/vizcore/dsl/layer_group_builder.rb +10 -12
  58. data/lib/vizcore/dsl/layout_helpers.rb +290 -0
  59. data/lib/vizcore/dsl/mapping_preset_builder.rb +41 -0
  60. data/lib/vizcore/dsl/mapping_resolver.rb +549 -13
  61. data/lib/vizcore/dsl/mapping_transform_builder.rb +50 -0
  62. data/lib/vizcore/dsl/midi_map_executor.rb +219 -23
  63. data/lib/vizcore/dsl/reaction_builder.rb +1 -0
  64. data/lib/vizcore/dsl/scene_builder.rb +83 -13
  65. data/lib/vizcore/dsl/shader_source_resolver.rb +1 -10
  66. data/lib/vizcore/dsl/style_builder.rb +3 -0
  67. data/lib/vizcore/dsl/timeline_builder.rb +91 -8
  68. data/lib/vizcore/dsl/transition_controller.rb +157 -18
  69. data/lib/vizcore/dsl.rb +2 -0
  70. data/lib/vizcore/layer_catalog.rb +5 -2
  71. data/lib/vizcore/plugin_asset_policy.rb +55 -0
  72. data/lib/vizcore/project_manifest.rb +12 -2
  73. data/lib/vizcore/renderer/render_sequence.rb +104 -13
  74. data/lib/vizcore/renderer/scene_frame_source.rb +190 -12
  75. data/lib/vizcore/renderer/scene_serializer.rb +38 -0
  76. data/lib/vizcore/renderer/snapshot.rb +4 -3
  77. data/lib/vizcore/renderer/snapshot_renderer.rb +641 -23
  78. data/lib/vizcore/scene_trust.rb +31 -0
  79. data/lib/vizcore/server/frame_broadcaster.rb +513 -18
  80. data/lib/vizcore/server/rack_app.rb +151 -4
  81. data/lib/vizcore/server/runner.rb +697 -82
  82. data/lib/vizcore/server/websocket_handler.rb +236 -14
  83. data/lib/vizcore/server.rb +21 -0
  84. data/lib/vizcore/shape.rb +742 -0
  85. data/lib/vizcore/sync/osc_message.rb +66 -9
  86. data/lib/vizcore/version.rb +1 -1
  87. data/lib/vizcore.rb +34 -0
  88. data/scripts/browser_capture.mjs +31 -2
  89. data/sig/vizcore.rbs +154 -4
  90. metadata +29 -3
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "../audio"
4
4
  require_relative "../analysis"
5
+ require_relative "../deep_copy"
5
6
  require_relative "../dsl"
6
7
  require_relative "../errors"
7
8
  require_relative "../renderer"
@@ -23,10 +24,15 @@ module Vizcore
23
24
  # @param scene_catalog [Array<Hash>, nil]
24
25
  # @param transitions [Array<Hash>, nil]
25
26
  # @param transition_controller [Vizcore::DSL::TransitionController, nil]
27
+ # @param initial_timeline_entry [Hash, nil]
26
28
  # @param noise_gate [Numeric]
27
29
  # @param audio_normalize [Hash, nil]
28
30
  # @param bpm [Numeric, nil]
29
31
  # @param bpm_lock [Boolean]
32
+ # @param onset_sensitivity [Numeric]
33
+ # @param fft_preview_bins [Integer]
34
+ # @param peak_hold_frames [Integer]
35
+ # @param silence_reset_frames [Integer]
30
36
  # @param error_reporter [#call, nil]
31
37
  def initialize(
32
38
  scene_name: "basic",
@@ -39,13 +45,18 @@ module Vizcore
39
45
  scene_catalog: nil,
40
46
  transitions: nil,
41
47
  transition_controller: nil,
48
+ initial_timeline_entry: nil,
42
49
  noise_gate: Vizcore::Analysis::Pipeline::DEFAULT_NOISE_GATE,
43
50
  audio_normalize: nil,
44
51
  bpm: nil,
45
52
  bpm_lock: false,
53
+ onset_sensitivity: 1.0,
54
+ fft_preview_bins: Vizcore::Analysis::Pipeline::DEFAULT_FFT_PREVIEW_BINS,
55
+ peak_hold_frames: 0,
56
+ silence_reset_frames: Vizcore::Analysis::Pipeline::SILENCE_RESET_FRAMES,
46
57
  error_reporter: nil
47
58
  )
48
- @scene_name = scene_name
59
+ @scene_name = scene_name.to_s
49
60
  @scene_layers = Array(scene_layers)
50
61
  @scene_mutex = Mutex.new
51
62
  @input_manager = input_manager || Vizcore::Audio::InputManager.new(source: :mic)
@@ -56,19 +67,45 @@ module Vizcore
56
67
  noise_gate: noise_gate,
57
68
  audio_normalize: audio_normalize,
58
69
  bpm: bpm,
59
- bpm_lock: bpm_lock
70
+ bpm_lock: bpm_lock,
71
+ onset_sensitivity: onset_sensitivity,
72
+ fft_preview_bins: fft_preview_bins,
73
+ peak_hold_frames: peak_hold_frames,
74
+ silence_reset_frames: silence_reset_frames
60
75
  )
61
76
  @mapping_resolver = mapping_resolver || Vizcore::DSL::MappingResolver.new
62
77
  @scene_serializer = scene_serializer || Vizcore::Renderer::SceneSerializer.new
78
+ @error_reporter = error_reporter || ->(_message) {}
63
79
  @transition_controller = transition_controller || Vizcore::DSL::TransitionController.new(
64
80
  scenes: scene_catalog || [],
65
- transitions: transitions || []
81
+ transitions: transitions || [],
82
+ error_reporter: lambda do |message|
83
+ @error_reporter.call(message)
84
+ report_runtime_message(
85
+ message,
86
+ context: "transition trigger failed",
87
+ source: "transition",
88
+ event: "transition_failed"
89
+ )
90
+ end
66
91
  )
67
- @error_reporter = error_reporter || ->(_message) {}
68
92
  @last_error = nil
69
93
  @frame_count = 0
94
+ @last_frame_metrics = {}
95
+ @scene_version = 1
96
+ @last_sent_scene_version = nil
97
+ @last_sent_scene_payload = nil
98
+ @connected_client_count = 0
99
+ @custom_shape_param_overrides = {}
100
+ @layer_param_overrides = {}
101
+ @custom_shape_param_mutex = Mutex.new
102
+ @transport_reference_position = nil
103
+ @transport_reference_wall_seconds = nil
104
+ @transport_drift_seconds = 0.0
105
+ @transport_drift_threshold_seconds = 0.08
70
106
  @transport_playing = initial_transport_playing_state
71
107
  reset_transition_trigger_counters!
108
+ apply_initial_timeline_entry(initial_timeline_entry)
72
109
  @tap_tempo = Vizcore::Analysis::TapTempo.new
73
110
  @frame_scheduler = frame_scheduler || Vizcore::Renderer::FrameScheduler.new(frame_rate: FRAME_RATE) do |elapsed|
74
111
  tick(elapsed)
@@ -107,15 +144,41 @@ module Vizcore
107
144
  current_scene
108
145
  end
109
146
 
147
+ # @return [Hash] runtime health details for control/status endpoints
148
+ def runtime_status
149
+ scene = current_scene
150
+ {
151
+ current_scene: scene[:name].to_s,
152
+ scene_version: @scene_version,
153
+ fps: FRAME_RATE,
154
+ frame_id: @frame_count,
155
+ sample_rate: input_manager_value(:sample_rate),
156
+ frame_size: input_manager_value(:frame_size),
157
+ input: input_manager_status,
158
+ transport_playing: @scene_mutex.synchronize { @transport_playing },
159
+ transport_drift: transport_drift_status,
160
+ websocket_clients: WebSocketHandler.connection_count,
161
+ dropped_frames: WebSocketHandler.dropped_frame_count,
162
+ websocket_backpressure: WebSocketHandler.backpressure_status,
163
+ last_error: formatted_last_error,
164
+ metrics: deep_dup(@last_frame_metrics)
165
+ }.compact
166
+ end
167
+
110
168
  # Synchronize external playback transport (e.g. browser audio element) with the input source.
111
169
  #
112
170
  # @param playing [Boolean]
113
171
  # @param position_seconds [Numeric]
114
172
  # @return [void]
115
173
  def sync_transport(playing:, position_seconds:)
174
+ position = finite_float(position_seconds)
116
175
  @scene_mutex.synchronize do
117
176
  @transport_playing = !!playing
118
- reset_transition_trigger_counters! if transport_position_reset?(position_seconds)
177
+ reset_transition_trigger_counters! if transport_position_reset?(position)
178
+ if file_transport_source?
179
+ @transport_reference_position = position
180
+ @transport_reference_wall_seconds = wall_clock_seconds
181
+ end
119
182
  end
120
183
  return unless @input_manager.respond_to?(:sync_transport)
121
184
 
@@ -133,7 +196,7 @@ module Vizcore
133
196
  @frame_count += 1
134
197
  frame = build_frame(elapsed_seconds, samples)
135
198
  WebSocketHandler.broadcast(type: "audio_frame", payload: frame)
136
- evaluate_transition(frame[:audio], frame_count: @frame_count)
199
+ evaluate_transition(frame[:audio], frame_count: @frame_count, elapsed_seconds: elapsed_seconds)
137
200
  frame
138
201
  end
139
202
 
@@ -144,8 +207,17 @@ module Vizcore
144
207
  # @return [void]
145
208
  def update_scene(scene_name:, scene_layers:)
146
209
  @scene_mutex.synchronize do
147
- @scene_name = scene_name.to_s
148
- @scene_layers = Array(scene_layers)
210
+ next_scene_name = scene_name.to_s
211
+ next_scene_layers = Array(scene_layers)
212
+ same_scene = @scene_name == next_scene_name &&
213
+ deep_layers_equal?(@scene_layers, next_scene_layers)
214
+
215
+ @scene_name = next_scene_name
216
+ @scene_layers = next_scene_layers
217
+ @scene_version += 1 unless same_scene
218
+ @last_sent_scene_version = nil unless same_scene
219
+ @last_sent_scene_payload = nil unless same_scene
220
+ @mapping_resolver.reset! if @mapping_resolver.respond_to?(:reset!)
149
221
  reset_transition_trigger_counters!
150
222
  end
151
223
  end
@@ -167,11 +239,15 @@ module Vizcore
167
239
  # @param bpm [Numeric, nil]
168
240
  # @param bpm_lock [Boolean]
169
241
  # @return [void]
170
- def update_analysis_settings(audio_normalize:, bpm: nil, bpm_lock: false)
242
+ def update_analysis_settings(audio_normalize:, bpm: nil, bpm_lock: false, onset_sensitivity: 1.0, fft_preview_bins: Vizcore::Analysis::Pipeline::DEFAULT_FFT_PREVIEW_BINS, peak_hold_frames: 0, silence_reset_frames: Vizcore::Analysis::Pipeline::SILENCE_RESET_FRAMES)
171
243
  return unless @analysis_pipeline.respond_to?(:audio_normalize=)
172
244
 
173
245
  @analysis_pipeline.audio_normalize = audio_normalize
174
246
  @analysis_pipeline.bpm_lock = { bpm: bpm, locked: bpm_lock } if @analysis_pipeline.respond_to?(:bpm_lock=)
247
+ @analysis_pipeline.onset_sensitivity = onset_sensitivity if @analysis_pipeline.respond_to?(:onset_sensitivity=)
248
+ @analysis_pipeline.fft_preview_bins = fft_preview_bins if @analysis_pipeline.respond_to?(:fft_preview_bins=)
249
+ @analysis_pipeline.peak_hold_frames = peak_hold_frames if @analysis_pipeline.respond_to?(:peak_hold_frames=)
250
+ @analysis_pipeline.silence_reset_frames = silence_reset_frames if @analysis_pipeline.respond_to?(:silence_reset_frames=)
175
251
  end
176
252
 
177
253
  # Apply a manual tap tempo event and lock analysis BPM when enough taps exist.
@@ -210,20 +286,51 @@ module Vizcore
210
286
  true
211
287
  end
212
288
 
289
+ def set_custom_shape_param(layer_name:, custom_shape_index:, param:, value:)
290
+ layer_key = layer_name.to_s
291
+ param_key = param.to_s.strip
292
+ index = Integer(custom_shape_index)
293
+ numeric = finite_float(value)
294
+ return custom_shape_param_overrides_snapshot if layer_key.empty? || param_key.empty? || index.negative? || numeric.nil?
295
+
296
+ @custom_shape_param_mutex.synchronize do
297
+ @custom_shape_param_overrides[layer_key] ||= {}
298
+ @custom_shape_param_overrides[layer_key][index] ||= {}
299
+ @custom_shape_param_overrides[layer_key][index][param_key] = numeric
300
+ deep_dup(@custom_shape_param_overrides)
301
+ end
302
+ rescue ArgumentError, TypeError
303
+ custom_shape_param_overrides_snapshot
304
+ end
305
+
306
+ def set_layer_param(layer_name:, param:, value:)
307
+ layer_key = layer_name.to_s
308
+ param_key = param.to_s.tr("/", ".").strip
309
+ return layer_param_overrides_snapshot if layer_key.empty? || param_key.empty?
310
+
311
+ @custom_shape_param_mutex.synchronize do
312
+ @layer_param_overrides[layer_key] ||= {}
313
+ @layer_param_overrides[layer_key][param_key] = value
314
+ deep_dup(@layer_param_overrides)
315
+ end
316
+ end
317
+
213
318
  # Build one frame payload for transport to frontend.
214
319
  #
215
320
  # @param _elapsed_seconds [Float]
216
321
  # @param samples [Array<Float>, nil]
217
322
  # @raise [Vizcore::FrameBuildError] when frame construction fails
218
323
  # @return [Hash]
219
- def build_frame(_elapsed_seconds, samples = nil)
324
+ def build_frame(elapsed_seconds, samples = nil)
220
325
  started_at_ms = monotonic_ms
326
+ apply_transport_drift_correction if file_transport_source?
221
327
  audio_samples, audio_capture_ms = capture_or_use_samples(samples)
328
+ sync_last_scene_state_with_connections
222
329
  analyzed, audio_analysis_ms = measure_ms { @analysis_pipeline.call(audio_samples) }
223
330
  scene = current_scene
224
- layers, scene_build_ms = measure_ms { build_scene_layers(scene[:layers], analyzed) }
331
+ layers, scene_build_ms = measure_ms { build_scene_layers(scene[:layers], analyzed, time: elapsed_seconds, frame: @frame_count) }
225
332
 
226
- @scene_serializer.audio_frame(
333
+ frame = @scene_serializer.audio_frame(
227
334
  timestamp: Time.now.to_f,
228
335
  audio: analyzed,
229
336
  scene_name: scene[:name],
@@ -237,6 +344,25 @@ module Vizcore
237
344
  server_frame_ms: monotonic_ms - started_at_ms
238
345
  }
239
346
  )
347
+ frame[:scene_version] = scene[:version]
348
+ full_scene = deep_dup(frame[:scene])
349
+ send_full_scene = scene[:version] != @last_sent_scene_version
350
+ if send_full_scene
351
+ full_scene[:version] = scene[:version]
352
+ frame[:scene] = full_scene
353
+ @last_sent_scene_payload = deep_dup(full_scene)
354
+ @last_sent_scene_version = scene[:version]
355
+ else
356
+ patch = scene_delta(previous: @last_sent_scene_payload, current: full_scene, scene_name: scene[:name], scene_version: scene[:version])
357
+ if patch
358
+ frame[:scene] = patch
359
+ else
360
+ frame.delete(:scene)
361
+ end
362
+ end
363
+ @last_sent_scene_payload = deep_dup(full_scene) if scene[:version] == @last_sent_scene_version
364
+ @last_frame_metrics = frame[:metrics] || {}
365
+ frame
240
366
  rescue StandardError => e
241
367
  report_error(e, context: "frame build failed")
242
368
  raise Vizcore::FrameBuildError, Vizcore::ErrorFormatting.summarize(e, context: "Frame build failed")
@@ -250,6 +376,31 @@ module Vizcore
250
376
  measure_ms { capture_samples }
251
377
  end
252
378
 
379
+ def input_manager_value(name)
380
+ return nil unless @input_manager.respond_to?(name)
381
+
382
+ @input_manager.public_send(name)
383
+ rescue StandardError
384
+ nil
385
+ end
386
+
387
+ def input_manager_status
388
+ return @input_manager.status if @input_manager.respond_to?(:status)
389
+
390
+ {}
391
+ rescue StandardError
392
+ {}
393
+ end
394
+
395
+ def formatted_last_error
396
+ error = @last_error
397
+ return nil unless error
398
+
399
+ Vizcore::ErrorFormatting.summarize(error, context: "last runtime error")
400
+ rescue StandardError
401
+ error.to_s
402
+ end
403
+
253
404
  def measure_ms
254
405
  started_at = monotonic_ms
255
406
  result = yield
@@ -260,6 +411,114 @@ module Vizcore
260
411
  Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_millisecond)
261
412
  end
262
413
 
414
+ def wall_clock_seconds
415
+ Time.now.to_f
416
+ end
417
+
418
+ def apply_transport_drift_correction
419
+ reference_position = @scene_mutex.synchronize { finite_float(@transport_reference_position) }
420
+ return if reference_position.nil?
421
+
422
+ reference_wall_seconds = @scene_mutex.synchronize { finite_float(@transport_reference_wall_seconds) }
423
+ return if reference_wall_seconds.nil?
424
+
425
+ current_position = transport_input_position_seconds
426
+ duration = transport_track_duration_seconds
427
+ return unless current_position && duration&.positive?
428
+
429
+ expected_position = file_transport_expected_position(
430
+ reference_position: reference_position,
431
+ duration: duration,
432
+ elapsed_wall_seconds: (wall_clock_seconds - reference_wall_seconds),
433
+ is_playing: transport_playing?
434
+ )
435
+ drift_seconds = circular_difference(expected_position, current_position, duration)
436
+ drift_seconds = 0.0 if drift_seconds.nil?
437
+
438
+ @scene_mutex.synchronize do
439
+ @transport_drift_seconds = drift_seconds
440
+ end
441
+
442
+ return unless drift_seconds.abs > @transport_drift_threshold_seconds
443
+
444
+ drifted_position = wrap_transport_position(current_position + drift_seconds, duration)
445
+ sync_transport_input_position(
446
+ playing: @scene_mutex.synchronize { @transport_playing },
447
+ position_seconds: drifted_position
448
+ )
449
+ end
450
+
451
+ def transport_drift_status
452
+ status = @scene_mutex.synchronize do
453
+ {
454
+ drift_seconds: @transport_drift_seconds,
455
+ threshold_seconds: @transport_drift_threshold_seconds,
456
+ reference_position: @transport_reference_position,
457
+ reference_wall_seconds: @transport_reference_wall_seconds
458
+ }
459
+ end
460
+ status.compact
461
+ rescue StandardError
462
+ {}
463
+ end
464
+
465
+ def transport_input_position_seconds
466
+ return nil unless @input_manager.respond_to?(:transport_position_seconds)
467
+ return nil if @input_manager.nil?
468
+
469
+ @input_manager.transport_position_seconds
470
+ rescue StandardError
471
+ nil
472
+ end
473
+
474
+ def transport_track_duration_seconds
475
+ return nil unless @input_manager.respond_to?(:track_duration_seconds)
476
+ return nil if @input_manager.nil?
477
+
478
+ @input_manager.track_duration_seconds
479
+ rescue StandardError
480
+ nil
481
+ end
482
+
483
+ def sync_transport_input_position(playing:, position_seconds:)
484
+ return unless @input_manager.respond_to?(:sync_transport)
485
+
486
+ @input_manager.sync_transport(playing: playing, position_seconds: position_seconds)
487
+ rescue StandardError
488
+ nil
489
+ end
490
+
491
+ def file_transport_expected_position(reference_position:, duration:, elapsed_wall_seconds:, is_playing:)
492
+ return nil unless duration.positive?
493
+
494
+ additional_position = finite_float(elapsed_wall_seconds)
495
+ return nil if additional_position.nil?
496
+
497
+ playback_position = reference_position + (is_playing ? additional_position : 0.0)
498
+ wrap_transport_position(playback_position, duration)
499
+ end
500
+
501
+ def circular_difference(expected, actual, duration)
502
+ return nil if expected.nil? || actual.nil? || !duration.positive?
503
+
504
+ diff = expected - actual
505
+ modulo = duration
506
+ return diff if modulo.zero?
507
+
508
+ ((diff + modulo / 2.0) % modulo) - modulo / 2.0
509
+ end
510
+
511
+ def wrap_transport_position(position, duration)
512
+ return 0.0 unless duration.positive?
513
+
514
+ wrapped = position % duration
515
+ wrapped.negative? ? wrapped + duration : wrapped
516
+ end
517
+
518
+ def transport_playing?
519
+ @scene_mutex.synchronize { @transport_playing }
520
+ end
521
+
263
522
  def capture_samples
264
523
  ingest_count =
265
524
  if @input_manager.respond_to?(:realtime_capture_size)
@@ -293,10 +552,38 @@ module Vizcore
293
552
  value.positive? && (value & (value - 1)).zero?
294
553
  end
295
554
 
296
- def build_scene_layers(scene_layers, analyzed)
555
+ def build_scene_layers(scene_layers, analyzed, time: 0.0, frame: 0)
297
556
  return default_scene_layers(analyzed) if scene_layers.empty?
298
557
 
299
- @mapping_resolver.resolve_layers(scene_layers: scene_layers, audio: analyzed)
558
+ @mapping_resolver.resolve_layers(
559
+ scene_layers: scene_layers,
560
+ audio: analyzed,
561
+ time: time,
562
+ frame: frame,
563
+ custom_shape_overrides: custom_shape_param_overrides_snapshot,
564
+ layer_param_overrides: layer_param_overrides_snapshot
565
+ )
566
+ end
567
+
568
+ def custom_shape_param_overrides_snapshot
569
+ @custom_shape_param_mutex.synchronize { deep_dup(@custom_shape_param_overrides) }
570
+ end
571
+
572
+ def layer_param_overrides_snapshot
573
+ @custom_shape_param_mutex.synchronize { deep_dup(@layer_param_overrides) }
574
+ end
575
+
576
+ def finite_float(value)
577
+ numeric = Float(value)
578
+ return nil unless numeric.finite?
579
+
580
+ numeric
581
+ rescue ArgumentError, TypeError
582
+ nil
583
+ end
584
+
585
+ def deep_dup(value)
586
+ Vizcore::DeepCopy.copy(value)
300
587
  end
301
588
 
302
589
  def default_scene_layers(analyzed)
@@ -318,13 +605,101 @@ module Vizcore
318
605
  def current_scene
319
606
  @scene_mutex.synchronize do
320
607
  {
608
+ version: @scene_version,
321
609
  name: @scene_name,
322
610
  layers: Array(@scene_layers)
323
611
  }
324
612
  end
325
613
  end
326
614
 
327
- def evaluate_transition(audio, frame_count:)
615
+ def scene_delta(previous:, current:, scene_name:, scene_version:)
616
+ return nil unless previous.is_a?(Hash) && current.is_a?(Hash)
617
+
618
+ prev_layers = Array(previous[:layers])
619
+ curr_layers = Array(current[:layers])
620
+ delta_layers = []
621
+
622
+ max_count = [prev_layers.length, curr_layers.length].max
623
+ (0...max_count).each do |index|
624
+ previous_layer = prev_layers[index]
625
+ current_layer = curr_layers[index]
626
+ if current_layer.nil?
627
+ delta_layers << { index: index, remove: true }
628
+ next
629
+ end
630
+
631
+ if previous_layer.nil?
632
+ delta_layers << { index: index, layer: deep_dup(current_layer) }
633
+ next
634
+ end
635
+
636
+ next if current_layer[:name].to_s == previous_layer[:name].to_s && previous_layer == current_layer
637
+
638
+ if previous_layer[:name].to_s == current_layer[:name].to_s &&
639
+ !layer_diff_needed?(previous_layer, current_layer)
640
+ param_changes = param_delta(previous_layer[:params], current_layer[:params])
641
+ delta_layers << { index: index, params: param_changes } if param_changes.any?
642
+ next
643
+ end
644
+
645
+ delta_layers << { index: index, layer: deep_dup(current_layer) }
646
+ end
647
+
648
+ return nil if delta_layers.empty?
649
+
650
+ {
651
+ name: scene_name,
652
+ version: scene_version,
653
+ schema_version: current[:schema_version],
654
+ patch: true,
655
+ layers: delta_layers
656
+ }
657
+ end
658
+
659
+ def layer_diff_needed?(previous_layer, current_layer)
660
+ comparable_fields = %i[type shader glsl glsl_source param_schema]
661
+ comparable_fields.any? do |field|
662
+ previous_layer[field].to_s != current_layer[field].to_s
663
+ end
664
+ end
665
+
666
+ def sync_last_scene_state_with_connections
667
+ current_client_count = connection_count_for_broadcast
668
+ if current_client_count > @connected_client_count
669
+ @last_sent_scene_version = nil
670
+ @last_sent_scene_payload = nil
671
+ end
672
+ @connected_client_count = current_client_count
673
+ end
674
+
675
+ def connection_count_for_broadcast
676
+ Integer(Vizcore::Server::WebSocketHandler.connection_count)
677
+ rescue StandardError
678
+ 0
679
+ end
680
+
681
+ def param_delta(previous_params, current_params)
682
+ previous_hash = Hash(previous_params || {})
683
+ current_hash = Hash(current_params || {})
684
+ keys = (previous_hash.keys | current_hash.keys)
685
+ delta = {}
686
+
687
+ keys.each do |key|
688
+ previous_value = previous_hash[key]
689
+ current_value = current_hash[key]
690
+ next if current_value == previous_value
691
+
692
+ delta[key] = deep_dup(current_value)
693
+ end
694
+
695
+ delta
696
+ end
697
+
698
+ def deep_layers_equal?(left, right)
699
+ deep_dup(left) == deep_dup(right)
700
+ end
701
+
702
+ def evaluate_transition(audio, frame_count:, elapsed_seconds:)
328
703
  return if transition_evaluation_paused?
329
704
 
330
705
  transition = @scene_mutex.synchronize do
@@ -337,10 +712,15 @@ module Vizcore
337
712
  audio: audio,
338
713
  frame_count: frame_count
339
714
  )
715
+ trigger_elapsed_seconds = transition_trigger_elapsed_seconds(
716
+ scene_name: scene[:name],
717
+ elapsed_seconds: elapsed_seconds
718
+ )
340
719
  @transition_controller.next_transition(
341
720
  scene_name: scene[:name],
342
721
  audio: trigger_audio,
343
- frame_count: trigger_frame_count
722
+ frame_count: trigger_frame_count,
723
+ elapsed_seconds: trigger_elapsed_seconds
344
724
  )
345
725
  end
346
726
  return unless transition
@@ -358,8 +738,58 @@ module Vizcore
358
738
 
359
739
  def reset_transition_trigger_counters!
360
740
  @transition_counter_scene_name = nil
741
+ @transition_counter_elapsed_scene_name = nil
361
742
  @transition_counter_frame_base = 0
362
743
  @transition_counter_beat_base = 0
744
+ @transition_counter_elapsed_base = 0.0
745
+ end
746
+
747
+ def apply_initial_timeline_entry(entry)
748
+ normalized = normalize_timeline_entry(entry)
749
+ unit = normalized[:unit]
750
+ start_position = normalized[:at]
751
+ return unless unit
752
+
753
+ if unit == "seconds"
754
+ return unless start_position.positive?
755
+
756
+ @transition_counter_elapsed_scene_name = @scene_name
757
+ @transition_counter_elapsed_base = start_position
758
+ return
759
+ end
760
+
761
+ return unless unit == "beats"
762
+ return unless start_position.positive?
763
+
764
+ @transition_counter_scene_name = @scene_name
765
+ @transition_counter_beat_base = start_position.to_i
766
+ end
767
+
768
+ def normalize_timeline_entry(entry)
769
+ values = Hash(entry || {})
770
+ return { unit: nil, at: nil } unless values
771
+
772
+ unit = values[:unit] || values["unit"]
773
+ at = values[:at] || values["at"]
774
+ {
775
+ unit: unit.to_s,
776
+ at: parse_timeline_position(at)
777
+ }
778
+ rescue StandardError
779
+ { unit: nil, at: nil }
780
+ end
781
+
782
+ def parse_timeline_position(value)
783
+ numeric = Float(value)
784
+ return nil unless numeric.finite?
785
+
786
+ numeric
787
+ rescue StandardError
788
+ begin
789
+ Integer(value)
790
+ rescue StandardError
791
+ nil
792
+ end
363
793
  end
364
794
 
365
795
  def transition_evaluation_paused?
@@ -394,11 +824,20 @@ module Vizcore
394
824
  global_beat_count = extract_beat_count(audio_hash)
395
825
  scene_beat_count = [global_beat_count - @transition_counter_beat_base, 0].max
396
826
 
397
- [scene_frame_count, audio_hash.merge(beat_count: scene_beat_count)]
827
+ [scene_frame_count, audio_hash.merge(scene_musical_counts(audio_hash, beat_count: scene_beat_count))]
398
828
  rescue StandardError
399
829
  [0, { beat_count: 0 }]
400
830
  end
401
831
 
832
+ def transition_trigger_elapsed_seconds(scene_name:, elapsed_seconds:)
833
+ sync_transition_elapsed_counter(scene_name: scene_name, elapsed_seconds: elapsed_seconds)
834
+
835
+ current_elapsed = Float(elapsed_seconds)
836
+ [current_elapsed - @transition_counter_elapsed_base, 0.0].max
837
+ rescue StandardError
838
+ 0.0
839
+ end
840
+
402
841
  def sync_transition_trigger_counters(scene_name:, audio:, frame_count:)
403
842
  normalized_scene_name = scene_name.to_s
404
843
  return if @transition_counter_scene_name == normalized_scene_name
@@ -415,23 +854,79 @@ module Vizcore
415
854
  reset_transition_trigger_counters!
416
855
  end
417
856
 
857
+ def sync_transition_elapsed_counter(scene_name:, elapsed_seconds:)
858
+ normalized_scene_name = scene_name.to_s
859
+ return if @transition_counter_elapsed_scene_name == normalized_scene_name
860
+
861
+ @transition_counter_elapsed_scene_name = normalized_scene_name
862
+ @transition_counter_elapsed_base = Float(elapsed_seconds)
863
+ rescue StandardError
864
+ @transition_counter_elapsed_base = 0.0
865
+ end
866
+
418
867
  def extract_beat_count(audio)
419
868
  Integer(audio[:beat_count] || audio["beat_count"] || 0)
420
869
  rescue StandardError
421
870
  0
422
871
  end
423
872
 
873
+ def scene_musical_counts(audio, beat_count:)
874
+ beat_index = beat_count.positive? ? beat_count - 1 : 0
875
+ beat_phase = Float(audio[:beat_phase] || audio["beat_phase"] || 0.0).clamp(0.0, 1.0)
876
+ {
877
+ beat_count: beat_count,
878
+ bar_phase: (((beat_index % 4) + beat_phase) / 4.0).clamp(0.0, 1.0),
879
+ bar_count: beat_index / 4,
880
+ phrase_count: beat_index / 32
881
+ }
882
+ rescue StandardError
883
+ { beat_count: beat_count, bar_phase: 0.0, bar_count: 0, phrase_count: 0 }
884
+ end
885
+
424
886
  def truthy_audio_beat?(audio)
425
887
  !!(audio[:beat] || audio["beat"])
426
888
  end
427
889
 
428
890
  def report_error(error, context:)
429
891
  @last_error = error
430
- @error_reporter.call(Vizcore::ErrorFormatting.summarize(error, context: context))
892
+ message = Vizcore::ErrorFormatting.summarize(error, context: context)
893
+ @error_reporter.call(message)
894
+ report_runtime_message(message, context: context, source: "runtime", event: runtime_error_event(context))
895
+ rescue StandardError
896
+ nil
897
+ end
898
+
899
+ def report_runtime_message(message, context:, source:, event: nil)
900
+ payload = {
901
+ source: source,
902
+ context: context,
903
+ message: message.to_s,
904
+ frame_id: @frame_count
905
+ }
906
+ payload[:event] = event.to_s if event && !event.to_s.empty?
907
+
908
+ WebSocketHandler.broadcast(type: "runtime_error", payload: payload)
431
909
  rescue StandardError
432
910
  nil
433
911
  end
434
912
 
913
+ def runtime_error_event(context)
914
+ case context.to_s.strip
915
+ when "frame build failed"
916
+ "frame_build_failed"
917
+ when "audio capture failed"
918
+ "audio_capture_failed"
919
+ when "audio transport sync failed"
920
+ "audio_transport_sync_failed"
921
+ when "frame broadcaster start failed"
922
+ "frame_broadcaster_start_failed"
923
+ when "transition trigger failed"
924
+ "transition_failed"
925
+ else
926
+ nil
927
+ end
928
+ end
929
+
435
930
  end
436
931
  end
437
932
  end