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
@@ -4,7 +4,7 @@ module Vizcore
4
4
  module Analysis
5
5
  # Estimates tempo (BPM) from beat onsets using lag autocorrelation.
6
6
  class BPMEstimator
7
- attr_reader :frame_rate
7
+ attr_reader :confidence, :frame_rate
8
8
 
9
9
  # @param frame_rate [Float] analysis frames per second
10
10
  # @param min_bpm [Float] minimum candidate BPM
@@ -21,6 +21,7 @@ module Vizcore
21
21
  @min_onsets = Integer(min_onsets)
22
22
  @history = []
23
23
  @current_bpm = 0.0
24
+ @confidence = 0.0
24
25
  end
25
26
 
26
27
  # @param beat [Boolean] whether the current frame contains a beat onset
@@ -29,10 +30,17 @@ module Vizcore
29
30
  @history << (beat ? 1.0 : 0.0)
30
31
  @history.shift while @history.length > @history_size
31
32
 
32
- return @current_bpm if onset_count < @min_onsets
33
+ if onset_count < @min_onsets
34
+ @confidence = 0.0
35
+ return @current_bpm
36
+ end
33
37
 
34
- candidate = estimate_candidate_bpm
35
- return @current_bpm if candidate <= 0.0
38
+ candidate, confidence = estimate_candidate_bpm
39
+ if candidate <= 0.0
40
+ @confidence = 0.0
41
+ return @current_bpm
42
+ end
43
+ @confidence = confidence
36
44
 
37
45
  @current_bpm =
38
46
  if @current_bpm <= 0.0
@@ -50,6 +58,7 @@ module Vizcore
50
58
  def reset
51
59
  @history.clear
52
60
  @current_bpm = 0.0
61
+ @confidence = 0.0
53
62
  end
54
63
 
55
64
  private
@@ -60,11 +69,11 @@ module Vizcore
60
69
 
61
70
  def estimate_candidate_bpm
62
71
  n = @history.length
63
- return 0.0 if n < 2
72
+ return [0.0, 0.0] if n < 2
64
73
 
65
74
  min_lag = [(60.0 * @frame_rate / @max_bpm).round, 1].max
66
75
  max_lag = [(60.0 * @frame_rate / @min_bpm).round, n - 1].min
67
- return 0.0 if min_lag > max_lag
76
+ return [0.0, 0.0] if min_lag > max_lag
68
77
 
69
78
  best_lag = nil
70
79
  best_score = -Float::INFINITY
@@ -77,9 +86,10 @@ module Vizcore
77
86
  best_lag = lag
78
87
  end
79
88
 
80
- return 0.0 unless best_lag && best_score.positive?
89
+ return [0.0, 0.0] unless best_lag && best_score.positive?
81
90
 
82
- (60.0 * @frame_rate / best_lag).clamp(@min_bpm, @max_bpm)
91
+ bpm = (60.0 * @frame_rate / best_lag).clamp(@min_bpm, @max_bpm)
92
+ [bpm, (best_score / onset_count.to_f).clamp(0.0, 1.0)]
83
93
  end
84
94
 
85
95
  def autocorrelation_at_lag(lag)
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "fileutils"
4
+ require "digest"
4
5
  require "json"
5
6
  require "pathname"
6
7
  require_relative "../audio/file_input"
@@ -22,7 +23,8 @@ module Vizcore
22
23
  noise_gate: Pipeline::DEFAULT_NOISE_GATE,
23
24
  audio_normalize: nil,
24
25
  bpm: nil,
25
- bpm_lock: false
26
+ bpm_lock: false,
27
+ cache_root: nil
26
28
  )
27
29
  @audio_file = Pathname.new(audio_file.to_s).expand_path
28
30
  @frames = normalize_frame_count(frames)
@@ -31,6 +33,16 @@ module Vizcore
31
33
  @audio_normalize = audio_normalize
32
34
  @bpm = bpm
33
35
  @bpm_lock = bpm_lock
36
+ @cache_root = normalize_cache_root(cache_root)
37
+ end
38
+
39
+ # Absolute cache file path for current recorder settings.
40
+ #
41
+ # @return [Pathname, nil]
42
+ def cache_path
43
+ return nil unless @cache_root
44
+
45
+ @cache_root.join("#{cache_key}.json")
34
46
  end
35
47
 
36
48
  # @param out [String, Pathname] JSON output path
@@ -38,18 +50,116 @@ module Vizcore
38
50
  def write(out:)
39
51
  output_path = Pathname.new(out.to_s).expand_path
40
52
  FileUtils.mkdir_p(output_path.dirname)
41
- payload = record
42
- output_path.write("#{JSON.pretty_generate(payload)}\n")
43
- {
44
- path: output_path,
53
+
54
+ if cache_path
55
+ cached_payload = load_cached_payload(cache_path)
56
+ output_payload = cached_payload if cached_payload
57
+ end
58
+
59
+ output_payload ||= record
60
+ output_path.write("#{JSON.pretty_generate(output_payload)}\n")
61
+ write_cached_output(output_payload) if cache_path && cache_path != output_path
62
+
63
+ metadata_from_payload(output_payload, path: output_path)
64
+ end
65
+
66
+ # Stable hash key for this recorder configuration.
67
+ #
68
+ # @return [String]
69
+ def cache_key
70
+ self.class.cache_key(
71
+ version: VERSION,
72
+ audio_file: @audio_file,
45
73
  frames: @frames,
46
74
  fps: @fps,
47
- sample_rate: payload.fetch("metadata").fetch("sample_rate")
48
- }
75
+ noise_gate: @noise_gate,
76
+ audio_normalize: @audio_normalize,
77
+ bpm: @bpm,
78
+ bpm_lock: @bpm_lock
79
+ )
80
+ end
81
+
82
+ # @return [Hash] recorder metadata
83
+ def self.cache_key(version:, audio_file:, frames:, fps:, noise_gate:, audio_normalize:, bpm:, bpm_lock:)
84
+ audio_path = Pathname.new(audio_file.to_s).expand_path
85
+ Digest::SHA256.hexdigest(
86
+ JSON.generate(
87
+ {
88
+ version: version,
89
+ audio_file: audio_path.to_s,
90
+ audio_file_size: audio_file_size(audio_path),
91
+ audio_file_mtime: audio_file_mtime(audio_path),
92
+ frames: frames,
93
+ fps: fps,
94
+ noise_gate: noise_gate,
95
+ audio_normalize: audio_normalize,
96
+ bpm: bpm,
97
+ bpm_lock: bpm_lock
98
+ }
99
+ )
100
+ )
101
+ end
102
+
103
+ def self.audio_file_size(path)
104
+ file_stat_value(path, :size)
105
+ end
106
+
107
+ def self.audio_file_mtime(path)
108
+ file_stat_value(path, :mtime).to_i
109
+ rescue StandardError
110
+ 0
111
+ end
112
+
113
+ def self.file_stat_value(path, name)
114
+ stat = Pathname.new(path).stat
115
+ stat.send(name)
116
+ rescue StandardError
117
+ 0
49
118
  end
50
119
 
51
120
  private
52
121
 
122
+ def load_cached_payload(path)
123
+ payload = JSON.parse(Pathname.new(path).read)
124
+ return unless payload.is_a?(Hash)
125
+
126
+ payload if valid_cached_payload?(payload)
127
+ rescue StandardError
128
+ nil
129
+ end
130
+
131
+ def valid_cached_payload?(payload)
132
+ payload.fetch("version", nil) == VERSION &&
133
+ payload.fetch("metadata", {}).fetch("frames", nil) == @frames &&
134
+ (Float(payload.fetch("metadata", {}).fetch("fps", nil)) - @fps).abs < Float::EPSILON &&
135
+ payload.fetch("features", nil).is_a?(Array) &&
136
+ !payload.fetch("features", []).empty?
137
+ end
138
+
139
+ def write_cached_output(payload)
140
+ cache = cache_path
141
+ return unless cache
142
+
143
+ FileUtils.mkdir_p(cache.dirname)
144
+ cache.write("#{JSON.pretty_generate(payload)}\n")
145
+ end
146
+
147
+ def metadata_from_payload(payload, path:)
148
+ metadata = payload.fetch("metadata", {})
149
+ {
150
+ path: path,
151
+ frames: metadata.fetch("frames"),
152
+ fps: metadata.fetch("fps"),
153
+ sample_rate: metadata.fetch("sample_rate")
154
+ }
155
+ end
156
+
157
+ def normalize_cache_root(value)
158
+ return nil if value.nil?
159
+
160
+ Pathname.new(value.to_s).expand_path
161
+ end
162
+
53
163
  def record
54
164
  validate_audio_file!
55
165
  input = Vizcore::Audio::FileInput.new(path: @audio_file.to_s)
@@ -8,7 +8,7 @@ module Vizcore
8
8
  module Analysis
9
9
  # Replays recorded analysis features as a pipeline-compatible source.
10
10
  class FeatureReplay
11
- attr_reader :metadata
11
+ attr_reader :metadata, :cursor
12
12
 
13
13
  def initialize(path:)
14
14
  @path = Pathname.new(path.to_s).expand_path
@@ -30,8 +30,48 @@ module Vizcore
30
30
  @features.length
31
31
  end
32
32
 
33
+ # Move the replay cursor to a frame index.
34
+ #
35
+ # @param index [Integer]
36
+ # @return [Vizcore::Analysis::FeatureReplay]
37
+ def seek(index)
38
+ @cursor = normalize_index(index)
39
+ self
40
+ end
41
+
42
+ # Move the replay cursor to the frame nearest to the given timestamp.
43
+ #
44
+ # @param seconds [Numeric]
45
+ # @return [Vizcore::Analysis::FeatureReplay]
46
+ def seek_seconds(seconds)
47
+ fps = metadata_fps
48
+ raise ArgumentError, "feature metadata fps must be positive to seek by seconds" unless fps.positive?
49
+
50
+ seek((numeric_seconds(seconds) * fps).floor)
51
+ end
52
+
53
+ # Read a specific feature frame without changing the replay cursor.
54
+ #
55
+ # @param index [Integer]
56
+ # @return [Hash<Symbol, Object>]
57
+ def frame(index)
58
+ deep_dup(@features.fetch(normalize_index(index)))
59
+ end
60
+
33
61
  private
34
62
 
63
+ def metadata_fps
64
+ Float(metadata[:fps] || metadata["fps"] || 0.0)
65
+ rescue ArgumentError, TypeError
66
+ 0.0
67
+ end
68
+
69
+ def numeric_seconds(value)
70
+ Float(value)
71
+ rescue ArgumentError, TypeError
72
+ raise ArgumentError, "seconds must be numeric"
73
+ end
74
+
35
75
  def load_payload
36
76
  raise ArgumentError, "Feature file not found: #{@path}" unless @path.file?
37
77
 
@@ -56,6 +96,12 @@ module Vizcore
56
96
  features
57
97
  end
58
98
 
99
+ def normalize_index(value)
100
+ Integer(value) % @features.length
101
+ rescue ArgumentError, TypeError
102
+ raise ArgumentError, "feature frame index must be an integer"
103
+ end
104
+
59
105
  def deep_symbolize(value)
60
106
  case value
61
107
  when Hash
@@ -70,14 +116,7 @@ module Vizcore
70
116
  end
71
117
 
72
118
  def deep_dup(value)
73
- case value
74
- when Hash
75
- value.each_with_object({}) { |(key, entry), output| output[key] = deep_dup(entry) }
76
- when Array
77
- value.map { |entry| deep_dup(entry) }
78
- else
79
- value
80
- end
119
+ Vizcore::DeepCopy.copy(value)
81
120
  end
82
121
  end
83
122
  end