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
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../deep_copy"
4
+ require_relative "../shape"
5
+
3
6
  module Vizcore
4
7
  module DSL
5
8
  # Resolves `map` definitions into concrete per-layer parameter values.
@@ -8,20 +11,30 @@ module Vizcore
8
11
  @mapping_state = {}
9
12
  end
10
13
 
14
+ # Clear stateful transform memory such as smoothing, hold, decay, and hysteresis.
15
+ #
16
+ # @return [void]
17
+ def reset!
18
+ @mapping_state.clear
19
+ end
20
+
11
21
  # @param scene_layers [Array<Hash>]
12
22
  # @param audio [Hash]
13
23
  # @return [Array<Hash>] normalized layer payloads with resolved params
14
- def resolve_layers(scene_layers:, audio:)
24
+ def resolve_layers(scene_layers:, audio:, time: 0.0, frame: 0, resolution: [1280, 720], globals: {}, custom_shape_overrides: {}, layer_param_overrides: {})
15
25
  normalize_scene_layers(scene_layers).map do |layer|
16
- resolve_layer(layer, audio)
26
+ resolve_layer(layer, audio, time: time, frame: frame, resolution: resolution, globals: globals, custom_shape_overrides: custom_shape_overrides, layer_param_overrides: layer_param_overrides)
17
27
  end
18
28
  end
19
29
 
20
30
  private
21
31
 
22
- def resolve_layer(layer, audio)
23
- params = (layer[:params] || {}).dup
24
- merge_resolved_mappings!(params, resolve_mappings(layer[:mappings], audio, layer_name: layer[:name]))
32
+ def resolve_layer(layer, audio, time:, frame:, resolution:, globals:, custom_shape_overrides:, layer_param_overrides:)
33
+ params = deep_dup(layer[:params] || {})
34
+ apply_custom_shape_overrides!(params, layer_name: layer[:name], custom_shape_overrides: custom_shape_overrides)
35
+ merge_resolved_mappings!(params, resolve_mappings(layer[:mappings], audio, globals: globals, layer_name: layer[:name], time: time, frame: frame))
36
+ apply_layer_param_overrides!(params, layer_name: layer[:name], layer_param_overrides: layer_param_overrides)
37
+ expand_dynamic_custom_shapes!(params, layer: layer, audio: audio, time: time, frame: frame, resolution: resolution, globals: globals)
25
38
 
26
39
  output = {
27
40
  name: layer.fetch(:name).to_s,
@@ -35,14 +48,22 @@ module Vizcore
35
48
  output
36
49
  end
37
50
 
38
- def resolve_mappings(mappings, audio, layer_name:)
51
+ def resolve_mappings(mappings, audio, globals:, layer_name:, time:, frame:)
39
52
  Array(mappings).each_with_object({}) do |mapping, resolved|
40
53
  source = mapping[:source]
41
54
  target = mapping[:target]
42
55
  next unless source && target
43
56
 
44
- value = resolve_source_value(source, audio)
45
- value = apply_transform(value, mapping[:transform], state_key: [layer_name, target, source])
57
+ state_key = [layer_name, target, source]
58
+ value = resolve_source_value(
59
+ source,
60
+ audio,
61
+ globals: globals,
62
+ time: time,
63
+ state_key: state_key,
64
+ frame: frame
65
+ )
66
+ value = apply_transform(value, mapping[:transform], state_key: state_key, frame: frame)
46
67
  resolved[target.to_s] = value unless value.nil?
47
68
  end
48
69
  end
@@ -57,6 +78,107 @@ module Vizcore
57
78
  end
58
79
  end
59
80
 
81
+ def expand_dynamic_custom_shapes!(params, layer:, audio:, time:, frame:, resolution:, globals:)
82
+ descriptors = Array(params.delete(:custom_shapes) || params.delete("custom_shapes"))
83
+ return if descriptors.empty?
84
+
85
+ params[:shapes] = Array(params[:shapes])
86
+ controls = []
87
+ descriptors.each_with_index do |descriptor, index|
88
+ start_index = params[:shapes].length
89
+ expanded = expand_dynamic_custom_shape(descriptor, layer: layer, palette: params[:palette], audio: audio, time: time, frame: frame, resolution: resolution, globals: globals)
90
+ params[:shapes].concat(expanded)
91
+ controls << custom_shape_control_descriptor(descriptor, index: index, start_index: start_index, count: expanded.length)
92
+ end
93
+ params[:custom_shape_controls] = controls unless controls.empty?
94
+ end
95
+
96
+ def expand_dynamic_custom_shape(descriptor, layer:, palette:, audio:, time:, frame:, resolution:, globals:)
97
+ values = Hash(descriptor)
98
+ renderer = values.fetch(:renderer)
99
+ shape_name = values[:name] || renderer
100
+ primitives = Vizcore::Shape.expand_custom_shape(
101
+ renderer,
102
+ params: Hash(values[:params] || {}),
103
+ shape_id: values[:shape_id],
104
+ layer_name: layer[:name],
105
+ palette: Array(palette),
106
+ audio: audio,
107
+ time: time,
108
+ frame: frame,
109
+ resolution: resolution,
110
+ globals: globals,
111
+ shape_name: shape_name
112
+ )
113
+ primitives.each { |primitive| apply_custom_shape_attributes!(primitive, values) }
114
+ end
115
+
116
+ def custom_shape_control_descriptor(descriptor, index:, start_index:, count:)
117
+ values = Hash(descriptor)
118
+ {
119
+ index: index,
120
+ name: (values[:name] || values["name"] || "custom_shape").to_s,
121
+ params: deep_dup(Hash(values[:params] || values["params"] || {})),
122
+ param_schema: Array(values[:param_schema] || values["param_schema"]).map { |entry| deep_dup(entry) },
123
+ shape_indices: (start_index...(start_index + count)).to_a
124
+ }
125
+ end
126
+
127
+ def apply_custom_shape_overrides!(params, layer_name:, custom_shape_overrides:)
128
+ layer_overrides = custom_shape_layer_overrides(custom_shape_overrides, layer_name)
129
+ return if layer_overrides.empty?
130
+
131
+ descriptors = Array(params[:custom_shapes] || params["custom_shapes"])
132
+ layer_overrides.each do |index, values|
133
+ descriptor = descriptors[Integer(index)]
134
+ next unless descriptor && values.is_a?(Hash)
135
+
136
+ descriptor[:params] ||= {}
137
+ values.each do |param_name, value|
138
+ key = param_name.to_sym
139
+ descriptor[:params][key] = value
140
+ end
141
+ rescue ArgumentError, TypeError
142
+ next
143
+ end
144
+ end
145
+
146
+ def custom_shape_layer_overrides(overrides, layer_name)
147
+ values = Hash(overrides)
148
+ name = layer_name.to_s
149
+ Hash(values[name] || values[layer_name.to_sym] || {})
150
+ rescue TypeError
151
+ {}
152
+ end
153
+
154
+ def apply_layer_param_overrides!(params, layer_name:, layer_param_overrides:)
155
+ layer_overrides = custom_shape_layer_overrides(layer_param_overrides, layer_name)
156
+ layer_overrides.each do |target, value|
157
+ target_name = target.to_s
158
+ if target_name.include?(".")
159
+ assign_nested_param(params, target_name.split("."), value)
160
+ else
161
+ params[target_name.to_sym] = value
162
+ end
163
+ end
164
+ end
165
+
166
+ def apply_custom_shape_attributes!(primitive, descriptor)
167
+ style = Hash(descriptor[:style] || {})
168
+ style.each do |key, value|
169
+ symbol_key = key.to_sym
170
+ if symbol_key == :opacity && primitive.key?(:opacity)
171
+ primitive[:opacity] = numeric(style[:opacity] || style["opacity"], :opacity) * numeric(primitive[:opacity], :opacity)
172
+ else
173
+ primitive[symbol_key] = deep_dup(value) unless primitive.key?(symbol_key)
174
+ end
175
+ end
176
+
177
+ transform = Hash(descriptor[:transform] || {})
178
+ primitive[:transform] = compose_shape_transform(transform, primitive[:transform]) unless transform.empty?
179
+ primitive
180
+ end
181
+
60
182
  def assign_nested_param(container, path, value)
61
183
  key = path.shift
62
184
  if path.empty?
@@ -65,6 +187,7 @@ module Vizcore
65
187
  end
66
188
 
67
189
  next_container = nested_value(container, key)
190
+ next_container = create_nested_container(container, key, path.first) if next_container.nil?
68
191
  return unless next_container
69
192
 
70
193
  assign_nested_param(next_container, path, value)
@@ -77,6 +200,13 @@ module Vizcore
77
200
  nil
78
201
  end
79
202
 
203
+ def create_nested_container(container, key, next_key)
204
+ return unless container.is_a?(Hash)
205
+
206
+ value = integer_key?(next_key) ? [] : {}
207
+ container[key.to_sym] = value
208
+ end
209
+
80
210
  def assign_nested_value(container, key, value)
81
211
  if container.is_a?(Array) && integer_key?(key)
82
212
  container[key.to_i] = value
@@ -89,12 +219,58 @@ module Vizcore
89
219
  value.match?(/\A\d+\z/)
90
220
  end
91
221
 
92
- def resolve_source_value(source, audio)
222
+ def compose_shape_transform(parent, child)
223
+ return deep_dup(child || {}) unless parent
224
+
225
+ child ||= {}
226
+ output = deep_dup(parent)
227
+ output[:translate] = add_shape_xy(parent[:translate], child[:translate]) if child.key?(:translate)
228
+ output[:origin] = child[:origin] if child.key?(:origin)
229
+ output[:rotate] = numeric(parent[:rotate] || 0, :rotate) + numeric(child[:rotate] || 0, :rotate) if child.key?(:rotate)
230
+ output[:scale] = multiply_shape_scale(parent[:scale], child[:scale]) if child.key?(:scale)
231
+ output
232
+ end
233
+
234
+ def add_shape_xy(parent, child)
235
+ parent ||= {}
236
+ child ||= {}
237
+ {
238
+ x: numeric(parent[:x] || parent["x"] || 0, :"translate.x") + numeric(child[:x] || child["x"] || 0, :"translate.x"),
239
+ y: numeric(parent[:y] || parent["y"] || 0, :"translate.y") + numeric(child[:y] || child["y"] || 0, :"translate.y")
240
+ }
241
+ end
242
+
243
+ def multiply_shape_scale(parent, child)
244
+ parent = shape_scale_pair(parent)
245
+ child = shape_scale_pair(child)
246
+ { x: parent[:x] * child[:x], y: parent[:y] * child[:y] }
247
+ end
248
+
249
+ def shape_scale_pair(value)
250
+ return { x: numeric(value[:x] || value["x"] || 1, :"scale.x"), y: numeric(value[:y] || value["y"] || 1, :"scale.y") } if value.is_a?(Hash)
251
+
252
+ scale = numeric(value || 1, :scale)
253
+ { x: scale, y: scale }
254
+ end
255
+
256
+ def numeric(value, name)
257
+ Float(value)
258
+ rescue ArgumentError, TypeError
259
+ raise ArgumentError, "param #{name} must be numeric"
260
+ end
261
+
262
+ def resolve_source_value(source, audio, globals: {}, time: 0.0, state_key: nil, frame: 0)
263
+ return 0.0 unless source
264
+
93
265
  case source[:kind]&.to_sym
94
266
  when :amplitude
95
267
  audio[:amplitude]
268
+ when :peak
269
+ audio[:peak]
96
270
  when :frequency_band
97
271
  audio.dig(:bands, source[:band]&.to_sym)
272
+ when :frequency_band_peak
273
+ audio.dig(:band_peaks, source[:band]&.to_sym)
98
274
  when :fft_spectrum
99
275
  audio[:fft]
100
276
  when :onset
@@ -109,13 +285,252 @@ module Vizcore
109
285
  audio[:beat_pulse]
110
286
  when :beat_count
111
287
  audio[:beat_count]
288
+ when :beat_phase
289
+ audio[:beat_phase]
290
+ when :beat_2
291
+ audio[:beat_2]
292
+ when :beat_4
293
+ audio[:beat_4]
294
+ when :beat_8
295
+ audio[:beat_8]
296
+ when :beat_triplet, :triplet
297
+ audio[:beat_triplet]
298
+ when :bar_phase
299
+ audio[:bar_phase]
300
+ when :bar_count
301
+ audio[:bar_count]
302
+ when :phrase_count
303
+ audio[:phrase_count]
112
304
  when :bpm
113
305
  audio[:bpm]
306
+ when :bpm_confidence
307
+ audio[:bpm_confidence]
308
+ when :spectral_centroid
309
+ audio[:spectral_centroid]
310
+ when :spectral_rolloff
311
+ audio[:spectral_rolloff]
312
+ when :spectral_flatness
313
+ audio[:spectral_flatness]
314
+ when :spectral_flux
315
+ audio[:spectral_flux]
316
+ when :zero_crossing_rate
317
+ audio[:zero_crossing_rate]
318
+ when :global
319
+ resolve_global(source, globals)
320
+ when :lfo
321
+ resolve_lfo(source, time)
322
+ when :adsr, :envelope
323
+ resolve_envelope(source, audio, globals: globals, time: time, state_key: state_key, frame: frame)
114
324
  else
115
325
  nil
116
326
  end
117
327
  end
118
328
 
329
+ def resolve_envelope(source, audio, globals: {}, time: 0.0, state_key:, frame:)
330
+ state = envelope_state(state_key)
331
+ params = envelope_params(source)
332
+ nested = source[:source] || :kick
333
+ normalized_nested = normalize_source_descriptor(nested)
334
+ trigger_value = resolve_nested_source_value(normalized_nested, audio, globals: globals, time: time)
335
+ trigger = trigger_numeric(trigger_value)
336
+
337
+ now = normalized_time(time)
338
+ state[:time] = now
339
+ state[:last_frame] = frame
340
+ state[:gate] = trigger > params[:threshold]
341
+ state[:note_on] = state[:gate]
342
+
343
+ peak = normalize_envelope_peak(params.fetch(:peak)) * trigger
344
+ if state[:gate]
345
+ if state[:phase] == :idle || state[:phase] == :release
346
+ state[:phase] = :attack
347
+ state[:phase_started_at] = now
348
+ state[:phase_start_value] = state[:value]
349
+ state[:peak] = peak
350
+ else
351
+ state[:peak] = [state[:peak], peak].max
352
+ end
353
+ end
354
+
355
+ state[:value], state[:phase] = next_envelope_step(
356
+ state,
357
+ params: params,
358
+ now: now
359
+ )
360
+ state[:value]
361
+ rescue StandardError
362
+ 0.0
363
+ ensure
364
+ @mapping_state[state_key] = state if state_key
365
+ end
366
+
367
+ def resolve_nested_source_value(source, audio, globals:, time:)
368
+ return resolve_source_value({ kind: :amplitude }, audio, globals: globals, time: time) if source.nil?
369
+
370
+ nested_kind = source[:kind]&.to_sym
371
+ return 0.0 if nested_kind == :adsr || nested_kind == :envelope
372
+
373
+ resolve_source_value(source, audio, globals: globals, time: time)
374
+ rescue StandardError
375
+ 0.0
376
+ end
377
+
378
+ def normalize_source_descriptor(source)
379
+ return source if source.is_a?(Hash) && source[:kind]
380
+
381
+ { kind: source.to_sym }
382
+ rescue StandardError
383
+ nil
384
+ end
385
+
386
+ def resolve_lfo(source, time)
387
+ rate = Float(source[:rate] || 1.0)
388
+ phase = Float(source[:phase] || 0.0)
389
+ position = (Float(time) * rate + phase) % 1.0
390
+ case source[:wave]&.to_sym
391
+ when :triangle
392
+ 1.0 - ((position * 2.0) - 1.0).abs
393
+ when :saw
394
+ position
395
+ when :square
396
+ position < 0.5 ? 1.0 : 0.0
397
+ else
398
+ (Math.sin(position * Math::PI * 2.0) + 1.0) * 0.5
399
+ end
400
+ rescue ArgumentError, TypeError
401
+ 0.0
402
+ end
403
+
404
+ def resolve_global(source, globals)
405
+ name = source[:name]&.to_sym
406
+ return nil unless name
407
+
408
+ values = Hash(globals || {})
409
+ values[name] || values[name.to_s]
410
+ rescue StandardError
411
+ nil
412
+ end
413
+
414
+ def envelope_state(state_key)
415
+ return {} unless state_key
416
+
417
+ @mapping_state[state_key] ||= {
418
+ phase: :idle,
419
+ value: 0.0,
420
+ peak: 0.0,
421
+ phase_started_at: 0.0,
422
+ phase_start_value: 0.0,
423
+ time: 0.0,
424
+ note_on: false,
425
+ gate: false
426
+ }
427
+ end
428
+
429
+ def envelope_params(source)
430
+ {
431
+ attack: Float(source[:attack] || 0.02),
432
+ decay: Float(source[:decay] || 0.08),
433
+ sustain: Float(source[:sustain] || 0.7).clamp(0.0, 1.0),
434
+ release: Float(source[:release] || 0.16),
435
+ threshold: Float(source[:threshold] || 0.0),
436
+ peak: Float(source[:peak] || 1.0)
437
+ }
438
+ rescue StandardError
439
+ { attack: 0.02, decay: 0.08, sustain: 0.7, release: 0.16, threshold: 0.0, peak: 1.0 }
440
+ end
441
+
442
+ def normalized_time(value)
443
+ numeric = Float(value)
444
+ numeric.nan? ? 0.0 : numeric
445
+ rescue StandardError
446
+ 0.0
447
+ end
448
+
449
+ def normalize_envelope_peak(value)
450
+ value = Float(value)
451
+ value.nan? ? 1.0 : value
452
+ rescue StandardError
453
+ 1.0
454
+ end
455
+
456
+ def next_envelope_step(state, params:, now:)
457
+ phase = state[:phase] || :idle
458
+ if phase == :attack
459
+ return [state[:peak], :sustain] if params[:attack] <= 0.0
460
+
461
+ elapsed = now - state[:phase_started_at]
462
+ if elapsed >= params[:attack]
463
+ state[:phase_started_at] = now
464
+ state[:phase_start_value] = state[:peak]
465
+ return [state[:peak], :decay]
466
+ end
467
+
468
+ ratio = [elapsed / params[:attack], 1.0].min
469
+ value = state[:phase_start_value] + (state[:peak] - state[:phase_start_value]) * ratio
470
+ return [value, :attack]
471
+ end
472
+
473
+ if phase == :decay
474
+ return [state[:peak] * params[:sustain], :sustain] if params[:decay] <= 0.0
475
+
476
+ elapsed = now - state[:phase_started_at]
477
+ target = state[:peak] * params[:sustain]
478
+ if elapsed >= params[:decay]
479
+ state[:phase_started_at] = now
480
+ state[:phase_start_value] = target
481
+ return [target, :sustain]
482
+ end
483
+
484
+ ratio = [elapsed / params[:decay], 1.0].min
485
+ value = state[:phase_start_value] + (target - state[:phase_start_value]) * ratio
486
+ return [value, :decay]
487
+ end
488
+
489
+ if phase == :sustain
490
+ return state[:phase_start_value], :sustain if state[:gate]
491
+
492
+ state[:phase] = :release
493
+ state[:phase_started_at] = now
494
+ state[:phase_start_value] = state[:value]
495
+ return [state[:value], :release]
496
+ end
497
+
498
+ if phase == :release
499
+ return [0.0, :idle] if params[:release] <= 0.0
500
+
501
+ elapsed = now - state[:phase_started_at]
502
+ if elapsed >= params[:release]
503
+ state[:phase_started_at] = now
504
+ state[:phase_start_value] = 0.0
505
+ return [0.0, :idle]
506
+ end
507
+
508
+ ratio = [elapsed / params[:release], 1.0].min
509
+ value = state[:phase_start_value] * (1.0 - ratio)
510
+ return [value, :release]
511
+ end
512
+
513
+ if phase == :idle
514
+ return [0.0, :idle] unless state[:gate] && params[:attack] > 0.0
515
+
516
+ state[:phase_started_at] = now
517
+ state[:phase_start_value] = 0.0
518
+ return [0.0, :attack]
519
+ end
520
+
521
+ [0.0, :idle]
522
+ end
523
+
524
+ def trigger_numeric(value)
525
+ return 0.0 if value == false || value == 0
526
+ return 1.0 if value == true
527
+ return 0.0 if value.nil?
528
+
529
+ Float(value)
530
+ rescue StandardError
531
+ 0.0
532
+ end
533
+
119
534
  def resolve_onset(source, audio)
120
535
  band = source[:band]&.to_sym
121
536
  return audio[:onset] unless band
@@ -123,15 +538,18 @@ module Vizcore
123
538
  audio.dig(:onsets, band)
124
539
  end
125
540
 
126
- def apply_transform(value, transform, state_key:)
541
+ def apply_transform(value, transform, state_key:, frame:)
127
542
  return value if transform.nil? || transform.empty?
128
543
  return transform_array(value, transform) if value.is_a?(Array)
129
544
  return nil if value.is_a?(Hash) || value.nil?
130
545
 
131
- transformed = transform_scalar(value, transform)
546
+ transformed = transform_scalar(value, transform, state_key: state_key)
132
547
  return nil if transformed.nil?
133
548
 
134
- apply_smoothing(transformed, transform, state_key)
549
+ transformed = apply_trigger_mode(transformed, transform, state_key: state_key) if transform[:as] == :trigger
550
+ transformed = apply_event_shaping(transformed, transform, state_key: state_key, frame: frame)
551
+ return apply_smoothing(transformed, transform, state_key) unless transform[:as] == :trigger
552
+ transformed
135
553
  end
136
554
 
137
555
  def transform_array(value, transform)
@@ -140,11 +558,12 @@ module Vizcore
140
558
  end
141
559
  end
142
560
 
143
- def transform_scalar(value, transform, fallback: nil)
561
+ def transform_scalar(value, transform, fallback: nil, state_key: nil)
144
562
  numeric = numeric_value(value, fallback: fallback)
145
563
  return nil if numeric.nil?
146
564
 
147
565
  numeric = 0.0 if transform.key?(:deadzone) && numeric.abs < Float(transform[:deadzone])
566
+ numeric = apply_threshold(numeric, transform, state_key: state_key)
148
567
  numeric *= Float(transform[:gain]) if transform.key?(:gain)
149
568
  numeric = apply_curve(numeric, transform[:curve]) if transform[:curve]
150
569
  numeric = [numeric, Float(transform[:min])].max if transform.key?(:min)
@@ -152,6 +571,20 @@ module Vizcore
152
571
  numeric
153
572
  end
154
573
 
574
+ def apply_threshold(value, transform, state_key:)
575
+ return value unless transform.key?(:threshold) || transform.key?(:hysteresis)
576
+
577
+ threshold = Float(transform.fetch(:threshold, 0.5))
578
+ hysteresis = Float(transform.fetch(:hysteresis, 0.0))
579
+ return value >= threshold ? value : 0.0 if hysteresis <= 0.0 || state_key.nil?
580
+
581
+ key = [:hysteresis, state_key]
582
+ active = !!@mapping_state[key]
583
+ active = value >= (active ? threshold - hysteresis : threshold)
584
+ @mapping_state[key] = active
585
+ active ? value : 0.0
586
+ end
587
+
155
588
  def numeric_value(value, fallback:)
156
589
  return value ? 1.0 : 0.0 if value == true || value == false
157
590
 
@@ -171,12 +604,111 @@ module Vizcore
171
604
  when :ease_out
172
605
  clamped = [[value, 0.0].max, 1.0].min
173
606
  1.0 - ((1.0 - clamped) * (1.0 - clamped))
607
+ when :ease_in
608
+ clamped = [[value, 0.0].max, 1.0].min
609
+ clamped * clamped
610
+ when :ease_in_out
611
+ clamped = [[value, 0.0].max, 1.0].min
612
+ clamped < 0.5 ? 2.0 * clamped * clamped : 1.0 - ((-2.0 * clamped + 2.0)**2 / 2.0)
613
+ when :smoothstep
614
+ clamped = [[value, 0.0].max, 1.0].min
615
+ clamped * clamped * (3.0 - 2.0 * clamped)
616
+ when :exp
617
+ clamped = [[value, 0.0].max, 1.0].min
618
+ ((Math.exp(clamped) - 1.0) / (Math::E - 1.0)).clamp(0.0, 1.0)
619
+ when :log
620
+ clamped = [[value, 0.0].max, 1.0].min
621
+ Math.log1p(clamped * (Math::E - 1.0))
622
+ when :step
623
+ value >= 0.5 ? 1.0 : 0.0
624
+ end
625
+ end
626
+
627
+ def apply_trigger_mode(value, _transform, state_key:)
628
+ key = [:trigger, state_key]
629
+ active = value.to_f > 0.0
630
+ previous = !!@mapping_state[key]
631
+ @mapping_state[key] = active
632
+ (active && !previous) ? 1.0 : 0.0
633
+ end
634
+
635
+ def apply_event_shaping(value, transform, state_key:, frame:)
636
+ shaped = value
637
+ shaped = apply_cooldown(shaped, transform, state_key: state_key, frame: frame) if transform.key?(:cooldown)
638
+ shaped = apply_one_shot(shaped, transform, state_key: state_key) if transform[:one_shot]
639
+ shaped = apply_hold(shaped, transform, state_key: state_key, frame: frame) if transform.key?(:hold)
640
+ shaped = apply_decay(shaped, transform, state_key: state_key) if transform.key?(:decay)
641
+ shaped
642
+ end
643
+
644
+ def apply_cooldown(value, transform, state_key:, frame:)
645
+ cooldown_frames = (Float(transform[:cooldown]) * 60.0).ceil
646
+ return value unless cooldown_frames.positive?
647
+
648
+ key = [:cooldown, state_key]
649
+ state = @mapping_state[key] || { until_frame: 0 }
650
+ current_frame = Integer(frame)
651
+ return value unless value.to_f > 0.0
652
+
653
+ if current_frame >= state[:until_frame]
654
+ state[:until_frame] = current_frame + cooldown_frames
655
+ @mapping_state[key] = state
656
+ value
657
+ else
658
+ 0.0
659
+ end
660
+ rescue StandardError
661
+ value
662
+ end
663
+
664
+ def apply_one_shot(value, _transform, state_key:)
665
+ key = [:one_shot, state_key]
666
+
667
+ active = value.to_f > 0.0
668
+ return 0.0 unless active
669
+
670
+ fired = !!@mapping_state[key]
671
+ return 0.0 if fired
672
+
673
+ @mapping_state[key] = true
674
+ value
675
+ rescue StandardError
676
+ value
677
+ end
678
+
679
+ def apply_hold(value, transform, state_key:, frame:)
680
+ hold_frames = (Float(transform[:hold]) * 60.0).ceil
681
+ return value unless hold_frames.positive?
682
+
683
+ key = [:hold, state_key]
684
+ state = @mapping_state[key] || { until_frame: -1, value: 0.0 }
685
+ current_frame = Integer(frame)
686
+ if value.to_f.positive?
687
+ state = { until_frame: current_frame + hold_frames, value: value }
688
+ elsif current_frame <= state[:until_frame]
689
+ value = state[:value]
174
690
  end
691
+ @mapping_state[key] = state
692
+ value
693
+ rescue StandardError
694
+ value
695
+ end
696
+
697
+ def apply_decay(value, transform, state_key:)
698
+ decay = Float(transform[:decay]).clamp(0.0, 1.0)
699
+ key = [:decay, state_key]
700
+ previous = @mapping_state[key].to_f
701
+ output = [value.to_f, previous * decay].max
702
+ @mapping_state[key] = output
703
+ output
704
+ rescue StandardError
705
+ value
175
706
  end
176
707
 
177
708
  def apply_smoothing(value, transform, state_key)
178
709
  return value unless transform.key?(:attack) || transform.key?(:release)
179
710
 
711
+ state_key = [:smooth, state_key]
180
712
  previous = @mapping_state[state_key]
181
713
  if previous.nil?
182
714
  @mapping_state[state_key] = value
@@ -193,6 +725,10 @@ module Vizcore
193
725
  Array(scene_layers).map { |layer| deep_symbolize(layer) }
194
726
  end
195
727
 
728
+ def deep_dup(value)
729
+ Vizcore::DeepCopy.copy(value)
730
+ end
731
+
196
732
  def deep_symbolize(value)
197
733
  case value
198
734
  when Hash