vizcore 0.1.0 → 1.1.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 (137) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +70 -117
  3. data/docs/.nojekyll +0 -0
  4. data/docs/assets/playground-worker.js +373 -0
  5. data/docs/assets/playground.css +440 -0
  6. data/docs/assets/playground.js +652 -0
  7. data/docs/assets/site.css +744 -0
  8. data/docs/assets/vizcore-demo.gif +0 -0
  9. data/docs/assets/vizcore-poster.png +0 -0
  10. data/docs/assets/vj-tunnel.js +159 -0
  11. data/docs/index.html +225 -0
  12. data/docs/playground.html +81 -0
  13. data/docs/shape_dsl.md +269 -0
  14. data/examples/README.md +59 -0
  15. data/examples/assets/README.md +19 -0
  16. data/examples/audio_inspector.rb +34 -0
  17. data/examples/club_intro_drop.rb +78 -0
  18. data/examples/kansai_rubykaigi_visual.rb +70 -0
  19. data/examples/live_coding_minimal.rb +22 -0
  20. data/examples/midi_controller_show.rb +78 -0
  21. data/examples/midi_scene_switch.rb +3 -1
  22. data/examples/parser_visualizer.rb +48 -0
  23. data/examples/readme_demo.rb +17 -0
  24. data/examples/rhythm_geometry.rb +34 -0
  25. data/examples/ruby_crystal_show.rb +35 -0
  26. data/examples/shader_playground.rb +18 -0
  27. data/examples/unyo_liquid.rb +59 -0
  28. data/examples/vj_ambient_chill_room.rb +124 -0
  29. data/examples/vj_dnb_jungle.rb +170 -0
  30. data/examples/vj_festival_mainstage.rb +245 -0
  31. data/examples/vj_festival_mainstage.yml +17 -0
  32. data/examples/vj_glitch_industrial.rb +164 -0
  33. data/examples/vj_hiphop_cipher.rb +167 -0
  34. data/examples/vj_jpop_idol_live.rb +210 -0
  35. data/examples/vj_synthwave_retro.rb +173 -0
  36. data/examples/vj_techno_warehouse.rb +195 -0
  37. data/frontend/index.html +494 -2
  38. data/frontend/src/audio-inspector.js +40 -0
  39. data/frontend/src/custom-shape-param-controls.js +106 -0
  40. data/frontend/src/live-controls.js +131 -0
  41. data/frontend/src/main.js +1060 -16
  42. data/frontend/src/mapping-target-selector.js +109 -0
  43. data/frontend/src/midi-learn.js +194 -0
  44. data/frontend/src/performance-monitor.js +183 -0
  45. data/frontend/src/plugin-runtime.js +130 -0
  46. data/frontend/src/projector-mode.js +56 -0
  47. data/frontend/src/renderer/engine.js +157 -3
  48. data/frontend/src/renderer/layer-manager.js +442 -30
  49. data/frontend/src/renderer/shader-manager.js +26 -0
  50. data/frontend/src/runtime-control-preset.js +11 -0
  51. data/frontend/src/shader-error-overlay.js +29 -0
  52. data/frontend/src/shader-param-controls.js +93 -0
  53. data/frontend/src/shaders/builtins.js +380 -2
  54. data/frontend/src/shaders/post-effects.js +52 -0
  55. data/frontend/src/shape-editor-controls.js +157 -0
  56. data/frontend/src/visual-regression.js +67 -0
  57. data/frontend/src/visual-settings-preset.js +103 -0
  58. data/frontend/src/visuals/geometry.js +666 -0
  59. data/frontend/src/visuals/image-renderer.js +291 -0
  60. data/frontend/src/visuals/particle-system.js +56 -10
  61. data/frontend/src/visuals/shape-renderer.js +475 -0
  62. data/frontend/src/visuals/spectrogram-renderer.js +226 -0
  63. data/frontend/src/visuals/svg-arc.js +104 -0
  64. data/frontend/src/visuals/text-renderer.js +112 -11
  65. data/frontend/src/websocket-client.js +12 -1
  66. data/lib/vizcore/analysis/adaptive_normalizer.rb +70 -0
  67. data/lib/vizcore/analysis/beat_detector.rb +4 -2
  68. data/lib/vizcore/analysis/bpm_estimator.rb +8 -0
  69. data/lib/vizcore/analysis/feature_recorder.rb +159 -0
  70. data/lib/vizcore/analysis/feature_replay.rb +84 -0
  71. data/lib/vizcore/analysis/pipeline.rb +235 -11
  72. data/lib/vizcore/analysis/tap_tempo.rb +74 -0
  73. data/lib/vizcore/analysis.rb +4 -0
  74. data/lib/vizcore/audio/dummy_sine_input.rb +1 -1
  75. data/lib/vizcore/audio/fixture_input.rb +65 -0
  76. data/lib/vizcore/audio/input_manager.rb +4 -2
  77. data/lib/vizcore/audio/mic_input.rb +24 -8
  78. data/lib/vizcore/audio/portaudio_ffi.rb +106 -1
  79. data/lib/vizcore/audio.rb +1 -0
  80. data/lib/vizcore/cli/doctor.rb +159 -0
  81. data/lib/vizcore/cli/dsl_reference.rb +99 -0
  82. data/lib/vizcore/cli/layer_docs.rb +46 -0
  83. data/lib/vizcore/cli/scene_diagnostics.rb +23 -0
  84. data/lib/vizcore/cli/scene_inspector.rb +136 -0
  85. data/lib/vizcore/cli/scene_validator.rb +337 -0
  86. data/lib/vizcore/cli/shader_template.rb +68 -0
  87. data/lib/vizcore/cli/shader_uniform_docs.rb +54 -0
  88. data/lib/vizcore/cli.rb +689 -18
  89. data/lib/vizcore/config.rb +103 -2
  90. data/lib/vizcore/control_preset.rb +68 -0
  91. data/lib/vizcore/dsl/engine.rb +277 -5
  92. data/lib/vizcore/dsl/layer_builder.rb +1280 -23
  93. data/lib/vizcore/dsl/layer_group_builder.rb +112 -0
  94. data/lib/vizcore/dsl/mapping_resolver.rb +290 -7
  95. data/lib/vizcore/dsl/mapping_transform_builder.rb +71 -0
  96. data/lib/vizcore/dsl/reaction_builder.rb +44 -0
  97. data/lib/vizcore/dsl/scene_builder.rb +61 -5
  98. data/lib/vizcore/dsl/shader_source_resolver.rb +67 -6
  99. data/lib/vizcore/dsl/style_builder.rb +68 -0
  100. data/lib/vizcore/dsl/timeline_builder.rb +138 -0
  101. data/lib/vizcore/dsl/transition_controller.rb +77 -0
  102. data/lib/vizcore/dsl.rb +5 -1
  103. data/lib/vizcore/layer_catalog.rb +275 -0
  104. data/lib/vizcore/project_manifest.rb +152 -0
  105. data/lib/vizcore/renderer/png_writer.rb +57 -0
  106. data/lib/vizcore/renderer/render_sequence.rb +153 -0
  107. data/lib/vizcore/renderer/scene_frame_source.rb +132 -0
  108. data/lib/vizcore/renderer/scene_serializer.rb +36 -3
  109. data/lib/vizcore/renderer/snapshot.rb +38 -0
  110. data/lib/vizcore/renderer/snapshot_renderer.rb +938 -0
  111. data/lib/vizcore/renderer.rb +5 -0
  112. data/lib/vizcore/server/frame_broadcaster.rb +143 -8
  113. data/lib/vizcore/server/gallery_app.rb +155 -0
  114. data/lib/vizcore/server/gallery_page.rb +100 -0
  115. data/lib/vizcore/server/gallery_runner.rb +48 -0
  116. data/lib/vizcore/server/rack_app.rb +203 -4
  117. data/lib/vizcore/server/runner.rb +391 -22
  118. data/lib/vizcore/server/scene_dependency_watcher.rb +79 -0
  119. data/lib/vizcore/server/websocket_handler.rb +60 -10
  120. data/lib/vizcore/server.rb +4 -0
  121. data/lib/vizcore/shape.rb +719 -0
  122. data/lib/vizcore/sync/osc_message.rb +103 -0
  123. data/lib/vizcore/sync/osc_receiver.rb +68 -0
  124. data/lib/vizcore/sync.rb +4 -0
  125. data/lib/vizcore/templates/midi_control_scene.rb +3 -1
  126. data/lib/vizcore/templates/plugin_layer.rb +20 -0
  127. data/lib/vizcore/templates/plugin_readme.md +23 -0
  128. data/lib/vizcore/templates/plugin_renderer.js +43 -0
  129. data/lib/vizcore/templates/plugin_scene.rb +14 -0
  130. data/lib/vizcore/templates/project_readme.md +7 -23
  131. data/lib/vizcore/templates/rubykaigi_scene.rb +30 -0
  132. data/lib/vizcore/version.rb +1 -1
  133. data/lib/vizcore.rb +28 -0
  134. data/scripts/browser_capture.mjs +75 -0
  135. data/sig/vizcore.rbs +461 -0
  136. metadata +94 -3
  137. data/docs/GETTING_STARTED.md +0 -105
@@ -4,6 +4,12 @@ module Vizcore
4
4
  module Analysis
5
5
  # End-to-end analysis pipeline from PCM samples to renderer-ready features.
6
6
  class Pipeline
7
+ BEAT_PULSE_DECAY = 0.86
8
+ BEAT_PULSE_FLOOR = 0.001
9
+ DEFAULT_NOISE_GATE = 0.01
10
+ DEFAULT_AUDIO_NORMALIZE = { mode: :off }.freeze
11
+ SILENCE_RESET_FRAMES = 90
12
+
7
13
  attr_reader :fft_processor, :band_splitter, :beat_detector, :bpm_estimator, :smoother
8
14
 
9
15
  # @param sample_rate [Integer]
@@ -12,38 +18,248 @@ module Vizcore
12
18
  # @param beat_detector [Vizcore::Analysis::BeatDetector, nil]
13
19
  # @param bpm_estimator [Vizcore::Analysis::BPMEstimator, nil]
14
20
  # @param smoother [Vizcore::Analysis::Smoother, nil]
15
- def initialize(sample_rate: 44_100, fft_size: 1024, window: :hamming, beat_detector: nil, bpm_estimator: nil, smoother: nil)
21
+ # @param noise_gate [Numeric] RMS threshold below which input is treated as silence
22
+ # @param audio_normalize [Hash, nil] optional audio normalization settings
23
+ # @param bpm [Numeric, nil] fixed BPM value used when bpm_lock is true
24
+ # @param bpm_lock [Boolean] true when BPM output should stay fixed
25
+ def initialize(sample_rate: 44_100, fft_size: 1024, window: :hamming, beat_detector: nil, bpm_estimator: nil, smoother: nil, noise_gate: DEFAULT_NOISE_GATE, audio_normalize: nil, bpm: nil, bpm_lock: false)
16
26
  @fft_processor = FFTProcessor.new(sample_rate: sample_rate, fft_size: fft_size, window: window)
17
27
  @band_splitter = BandSplitter.new(sample_rate: sample_rate, fft_size: fft_size)
18
28
  @beat_detector = beat_detector || BeatDetector.new
19
- frame_rate = sample_rate.to_f / fft_size.to_f
20
- @bpm_estimator = bpm_estimator || BPMEstimator.new(frame_rate: frame_rate)
29
+ @analysis_frame_rate = sample_rate.to_f / fft_size.to_f
30
+ @bpm_estimator = bpm_estimator || BPMEstimator.new(frame_rate: @analysis_frame_rate)
21
31
  @smoother = smoother || Smoother.new(alpha: 0.35)
32
+ @noise_gate = normalize_noise_gate(noise_gate)
33
+ self.bpm_lock = { bpm: bpm, locked: bpm_lock }
34
+ self.audio_normalize = audio_normalize
35
+ @beat_pulse = 0.0
36
+ @last_bpm = 0.0
37
+ @silent_frame_count = 0
38
+ @previous_onset_amplitude = 0.0
39
+ @previous_onset_bands = {}
40
+ end
41
+
42
+ # @param settings [Hash, nil]
43
+ # @return [Hash] normalized settings
44
+ def audio_normalize=(settings)
45
+ @audio_normalize = normalize_audio_normalize(settings)
46
+ @normalizer = build_normalizer(@audio_normalize)
47
+ end
48
+
49
+ # @param settings [Hash]
50
+ # @return [Float, nil]
51
+ def bpm_lock=(settings)
52
+ values = symbolize_hash(settings)
53
+ @locked_bpm = normalize_locked_bpm(values[:bpm], bpm_lock: values[:locked])
54
+ @last_bpm = @locked_bpm if @locked_bpm
22
55
  end
23
56
 
24
57
  # @param samples [Array<Numeric>] audio frame samples
25
58
  # @return [Hash] normalized analysis payload consumed by frame broadcaster
26
59
  def call(samples)
60
+ amplitude = rms(samples)
61
+ if silence?(amplitude)
62
+ track_silent_frame(samples)
63
+ return silent_frame(reset_tempo: sustained_silence?)
64
+ end
65
+
66
+ @silent_frame_count = 0
67
+
27
68
  fft = @fft_processor.call(samples)
28
69
  bands = @band_splitter.call(fft[:magnitudes])
29
70
  beat = @beat_detector.call(samples)
30
- bpm = @bpm_estimator.call(beat: beat[:beat])
31
- amplitude = rms(samples)
32
- spectrum_preview = preview_spectrum(fft[:magnitudes])
71
+ beat_detected = beat[:beat]
72
+ confidence = beat_confidence(beat)
73
+ @beat_pulse = beat_detected ? 1.0 : @beat_pulse * BEAT_PULSE_DECAY
74
+ @beat_pulse = 0.0 if @beat_pulse < BEAT_PULSE_FLOOR
75
+ bpm = resolve_bpm(beat_detected)
76
+ normalized = normalize_features(
77
+ amplitude: amplitude,
78
+ bands: bands,
79
+ fft: preview_spectrum(fft[:magnitudes])
80
+ )
81
+ onsets = detect_onsets(amplitude: normalized[:amplitude], bands: normalized[:bands])
82
+ drums = detect_drum_sources(bands: normalized[:bands], onsets: onsets[:bands])
33
83
 
34
84
  {
35
- amplitude: @smoother.smooth(:amplitude, amplitude),
36
- bands: @smoother.smooth_hash(bands, namespace: :bands),
37
- fft: @smoother.smooth_array(spectrum_preview, namespace: :fft),
38
- beat: beat[:beat],
85
+ amplitude: @smoother.smooth(:amplitude, normalized[:amplitude]),
86
+ bands: @smoother.smooth_hash(normalized[:bands], namespace: :bands),
87
+ fft: @smoother.smooth_array(normalized[:fft], namespace: :fft),
88
+ onset: onsets[:amplitude],
89
+ onsets: onsets[:bands],
90
+ drums: drums,
91
+ beat: beat_detected,
92
+ beat_confidence: confidence,
93
+ beat_pulse: @beat_pulse,
39
94
  beat_count: beat[:beat_count],
40
- bpm: @smoother.smooth(:bpm, bpm, alpha: 0.2),
95
+ bpm: bpm,
41
96
  peak_frequency: fft[:peak_frequency]
42
97
  }
43
98
  end
44
99
 
45
100
  private
46
101
 
102
+ def silence?(amplitude)
103
+ amplitude < @noise_gate
104
+ end
105
+
106
+ def normalize_noise_gate(value)
107
+ Float(value).clamp(0.0, 1.0)
108
+ rescue ArgumentError, TypeError
109
+ DEFAULT_NOISE_GATE
110
+ end
111
+
112
+ def normalize_audio_normalize(value)
113
+ settings = DEFAULT_AUDIO_NORMALIZE.merge(symbolize_hash(value))
114
+ mode = settings[:mode].to_s.strip.to_sym
115
+ raise ArgumentError, "unsupported audio_normalize mode: #{settings[:mode]}" unless %i[off adaptive].include?(mode)
116
+
117
+ settings.merge(mode: mode)
118
+ end
119
+
120
+ def normalize_locked_bpm(value, bpm_lock:)
121
+ return nil unless bpm_lock
122
+
123
+ numeric = Float(value)
124
+ raise ArgumentError, "bpm must be positive when bpm_lock is enabled" unless numeric.positive?
125
+
126
+ numeric
127
+ rescue ArgumentError, TypeError
128
+ raise ArgumentError, "bpm must be a positive number when bpm_lock is enabled"
129
+ end
130
+
131
+ def build_normalizer(settings)
132
+ return nil unless settings[:mode] == :adaptive
133
+
134
+ AdaptiveNormalizer.new(
135
+ window_size: normalization_window_size(settings),
136
+ target: settings.fetch(:target, AdaptiveNormalizer::DEFAULT_TARGET),
137
+ floor: settings.fetch(:floor, AdaptiveNormalizer::DEFAULT_FLOOR)
138
+ )
139
+ end
140
+
141
+ def normalization_window_size(settings)
142
+ return settings[:window_size] if settings.key?(:window_size)
143
+
144
+ seconds = settings.fetch(:window, nil)
145
+ return AdaptiveNormalizer::DEFAULT_WINDOW_SIZE if seconds.nil?
146
+
147
+ (Float(seconds) * @analysis_frame_rate).round.clamp(1, 10_000)
148
+ rescue ArgumentError, TypeError
149
+ AdaptiveNormalizer::DEFAULT_WINDOW_SIZE
150
+ end
151
+
152
+ def normalize_features(amplitude:, bands:, fft:)
153
+ return { amplitude: amplitude, bands: bands, fft: fft } unless @normalizer
154
+
155
+ @normalizer.call(amplitude: amplitude, bands: bands, fft: fft)
156
+ end
157
+
158
+ def detect_onsets(amplitude:, bands:)
159
+ current_amplitude = Float(amplitude).clamp(0.0, 1.0)
160
+ current_bands = Hash(bands).transform_values { |value| Float(value).clamp(0.0, 1.0) }
161
+
162
+ amplitude_onset = positive_delta(current_amplitude, @previous_onset_amplitude)
163
+ band_onsets = current_bands.each_with_object({}) do |(key, value), output|
164
+ output[key] = positive_delta(value, @previous_onset_bands[key].to_f)
165
+ end
166
+
167
+ @previous_onset_amplitude = current_amplitude
168
+ @previous_onset_bands = current_bands
169
+
170
+ { amplitude: amplitude_onset, bands: band_onsets }
171
+ rescue ArgumentError, TypeError
172
+ { amplitude: 0.0, bands: {} }
173
+ end
174
+
175
+ def positive_delta(current, previous)
176
+ [current - previous, 0.0].max.clamp(0.0, 1.0)
177
+ end
178
+
179
+ def detect_drum_sources(bands:, onsets:)
180
+ band_values = Hash(bands)
181
+ onset_values = Hash(onsets)
182
+
183
+ {
184
+ kick: drum_confidence([:sub, :low], band_values, onset_values),
185
+ snare: drum_confidence([:mid], band_values, onset_values),
186
+ hihat: drum_confidence([:high], band_values, onset_values)
187
+ }
188
+ end
189
+
190
+ def drum_confidence(keys, bands, onsets)
191
+ level = keys.map { |key| Float(bands[key] || 0.0) }.max || 0.0
192
+ rise = keys.map { |key| Float(onsets[key] || 0.0) }.max || 0.0
193
+ (level * rise).clamp(0.0, 1.0)
194
+ rescue ArgumentError, TypeError
195
+ 0.0
196
+ end
197
+
198
+ def silent_frame(reset_tempo:)
199
+ @beat_pulse = 0.0
200
+ reset_tempo_state if reset_tempo
201
+ @smoother.reset if @smoother.respond_to?(:reset)
202
+ @previous_onset_amplitude = 0.0
203
+ @previous_onset_bands = {}
204
+
205
+ {
206
+ amplitude: 0.0,
207
+ bands: { sub: 0.0, low: 0.0, mid: 0.0, high: 0.0 },
208
+ fft: Array.new(32, 0.0),
209
+ onset: 0.0,
210
+ onsets: { sub: 0.0, low: 0.0, mid: 0.0, high: 0.0 },
211
+ drums: { kick: 0.0, snare: 0.0, hihat: 0.0 },
212
+ beat: false,
213
+ beat_confidence: 0.0,
214
+ beat_pulse: 0.0,
215
+ beat_count: current_beat_count,
216
+ bpm: @last_bpm,
217
+ peak_frequency: 0.0
218
+ }
219
+ end
220
+
221
+ def reset_tempo_state
222
+ @last_bpm = @locked_bpm || 0.0
223
+ @bpm_estimator.reset if @bpm_estimator.respond_to?(:reset)
224
+ end
225
+
226
+ def track_silent_frame(samples)
227
+ @silent_frame_count += 1
228
+ @beat_detector.call(samples) if @beat_detector.respond_to?(:call)
229
+ @last_bpm = @bpm_estimator.call(beat: false).to_f if @bpm_estimator.respond_to?(:call)
230
+ rescue StandardError
231
+ nil
232
+ end
233
+
234
+ def sustained_silence?
235
+ @silent_frame_count == SILENCE_RESET_FRAMES
236
+ end
237
+
238
+ def current_beat_count
239
+ return Integer(@beat_detector.beat_count) if @beat_detector.respond_to?(:beat_count)
240
+
241
+ 0
242
+ rescue StandardError
243
+ 0
244
+ end
245
+
246
+ def beat_confidence(beat)
247
+ threshold = Float(beat[:threshold])
248
+ instant_energy = Float(beat[:instant_energy])
249
+ return beat[:beat] ? 1.0 : 0.0 unless threshold.positive?
250
+
251
+ (instant_energy / threshold).clamp(0.0, 1.0)
252
+ rescue ArgumentError, TypeError
253
+ beat[:beat] ? 1.0 : 0.0
254
+ end
255
+
256
+ def resolve_bpm(beat_detected)
257
+ return @last_bpm = @locked_bpm if @locked_bpm
258
+
259
+ bpm = @bpm_estimator.call(beat: beat_detected)
260
+ @last_bpm = @smoother.smooth(:bpm, bpm, alpha: 0.2).to_f
261
+ end
262
+
47
263
  def preview_spectrum(magnitudes, bins: 32)
48
264
  values = Array(magnitudes)
49
265
  return Array.new(bins, 0.0) if values.empty?
@@ -67,6 +283,14 @@ module Vizcore
67
283
  rescue ArgumentError, TypeError
68
284
  0.0
69
285
  end
286
+
287
+ def symbolize_hash(value)
288
+ Hash(value).each_with_object({}) do |(key, entry), output|
289
+ output[key.to_sym] = entry
290
+ end
291
+ rescue StandardError
292
+ {}
293
+ end
70
294
  end
71
295
  end
72
296
  end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vizcore
4
+ module Analysis
5
+ # Estimates a fixed BPM from manual tap timestamps.
6
+ class TapTempo
7
+ DEFAULT_MIN_BPM = 40.0
8
+ DEFAULT_MAX_BPM = 240.0
9
+ DEFAULT_HISTORY_SIZE = 4
10
+ DEFAULT_RESET_AFTER_MS = 2_500.0
11
+
12
+ def initialize(
13
+ min_bpm: DEFAULT_MIN_BPM,
14
+ max_bpm: DEFAULT_MAX_BPM,
15
+ history_size: DEFAULT_HISTORY_SIZE,
16
+ reset_after_ms: DEFAULT_RESET_AFTER_MS
17
+ )
18
+ @min_bpm = Float(min_bpm)
19
+ @max_bpm = Float(max_bpm)
20
+ @history_size = Integer(history_size).clamp(1, 16)
21
+ @reset_after_ms = Float(reset_after_ms)
22
+ @last_tap_ms = nil
23
+ @intervals = []
24
+ end
25
+
26
+ # @param timestamp_ms [Numeric] tap timestamp in milliseconds
27
+ # @return [Float, nil] estimated BPM after at least two taps
28
+ def tap(timestamp_ms:)
29
+ current = Float(timestamp_ms)
30
+ reset_if_stale(current)
31
+ return remember_first_tap(current) unless @last_tap_ms
32
+
33
+ interval = current - @last_tap_ms
34
+ @last_tap_ms = current
35
+ return nil unless valid_interval?(interval)
36
+
37
+ @intervals << interval
38
+ @intervals.shift while @intervals.length > @history_size
39
+ bpm_from_intervals
40
+ rescue ArgumentError, TypeError
41
+ nil
42
+ end
43
+
44
+ private
45
+
46
+ def reset_if_stale(current)
47
+ return unless @last_tap_ms
48
+ return unless current - @last_tap_ms > @reset_after_ms
49
+
50
+ @last_tap_ms = nil
51
+ @intervals.clear
52
+ end
53
+
54
+ def remember_first_tap(current)
55
+ @last_tap_ms = current
56
+ nil
57
+ end
58
+
59
+ def valid_interval?(interval)
60
+ return false unless interval.positive?
61
+
62
+ bpm = 60_000.0 / interval
63
+ bpm.between?(@min_bpm, @max_bpm)
64
+ end
65
+
66
+ def bpm_from_intervals
67
+ return nil if @intervals.empty?
68
+
69
+ average_interval = @intervals.sum / @intervals.length.to_f
70
+ (60_000.0 / average_interval).clamp(@min_bpm, @max_bpm)
71
+ end
72
+ end
73
+ end
74
+ end
@@ -7,8 +7,12 @@ module Vizcore
7
7
  end
8
8
 
9
9
  require_relative "analysis/band_splitter"
10
+ require_relative "analysis/adaptive_normalizer"
10
11
  require_relative "analysis/beat_detector"
11
12
  require_relative "analysis/bpm_estimator"
12
13
  require_relative "analysis/fft_processor"
13
14
  require_relative "analysis/pipeline"
15
+ require_relative "analysis/feature_recorder"
16
+ require_relative "analysis/feature_replay"
14
17
  require_relative "analysis/smoother"
18
+ require_relative "analysis/tap_tempo"
@@ -4,7 +4,7 @@ require_relative "base_input"
4
4
 
5
5
  module Vizcore
6
6
  module Audio
7
- # Deterministic sine-wave generator used as fallback/dummy source.
7
+ # Deterministic sine-wave generator used for the explicit dummy source.
8
8
  class DummySineInput < BaseInput
9
9
  # Default oscillator amplitude.
10
10
  DEFAULT_AMPLITUDE = 0.45
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base_input"
4
+
5
+ module Vizcore
6
+ module Audio
7
+ # Deterministic sample-frame input for tests and repeatable development checks.
8
+ class FixtureInput < BaseInput
9
+ # @param frames [Array<Array<Numeric>>] sample frames returned in order
10
+ # @param sample_rate [Integer]
11
+ # @param loop [Boolean] whether to repeat frames after the last one
12
+ def initialize(frames:, sample_rate: 44_100, loop: true)
13
+ super(sample_rate: sample_rate)
14
+ @frames = normalize_frames(frames)
15
+ @loop = !!loop
16
+ @index = 0
17
+ end
18
+
19
+ # @param frame_size [Integer]
20
+ # @return [Array<Float>]
21
+ def read(frame_size)
22
+ count = Integer(frame_size)
23
+ return Array.new(count, 0.0) unless running?
24
+
25
+ frame = next_frame
26
+ return Array.new(count, 0.0) unless frame
27
+
28
+ normalize_frame_size(frame, count)
29
+ end
30
+
31
+ # @return [void]
32
+ def reset
33
+ @index = 0
34
+ end
35
+
36
+ private
37
+
38
+ def next_frame
39
+ return nil if @frames.empty?
40
+ return nil if @index >= @frames.length && !@loop
41
+
42
+ frame = @frames[@index % @frames.length]
43
+ @index += 1
44
+ frame
45
+ end
46
+
47
+ def normalize_frames(frames)
48
+ Array(frames).map do |frame|
49
+ values = Array(frame).map { |sample| Float(sample) }
50
+ raise ArgumentError, "fixture frames must not be empty" if values.empty?
51
+
52
+ values
53
+ end
54
+ rescue ArgumentError, TypeError
55
+ raise ArgumentError, "fixture frames must contain numeric samples"
56
+ end
57
+
58
+ def normalize_frame_size(frame, count)
59
+ return frame.first(count) if frame.length >= count
60
+
61
+ frame + Array.new(count - frame.length, 0.0)
62
+ end
63
+ end
64
+ end
65
+ end
@@ -25,11 +25,13 @@ module Vizcore
25
25
  # @param frame_size [Integer] frame size used by capture loop
26
26
  # @param ring_buffer_size [Integer] stored sample capacity
27
27
  # @param file_path [String, nil] source file path for `:file`
28
- def initialize(source: :mic, sample_rate: DEFAULT_SAMPLE_RATE, frame_size: DEFAULT_FRAME_SIZE, ring_buffer_size: DEFAULT_RING_BUFFER_SIZE, file_path: nil)
28
+ # @param audio_device [String, Integer, nil] input device index/name for `:mic`
29
+ def initialize(source: :mic, sample_rate: DEFAULT_SAMPLE_RATE, frame_size: DEFAULT_FRAME_SIZE, ring_buffer_size: DEFAULT_RING_BUFFER_SIZE, file_path: nil, audio_device: nil)
29
30
  @source_name = source.to_sym
30
31
  @sample_rate = Integer(sample_rate)
31
32
  @frame_size = Integer(frame_size)
32
33
  @ring_buffer = RingBuffer.new(ring_buffer_size)
34
+ @audio_device = audio_device
33
35
  @input = build_input(file_path)
34
36
  @sample_rate = resolve_input_sample_rate(@input, fallback: @sample_rate)
35
37
  end
@@ -110,7 +112,7 @@ module Vizcore
110
112
  def build_input(file_path)
111
113
  case @source_name
112
114
  when :mic
113
- MicInput.new(sample_rate: sample_rate)
115
+ MicInput.new(sample_rate: sample_rate, device: @audio_device || :default)
114
116
  when :file
115
117
  FileInput.new(path: file_path, sample_rate: sample_rate)
116
118
  when :dummy
@@ -1,13 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "base_input"
4
- require_relative "dummy_sine_input"
5
4
  require_relative "../errors"
6
5
  require_relative "portaudio_ffi"
7
6
 
8
7
  module Vizcore
9
8
  module Audio
10
- # Microphone input using PortAudio, with automatic fallback to dummy source.
9
+ # Microphone input using PortAudio, with automatic fallback to silence.
11
10
  class MicInput < BaseInput
12
11
  attr_reader :device, :last_error
13
12
 
@@ -22,7 +21,7 @@ module Vizcore
22
21
  @device = device
23
22
  @channels = Integer(channels)
24
23
  @frames_per_buffer = Integer(frames_per_buffer)
25
- @fallback_input = fallback_input || DummySineInput.new(sample_rate: sample_rate)
24
+ @fallback_input = fallback_input || BaseInput.new(sample_rate: sample_rate)
26
25
  @portaudio_backend = portaudio_backend
27
26
  @stream = nil
28
27
  @using_fallback = false
@@ -75,11 +74,7 @@ module Vizcore
75
74
  private
76
75
 
77
76
  def open_stream
78
- stream = @portaudio_backend.open_default_input_stream(
79
- sample_rate: sample_rate,
80
- channels: @channels,
81
- frames_per_buffer: @frames_per_buffer
82
- )
77
+ stream = open_requested_stream
83
78
  return nil unless stream
84
79
  return stream if stream.start
85
80
 
@@ -90,6 +85,27 @@ module Vizcore
90
85
  nil
91
86
  end
92
87
 
88
+ def open_requested_stream
89
+ if default_device?
90
+ return @portaudio_backend.open_default_input_stream(
91
+ sample_rate: sample_rate,
92
+ channels: @channels,
93
+ frames_per_buffer: @frames_per_buffer
94
+ )
95
+ end
96
+
97
+ @portaudio_backend.open_input_stream(
98
+ device: @device,
99
+ sample_rate: sample_rate,
100
+ channels: @channels,
101
+ frames_per_buffer: @frames_per_buffer
102
+ )
103
+ end
104
+
105
+ def default_device?
106
+ @device.nil? || @device.to_s.empty? || @device.to_s == "default"
107
+ end
108
+
93
109
  def close_stream
94
110
  return unless @stream
95
111