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.
- checksums.yaml +4 -4
- data/README.md +66 -648
- data/docs/assets/playground-worker.js +373 -0
- data/docs/assets/playground.css +440 -0
- data/docs/assets/playground.js +652 -0
- data/docs/index.html +2 -1
- data/docs/playground.html +81 -0
- data/docs/shape_dsl.md +269 -0
- data/frontend/index.html +50 -2
- data/frontend/src/audio-inspector.js +9 -0
- data/frontend/src/custom-shape-param-controls.js +106 -0
- data/frontend/src/live-controls.js +219 -7
- data/frontend/src/main.js +703 -45
- data/frontend/src/mapping-target-selector.js +109 -0
- data/frontend/src/midi-learn.js +22 -2
- data/frontend/src/performance-monitor.js +137 -1
- data/frontend/src/renderer/engine.js +401 -11
- data/frontend/src/renderer/layer-manager.js +490 -75
- data/frontend/src/runtime-control-preset.js +44 -0
- data/frontend/src/scene-patches.js +159 -0
- data/frontend/src/shader-error-overlay.js +1 -0
- data/frontend/src/shape-editor-controls.js +157 -0
- data/frontend/src/visuals/geometry.js +425 -27
- data/frontend/src/visuals/image-renderer.js +19 -0
- data/frontend/src/visuals/particle-system.js +10 -0
- data/frontend/src/visuals/shape-renderer.js +488 -0
- data/frontend/src/visuals/spectrogram-renderer.js +14 -0
- data/frontend/src/visuals/svg-arc.js +104 -0
- data/frontend/src/visuals/text-renderer.js +13 -0
- data/frontend/src/websocket-client.js +6 -0
- data/lib/vizcore/analysis/adaptive_normalizer.rb +20 -2
- data/lib/vizcore/analysis/bpm_estimator.rb +18 -8
- data/lib/vizcore/analysis/feature_recorder.rb +117 -7
- data/lib/vizcore/analysis/feature_replay.rb +48 -9
- data/lib/vizcore/analysis/pipeline.rb +258 -9
- data/lib/vizcore/analysis/tap_tempo.rb +17 -2
- data/lib/vizcore/audio/calibration.rb +156 -0
- data/lib/vizcore/audio/file_input.rb +28 -0
- data/lib/vizcore/audio/input_manager.rb +36 -1
- data/lib/vizcore/audio/midi_input.rb +5 -0
- data/lib/vizcore/audio/ring_buffer.rb +22 -0
- data/lib/vizcore/audio.rb +1 -0
- data/lib/vizcore/cli/dsl_reference.rb +65 -9
- data/lib/vizcore/cli/plugin_checker.rb +93 -0
- data/lib/vizcore/cli/scene_diagnostics.rb +2 -2
- data/lib/vizcore/cli/scene_inspector.rb +35 -1
- data/lib/vizcore/cli/scene_validator.rb +573 -33
- data/lib/vizcore/cli/shader_template.rb +7 -2
- data/lib/vizcore/cli/shader_uniform_docs.rb +11 -0
- data/lib/vizcore/cli.rb +268 -15
- data/lib/vizcore/config.rb +40 -3
- data/lib/vizcore/control_preset.rb +29 -0
- data/lib/vizcore/deep_copy.rb +21 -0
- data/lib/vizcore/dsl/color_helpers.rb +155 -0
- data/lib/vizcore/dsl/engine.rb +219 -23
- data/lib/vizcore/dsl/layer_builder.rb +1072 -21
- data/lib/vizcore/dsl/layer_group_builder.rb +10 -12
- data/lib/vizcore/dsl/layout_helpers.rb +290 -0
- data/lib/vizcore/dsl/mapping_preset_builder.rb +41 -0
- data/lib/vizcore/dsl/mapping_resolver.rb +549 -13
- data/lib/vizcore/dsl/mapping_transform_builder.rb +50 -0
- data/lib/vizcore/dsl/midi_map_executor.rb +219 -23
- data/lib/vizcore/dsl/reaction_builder.rb +1 -0
- data/lib/vizcore/dsl/scene_builder.rb +83 -13
- data/lib/vizcore/dsl/shader_source_resolver.rb +1 -10
- data/lib/vizcore/dsl/style_builder.rb +3 -0
- data/lib/vizcore/dsl/timeline_builder.rb +91 -8
- data/lib/vizcore/dsl/transition_controller.rb +157 -18
- data/lib/vizcore/dsl.rb +2 -0
- data/lib/vizcore/layer_catalog.rb +5 -2
- data/lib/vizcore/plugin_asset_policy.rb +55 -0
- data/lib/vizcore/project_manifest.rb +12 -2
- data/lib/vizcore/renderer/render_sequence.rb +104 -13
- data/lib/vizcore/renderer/scene_frame_source.rb +190 -12
- data/lib/vizcore/renderer/scene_serializer.rb +38 -0
- data/lib/vizcore/renderer/snapshot.rb +4 -3
- data/lib/vizcore/renderer/snapshot_renderer.rb +641 -23
- data/lib/vizcore/scene_trust.rb +31 -0
- data/lib/vizcore/server/frame_broadcaster.rb +513 -18
- data/lib/vizcore/server/rack_app.rb +151 -4
- data/lib/vizcore/server/runner.rb +697 -82
- data/lib/vizcore/server/websocket_handler.rb +236 -14
- data/lib/vizcore/server.rb +21 -0
- data/lib/vizcore/shape.rb +742 -0
- data/lib/vizcore/sync/osc_message.rb +66 -9
- data/lib/vizcore/version.rb +1 -1
- data/lib/vizcore.rb +34 -0
- data/scripts/browser_capture.mjs +31 -2
- data/sig/vizcore.rbs +154 -4
- 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?(
|
|
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
|
-
|
|
148
|
-
|
|
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(
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|