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
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../deep_copy"
3
4
  require_relative "../shape"
4
5
 
5
6
  module Vizcore
@@ -10,21 +11,29 @@ module Vizcore
10
11
  @mapping_state = {}
11
12
  end
12
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
+
13
21
  # @param scene_layers [Array<Hash>]
14
22
  # @param audio [Hash]
15
23
  # @return [Array<Hash>] normalized layer payloads with resolved params
16
- def resolve_layers(scene_layers:, audio:, time: 0.0, frame: 0, resolution: [1280, 720], globals: {}, custom_shape_overrides: {})
24
+ def resolve_layers(scene_layers:, audio:, time: 0.0, frame: 0, resolution: [1280, 720], globals: {}, custom_shape_overrides: {}, layer_param_overrides: {})
17
25
  normalize_scene_layers(scene_layers).map do |layer|
18
- resolve_layer(layer, audio, time: time, frame: frame, resolution: resolution, globals: globals, custom_shape_overrides: custom_shape_overrides)
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)
19
27
  end
20
28
  end
21
29
 
22
30
  private
23
31
 
24
- def resolve_layer(layer, audio, time:, frame:, resolution:, globals:, custom_shape_overrides:)
32
+ def resolve_layer(layer, audio, time:, frame:, resolution:, globals:, custom_shape_overrides:, layer_param_overrides:)
25
33
  params = deep_dup(layer[:params] || {})
26
34
  apply_custom_shape_overrides!(params, layer_name: layer[:name], custom_shape_overrides: custom_shape_overrides)
27
- merge_resolved_mappings!(params, resolve_mappings(layer[:mappings], audio, layer_name: layer[:name]))
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)
28
37
  expand_dynamic_custom_shapes!(params, layer: layer, audio: audio, time: time, frame: frame, resolution: resolution, globals: globals)
29
38
 
30
39
  output = {
@@ -39,14 +48,22 @@ module Vizcore
39
48
  output
40
49
  end
41
50
 
42
- def resolve_mappings(mappings, audio, layer_name:)
51
+ def resolve_mappings(mappings, audio, globals:, layer_name:, time:, frame:)
43
52
  Array(mappings).each_with_object({}) do |mapping, resolved|
44
53
  source = mapping[:source]
45
54
  target = mapping[:target]
46
55
  next unless source && target
47
56
 
48
- value = resolve_source_value(source, audio)
49
- 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)
50
67
  resolved[target.to_s] = value unless value.nil?
51
68
  end
52
69
  end
@@ -134,6 +151,18 @@ module Vizcore
134
151
  {}
135
152
  end
136
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
+
137
166
  def apply_custom_shape_attributes!(primitive, descriptor)
138
167
  style = Hash(descriptor[:style] || {})
139
168
  style.each do |key, value|
@@ -230,12 +259,18 @@ module Vizcore
230
259
  raise ArgumentError, "param #{name} must be numeric"
231
260
  end
232
261
 
233
- def resolve_source_value(source, audio)
262
+ def resolve_source_value(source, audio, globals: {}, time: 0.0, state_key: nil, frame: 0)
263
+ return 0.0 unless source
264
+
234
265
  case source[:kind]&.to_sym
235
266
  when :amplitude
236
267
  audio[:amplitude]
268
+ when :peak
269
+ audio[:peak]
237
270
  when :frequency_band
238
271
  audio.dig(:bands, source[:band]&.to_sym)
272
+ when :frequency_band_peak
273
+ audio.dig(:band_peaks, source[:band]&.to_sym)
239
274
  when :fft_spectrum
240
275
  audio[:fft]
241
276
  when :onset
@@ -250,13 +285,252 @@ module Vizcore
250
285
  audio[:beat_pulse]
251
286
  when :beat_count
252
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]
253
304
  when :bpm
254
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)
255
324
  else
256
325
  nil
257
326
  end
258
327
  end
259
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
+
260
534
  def resolve_onset(source, audio)
261
535
  band = source[:band]&.to_sym
262
536
  return audio[:onset] unless band
@@ -264,15 +538,18 @@ module Vizcore
264
538
  audio.dig(:onsets, band)
265
539
  end
266
540
 
267
- def apply_transform(value, transform, state_key:)
541
+ def apply_transform(value, transform, state_key:, frame:)
268
542
  return value if transform.nil? || transform.empty?
269
543
  return transform_array(value, transform) if value.is_a?(Array)
270
544
  return nil if value.is_a?(Hash) || value.nil?
271
545
 
272
- transformed = transform_scalar(value, transform)
546
+ transformed = transform_scalar(value, transform, state_key: state_key)
273
547
  return nil if transformed.nil?
274
548
 
275
- 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
276
553
  end
277
554
 
278
555
  def transform_array(value, transform)
@@ -281,11 +558,12 @@ module Vizcore
281
558
  end
282
559
  end
283
560
 
284
- def transform_scalar(value, transform, fallback: nil)
561
+ def transform_scalar(value, transform, fallback: nil, state_key: nil)
285
562
  numeric = numeric_value(value, fallback: fallback)
286
563
  return nil if numeric.nil?
287
564
 
288
565
  numeric = 0.0 if transform.key?(:deadzone) && numeric.abs < Float(transform[:deadzone])
566
+ numeric = apply_threshold(numeric, transform, state_key: state_key)
289
567
  numeric *= Float(transform[:gain]) if transform.key?(:gain)
290
568
  numeric = apply_curve(numeric, transform[:curve]) if transform[:curve]
291
569
  numeric = [numeric, Float(transform[:min])].max if transform.key?(:min)
@@ -293,6 +571,20 @@ module Vizcore
293
571
  numeric
294
572
  end
295
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
+
296
588
  def numeric_value(value, fallback:)
297
589
  return value ? 1.0 : 0.0 if value == true || value == false
298
590
 
@@ -312,12 +604,111 @@ module Vizcore
312
604
  when :ease_out
313
605
  clamped = [[value, 0.0].max, 1.0].min
314
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
315
624
  end
316
625
  end
317
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]
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
706
+ end
707
+
318
708
  def apply_smoothing(value, transform, state_key)
319
709
  return value unless transform.key?(:attack) || transform.key?(:release)
320
710
 
711
+ state_key = [:smooth, state_key]
321
712
  previous = @mapping_state[state_key]
322
713
  if previous.nil?
323
714
  @mapping_state[state_key] = value
@@ -335,16 +726,7 @@ module Vizcore
335
726
  end
336
727
 
337
728
  def deep_dup(value)
338
- case value
339
- when Hash
340
- value.each_with_object({}) do |(key, entry), output|
341
- output[key] = deep_dup(entry)
342
- end
343
- when Array
344
- value.map { |entry| deep_dup(entry) }
345
- else
346
- value
347
- end
729
+ Vizcore::DeepCopy.copy(value)
348
730
  end
349
731
 
350
732
  def deep_symbolize(value)
@@ -4,6 +4,8 @@ module Vizcore
4
4
  module DSL
5
5
  # Collects block-style mapping transform options.
6
6
  class MappingTransformBuilder
7
+ TRIGGER_MODES = %i[continuous trigger].freeze
8
+
7
9
  # @param initial [Hash]
8
10
  def initialize(initial = {})
9
11
  @values = initial.each_with_object({}) do |(key, value), output|
@@ -53,6 +55,42 @@ module Vizcore
53
55
  @values[:deadzone] = value
54
56
  end
55
57
 
58
+ # @param value [Numeric]
59
+ # @return [Numeric]
60
+ def threshold(value)
61
+ @values[:threshold] = value
62
+ end
63
+
64
+ # @param value [Numeric]
65
+ # @return [Numeric]
66
+ def hysteresis(value)
67
+ @values[:hysteresis] = value
68
+ end
69
+
70
+ # @param value [Numeric] hold duration in seconds at the runtime frame cadence
71
+ # @return [Numeric]
72
+ def hold(value)
73
+ @values[:hold] = value
74
+ end
75
+
76
+ # @param value [Numeric] per-frame decay multiplier
77
+ # @return [Numeric]
78
+ def decay(value)
79
+ @values[:decay] = value
80
+ end
81
+
82
+ # @param seconds [Numeric]
83
+ # @return [Numeric]
84
+ def cooldown(seconds)
85
+ @values[:cooldown] = seconds
86
+ end
87
+
88
+ # @param enabled [Boolean, nil]
89
+ # @return [Boolean]
90
+ def one_shot(enabled = true)
91
+ @values[:one_shot] = !!enabled
92
+ end
93
+
56
94
  # @param attack [Numeric, nil]
57
95
  # @param release [Numeric, nil]
58
96
  # @return [Hash]
@@ -62,6 +100,18 @@ module Vizcore
62
100
  @values
63
101
  end
64
102
 
103
+ # @param mode [Symbol, String]
104
+ # @return [Symbol]
105
+ def as(mode)
106
+ normalized = mode.to_sym
107
+ unless TRIGGER_MODES.include?(normalized)
108
+ raise ArgumentError, "mapping as must be :continuous or :trigger"
109
+ end
110
+
111
+ @values[:as] = normalized
112
+ normalized
113
+ end
114
+
65
115
  # @return [Hash]
66
116
  def to_h
67
117
  @values.dup