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
@@ -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,21 +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
70
99
  @custom_shape_param_overrides = {}
100
+ @layer_param_overrides = {}
71
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
72
106
  @transport_playing = initial_transport_playing_state
73
107
  reset_transition_trigger_counters!
108
+ apply_initial_timeline_entry(initial_timeline_entry)
74
109
  @tap_tempo = Vizcore::Analysis::TapTempo.new
75
110
  @frame_scheduler = frame_scheduler || Vizcore::Renderer::FrameScheduler.new(frame_rate: FRAME_RATE) do |elapsed|
76
111
  tick(elapsed)
@@ -109,15 +144,41 @@ module Vizcore
109
144
  current_scene
110
145
  end
111
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
+
112
168
  # Synchronize external playback transport (e.g. browser audio element) with the input source.
113
169
  #
114
170
  # @param playing [Boolean]
115
171
  # @param position_seconds [Numeric]
116
172
  # @return [void]
117
173
  def sync_transport(playing:, position_seconds:)
174
+ position = finite_float(position_seconds)
118
175
  @scene_mutex.synchronize do
119
176
  @transport_playing = !!playing
120
- 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
121
182
  end
122
183
  return unless @input_manager.respond_to?(:sync_transport)
123
184
 
@@ -135,7 +196,7 @@ module Vizcore
135
196
  @frame_count += 1
136
197
  frame = build_frame(elapsed_seconds, samples)
137
198
  WebSocketHandler.broadcast(type: "audio_frame", payload: frame)
138
- evaluate_transition(frame[:audio], frame_count: @frame_count)
199
+ evaluate_transition(frame[:audio], frame_count: @frame_count, elapsed_seconds: elapsed_seconds)
139
200
  frame
140
201
  end
141
202
 
@@ -146,8 +207,17 @@ module Vizcore
146
207
  # @return [void]
147
208
  def update_scene(scene_name:, scene_layers:)
148
209
  @scene_mutex.synchronize do
149
- @scene_name = scene_name.to_s
150
- @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!)
151
221
  reset_transition_trigger_counters!
152
222
  end
153
223
  end
@@ -169,11 +239,15 @@ module Vizcore
169
239
  # @param bpm [Numeric, nil]
170
240
  # @param bpm_lock [Boolean]
171
241
  # @return [void]
172
- 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)
173
243
  return unless @analysis_pipeline.respond_to?(:audio_normalize=)
174
244
 
175
245
  @analysis_pipeline.audio_normalize = audio_normalize
176
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=)
177
251
  end
178
252
 
179
253
  # Apply a manual tap tempo event and lock analysis BPM when enough taps exist.
@@ -229,6 +303,18 @@ module Vizcore
229
303
  custom_shape_param_overrides_snapshot
230
304
  end
231
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
+
232
318
  # Build one frame payload for transport to frontend.
233
319
  #
234
320
  # @param _elapsed_seconds [Float]
@@ -237,12 +323,14 @@ module Vizcore
237
323
  # @return [Hash]
238
324
  def build_frame(elapsed_seconds, samples = nil)
239
325
  started_at_ms = monotonic_ms
326
+ apply_transport_drift_correction if file_transport_source?
240
327
  audio_samples, audio_capture_ms = capture_or_use_samples(samples)
328
+ sync_last_scene_state_with_connections
241
329
  analyzed, audio_analysis_ms = measure_ms { @analysis_pipeline.call(audio_samples) }
242
330
  scene = current_scene
243
331
  layers, scene_build_ms = measure_ms { build_scene_layers(scene[:layers], analyzed, time: elapsed_seconds, frame: @frame_count) }
244
332
 
245
- @scene_serializer.audio_frame(
333
+ frame = @scene_serializer.audio_frame(
246
334
  timestamp: Time.now.to_f,
247
335
  audio: analyzed,
248
336
  scene_name: scene[:name],
@@ -256,6 +344,25 @@ module Vizcore
256
344
  server_frame_ms: monotonic_ms - started_at_ms
257
345
  }
258
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
259
366
  rescue StandardError => e
260
367
  report_error(e, context: "frame build failed")
261
368
  raise Vizcore::FrameBuildError, Vizcore::ErrorFormatting.summarize(e, context: "Frame build failed")
@@ -269,6 +376,31 @@ module Vizcore
269
376
  measure_ms { capture_samples }
270
377
  end
271
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
+
272
404
  def measure_ms
273
405
  started_at = monotonic_ms
274
406
  result = yield
@@ -279,6 +411,114 @@ module Vizcore
279
411
  Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_millisecond)
280
412
  end
281
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
+
282
522
  def capture_samples
283
523
  ingest_count =
284
524
  if @input_manager.respond_to?(:realtime_capture_size)
@@ -320,7 +560,8 @@ module Vizcore
320
560
  audio: analyzed,
321
561
  time: time,
322
562
  frame: frame,
323
- custom_shape_overrides: custom_shape_param_overrides_snapshot
563
+ custom_shape_overrides: custom_shape_param_overrides_snapshot,
564
+ layer_param_overrides: layer_param_overrides_snapshot
324
565
  )
325
566
  end
326
567
 
@@ -328,6 +569,10 @@ module Vizcore
328
569
  @custom_shape_param_mutex.synchronize { deep_dup(@custom_shape_param_overrides) }
329
570
  end
330
571
 
572
+ def layer_param_overrides_snapshot
573
+ @custom_shape_param_mutex.synchronize { deep_dup(@layer_param_overrides) }
574
+ end
575
+
331
576
  def finite_float(value)
332
577
  numeric = Float(value)
333
578
  return nil unless numeric.finite?
@@ -338,14 +583,7 @@ module Vizcore
338
583
  end
339
584
 
340
585
  def deep_dup(value)
341
- case value
342
- when Hash
343
- value.each_with_object({}) { |(key, entry), output| output[key] = deep_dup(entry) }
344
- when Array
345
- value.map { |entry| deep_dup(entry) }
346
- else
347
- value
348
- end
586
+ Vizcore::DeepCopy.copy(value)
349
587
  end
350
588
 
351
589
  def default_scene_layers(analyzed)
@@ -367,13 +605,101 @@ module Vizcore
367
605
  def current_scene
368
606
  @scene_mutex.synchronize do
369
607
  {
608
+ version: @scene_version,
370
609
  name: @scene_name,
371
610
  layers: Array(@scene_layers)
372
611
  }
373
612
  end
374
613
  end
375
614
 
376
- 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:)
377
703
  return if transition_evaluation_paused?
378
704
 
379
705
  transition = @scene_mutex.synchronize do
@@ -386,10 +712,15 @@ module Vizcore
386
712
  audio: audio,
387
713
  frame_count: frame_count
388
714
  )
715
+ trigger_elapsed_seconds = transition_trigger_elapsed_seconds(
716
+ scene_name: scene[:name],
717
+ elapsed_seconds: elapsed_seconds
718
+ )
389
719
  @transition_controller.next_transition(
390
720
  scene_name: scene[:name],
391
721
  audio: trigger_audio,
392
- frame_count: trigger_frame_count
722
+ frame_count: trigger_frame_count,
723
+ elapsed_seconds: trigger_elapsed_seconds
393
724
  )
394
725
  end
395
726
  return unless transition
@@ -407,8 +738,58 @@ module Vizcore
407
738
 
408
739
  def reset_transition_trigger_counters!
409
740
  @transition_counter_scene_name = nil
741
+ @transition_counter_elapsed_scene_name = nil
410
742
  @transition_counter_frame_base = 0
411
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
412
793
  end
413
794
 
414
795
  def transition_evaluation_paused?
@@ -443,11 +824,20 @@ module Vizcore
443
824
  global_beat_count = extract_beat_count(audio_hash)
444
825
  scene_beat_count = [global_beat_count - @transition_counter_beat_base, 0].max
445
826
 
446
- [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))]
447
828
  rescue StandardError
448
829
  [0, { beat_count: 0 }]
449
830
  end
450
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
+
451
841
  def sync_transition_trigger_counters(scene_name:, audio:, frame_count:)
452
842
  normalized_scene_name = scene_name.to_s
453
843
  return if @transition_counter_scene_name == normalized_scene_name
@@ -464,23 +854,79 @@ module Vizcore
464
854
  reset_transition_trigger_counters!
465
855
  end
466
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
+
467
867
  def extract_beat_count(audio)
468
868
  Integer(audio[:beat_count] || audio["beat_count"] || 0)
469
869
  rescue StandardError
470
870
  0
471
871
  end
472
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
+
473
886
  def truthy_audio_beat?(audio)
474
887
  !!(audio[:beat] || audio["beat"])
475
888
  end
476
889
 
477
890
  def report_error(error, context:)
478
891
  @last_error = error
479
- @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))
480
895
  rescue StandardError
481
896
  nil
482
897
  end
483
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)
909
+ rescue StandardError
910
+ nil
911
+ end
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
+
484
930
  end
485
931
  end
486
932
  end