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
@@ -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
@@ -8,6 +8,11 @@ module Vizcore
8
8
  BEAT_PULSE_FLOOR = 0.001
9
9
  DEFAULT_NOISE_GATE = 0.01
10
10
  DEFAULT_AUDIO_NORMALIZE = { mode: :off }.freeze
11
+ DEFAULT_FFT_PREVIEW_BINS = 32
12
+ BEATS_PER_BAR = 4
13
+ BEATS_PER_PHRASE = 32
14
+ BEAT_SUBDIVISIONS = { beat_2: 2, beat_4: 4, beat_8: 8, beat_triplet: 3 }.freeze
15
+ BAND_KEYS = %i[sub low mid high].freeze
11
16
  SILENCE_RESET_FRAMES = 90
12
17
 
13
18
  attr_reader :fft_processor, :band_splitter, :beat_detector, :bpm_estimator, :smoother
@@ -22,7 +27,11 @@ module Vizcore
22
27
  # @param audio_normalize [Hash, nil] optional audio normalization settings
23
28
  # @param bpm [Numeric, nil] fixed BPM value used when bpm_lock is true
24
29
  # @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)
30
+ # @param onset_sensitivity [Numeric] multiplier applied to positive onset deltas
31
+ # @param fft_preview_bins [Integer] number of FFT preview bins included in payloads
32
+ # @param peak_hold_frames [Integer] frames to hold per-band peak values
33
+ # @param silence_reset_frames [Integer] silent frames before tempo state resets
34
+ 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, onset_sensitivity: 1.0, fft_preview_bins: DEFAULT_FFT_PREVIEW_BINS, peak_hold_frames: 0, silence_reset_frames: SILENCE_RESET_FRAMES)
26
35
  @fft_processor = FFTProcessor.new(sample_rate: sample_rate, fft_size: fft_size, window: window)
27
36
  @band_splitter = BandSplitter.new(sample_rate: sample_rate, fft_size: fft_size)
28
37
  @beat_detector = beat_detector || BeatDetector.new
@@ -30,13 +39,20 @@ module Vizcore
30
39
  @bpm_estimator = bpm_estimator || BPMEstimator.new(frame_rate: @analysis_frame_rate)
31
40
  @smoother = smoother || Smoother.new(alpha: 0.35)
32
41
  @noise_gate = normalize_noise_gate(noise_gate)
33
- self.bpm_lock = { bpm: bpm, locked: bpm_lock }
34
- self.audio_normalize = audio_normalize
42
+ self.onset_sensitivity = onset_sensitivity
43
+ self.fft_preview_bins = fft_preview_bins
44
+ self.peak_hold_frames = peak_hold_frames
45
+ self.silence_reset_frames = silence_reset_frames
35
46
  @beat_pulse = 0.0
47
+ @beat_phase = 0.0
36
48
  @last_bpm = 0.0
49
+ self.bpm_lock = { bpm: bpm, locked: bpm_lock }
50
+ self.audio_normalize = audio_normalize
37
51
  @silent_frame_count = 0
52
+ @band_peak_state = {}
38
53
  @previous_onset_amplitude = 0.0
39
54
  @previous_onset_bands = {}
55
+ @previous_flux_spectrum = nil
40
56
  end
41
57
 
42
58
  # @param settings [Hash, nil]
@@ -54,6 +70,30 @@ module Vizcore
54
70
  @last_bpm = @locked_bpm if @locked_bpm
55
71
  end
56
72
 
73
+ # @param value [Numeric]
74
+ # @return [Float]
75
+ def onset_sensitivity=(value)
76
+ @onset_sensitivity = normalize_positive_number(value, fallback: 1.0)
77
+ end
78
+
79
+ # @param value [Integer]
80
+ # @return [Integer]
81
+ def fft_preview_bins=(value)
82
+ @fft_preview_bins = normalize_integer(value, fallback: DEFAULT_FFT_PREVIEW_BINS, min: 8, max: 128)
83
+ end
84
+
85
+ # @param value [Integer]
86
+ # @return [Integer]
87
+ def peak_hold_frames=(value)
88
+ @peak_hold_frames = normalize_integer(value, fallback: 0, min: 0, max: 10_000)
89
+ end
90
+
91
+ # @param value [Integer]
92
+ # @return [Integer]
93
+ def silence_reset_frames=(value)
94
+ @silence_reset_frames = normalize_integer(value, fallback: SILENCE_RESET_FRAMES, min: 1, max: 10_000)
95
+ end
96
+
57
97
  # @param samples [Array<Numeric>] audio frame samples
58
98
  # @return [Hash] normalized analysis payload consumed by frame broadcaster
59
99
  def call(samples)
@@ -73,17 +113,24 @@ module Vizcore
73
113
  @beat_pulse = beat_detected ? 1.0 : @beat_pulse * BEAT_PULSE_DECAY
74
114
  @beat_pulse = 0.0 if @beat_pulse < BEAT_PULSE_FLOOR
75
115
  bpm = resolve_bpm(beat_detected)
116
+ tempo = tempo_features(beat_detected: beat_detected, beat_count: beat[:beat_count], bpm: bpm)
117
+ peak = peak_level(samples)
118
+ spectrum_preview = preview_spectrum(fft[:magnitudes], bins: @fft_preview_bins)
119
+ spectral = spectral_features(fft[:magnitudes], spectrum_preview)
76
120
  normalized = normalize_features(
77
121
  amplitude: amplitude,
78
122
  bands: bands,
79
- fft: preview_spectrum(fft[:magnitudes])
123
+ fft: spectrum_preview
80
124
  )
125
+ band_peaks = update_band_peaks(normalized[:bands])
81
126
  onsets = detect_onsets(amplitude: normalized[:amplitude], bands: normalized[:bands])
82
127
  drums = detect_drum_sources(bands: normalized[:bands], onsets: onsets[:bands])
83
128
 
84
129
  {
85
130
  amplitude: @smoother.smooth(:amplitude, normalized[:amplitude]),
131
+ peak: peak,
86
132
  bands: @smoother.smooth_hash(normalized[:bands], namespace: :bands),
133
+ band_peaks: band_peaks,
87
134
  fft: @smoother.smooth_array(normalized[:fft], namespace: :fft),
88
135
  onset: onsets[:amplitude],
89
136
  onsets: onsets[:bands],
@@ -92,7 +139,21 @@ module Vizcore
92
139
  beat_confidence: confidence,
93
140
  beat_pulse: @beat_pulse,
94
141
  beat_count: beat[:beat_count],
142
+ beat_phase: tempo[:beat_phase],
143
+ beat_2: tempo[:beat_2],
144
+ beat_4: tempo[:beat_4],
145
+ beat_8: tempo[:beat_8],
146
+ beat_triplet: tempo[:beat_triplet],
147
+ bar_phase: tempo[:bar_phase],
148
+ bar_count: tempo[:bar_count],
149
+ phrase_count: tempo[:phrase_count],
95
150
  bpm: bpm,
151
+ bpm_confidence: bpm_confidence,
152
+ spectral_centroid: spectral[:centroid],
153
+ spectral_rolloff: spectral[:rolloff],
154
+ spectral_flatness: spectral[:flatness],
155
+ spectral_flux: spectral[:flux],
156
+ zero_crossing_rate: zero_crossing_rate(samples),
96
157
  peak_frequency: fft[:peak_frequency]
97
158
  }
98
159
  end
@@ -109,6 +170,21 @@ module Vizcore
109
170
  DEFAULT_NOISE_GATE
110
171
  end
111
172
 
173
+ def normalize_positive_number(value, fallback:)
174
+ numeric = Float(value)
175
+ return fallback unless numeric.finite? && numeric.positive?
176
+
177
+ numeric
178
+ rescue ArgumentError, TypeError
179
+ fallback
180
+ end
181
+
182
+ def normalize_integer(value, fallback:, min:, max:)
183
+ Integer(value).clamp(min, max)
184
+ rescue ArgumentError, TypeError
185
+ fallback
186
+ end
187
+
112
188
  def normalize_audio_normalize(value)
113
189
  settings = DEFAULT_AUDIO_NORMALIZE.merge(symbolize_hash(value))
114
190
  mode = settings[:mode].to_s.strip.to_sym
@@ -134,7 +210,8 @@ module Vizcore
134
210
  AdaptiveNormalizer.new(
135
211
  window_size: normalization_window_size(settings),
136
212
  target: settings.fetch(:target, AdaptiveNormalizer::DEFAULT_TARGET),
137
- floor: settings.fetch(:floor, AdaptiveNormalizer::DEFAULT_FLOOR)
213
+ floor: settings.fetch(:floor, AdaptiveNormalizer::DEFAULT_FLOOR),
214
+ per_band: settings.fetch(:per_band, false)
138
215
  )
139
216
  end
140
217
 
@@ -155,6 +232,25 @@ module Vizcore
155
232
  @normalizer.call(amplitude: amplitude, bands: bands, fft: fft)
156
233
  end
157
234
 
235
+ def update_band_peaks(bands)
236
+ values = zero_bands.merge(symbolize_hash(bands))
237
+ return values.transform_values { |value| Float(value).clamp(0.0, 1.0) } if @peak_hold_frames <= 0
238
+
239
+ values.each_with_object({}) do |(key, value), output|
240
+ current = Float(value).clamp(0.0, 1.0)
241
+ state = @band_peak_state[key] || { value: 0.0, remaining: 0 }
242
+ if current >= state[:value].to_f || state[:remaining].to_i <= 0
243
+ @band_peak_state[key] = { value: current, remaining: @peak_hold_frames }
244
+ output[key] = current
245
+ else
246
+ @band_peak_state[key] = { value: state[:value].to_f, remaining: state[:remaining].to_i - 1 }
247
+ output[key] = state[:value].to_f
248
+ end
249
+ end
250
+ rescue StandardError
251
+ zero_bands
252
+ end
253
+
158
254
  def detect_onsets(amplitude:, bands:)
159
255
  current_amplitude = Float(amplitude).clamp(0.0, 1.0)
160
256
  current_bands = Hash(bands).transform_values { |value| Float(value).clamp(0.0, 1.0) }
@@ -173,7 +269,7 @@ module Vizcore
173
269
  end
174
270
 
175
271
  def positive_delta(current, previous)
176
- [current - previous, 0.0].max.clamp(0.0, 1.0)
272
+ ([current - previous, 0.0].max * @onset_sensitivity).clamp(0.0, 1.0)
177
273
  end
178
274
 
179
275
  def detect_drum_sources(bands:, onsets:)
@@ -198,14 +294,19 @@ module Vizcore
198
294
  def silent_frame(reset_tempo:)
199
295
  @beat_pulse = 0.0
200
296
  reset_tempo_state if reset_tempo
297
+ tempo = tempo_features(beat_detected: false, beat_count: current_beat_count, bpm: @last_bpm, advance: !reset_tempo)
201
298
  @smoother.reset if @smoother.respond_to?(:reset)
299
+ @band_peak_state.clear
202
300
  @previous_onset_amplitude = 0.0
203
301
  @previous_onset_bands = {}
302
+ @previous_flux_spectrum = nil
204
303
 
205
304
  {
206
305
  amplitude: 0.0,
207
- bands: { sub: 0.0, low: 0.0, mid: 0.0, high: 0.0 },
208
- fft: Array.new(32, 0.0),
306
+ peak: 0.0,
307
+ bands: zero_bands,
308
+ band_peaks: zero_bands,
309
+ fft: Array.new(@fft_preview_bins, 0.0),
209
310
  onset: 0.0,
210
311
  onsets: { sub: 0.0, low: 0.0, mid: 0.0, high: 0.0 },
211
312
  drums: { kick: 0.0, snare: 0.0, hihat: 0.0 },
@@ -213,13 +314,29 @@ module Vizcore
213
314
  beat_confidence: 0.0,
214
315
  beat_pulse: 0.0,
215
316
  beat_count: current_beat_count,
317
+ beat_phase: tempo[:beat_phase],
318
+ beat_2: tempo[:beat_2],
319
+ beat_4: tempo[:beat_4],
320
+ beat_8: tempo[:beat_8],
321
+ beat_triplet: tempo[:beat_triplet],
322
+ bar_phase: tempo[:bar_phase],
323
+ bar_count: tempo[:bar_count],
324
+ phrase_count: tempo[:phrase_count],
216
325
  bpm: @last_bpm,
326
+ bpm_confidence: bpm_confidence,
327
+ spectral_centroid: 0.0,
328
+ spectral_rolloff: 0.0,
329
+ spectral_flatness: 0.0,
330
+ spectral_flux: 0.0,
331
+ zero_crossing_rate: 0.0,
217
332
  peak_frequency: 0.0
218
333
  }
219
334
  end
220
335
 
221
336
  def reset_tempo_state
222
337
  @last_bpm = @locked_bpm || 0.0
338
+ @beat_phase = 0.0
339
+ @previous_flux_spectrum = nil
223
340
  @bpm_estimator.reset if @bpm_estimator.respond_to?(:reset)
224
341
  end
225
342
 
@@ -232,7 +349,7 @@ module Vizcore
232
349
  end
233
350
 
234
351
  def sustained_silence?
235
- @silent_frame_count == SILENCE_RESET_FRAMES
352
+ @silent_frame_count == @silence_reset_frames
236
353
  end
237
354
 
238
355
  def current_beat_count
@@ -243,6 +360,10 @@ module Vizcore
243
360
  0
244
361
  end
245
362
 
363
+ def zero_bands
364
+ BAND_KEYS.to_h { |key| [key, 0.0] }
365
+ end
366
+
246
367
  def beat_confidence(beat)
247
368
  threshold = Float(beat[:threshold])
248
369
  instant_energy = Float(beat[:instant_energy])
@@ -260,6 +381,116 @@ module Vizcore
260
381
  @last_bpm = @smoother.smooth(:bpm, bpm, alpha: 0.2).to_f
261
382
  end
262
383
 
384
+ def tempo_features(beat_detected:, beat_count:, bpm:, advance: true)
385
+ previous_phase = @beat_phase
386
+ @beat_phase = advance ? next_beat_phase(beat_detected: beat_detected, bpm: bpm) : 0.0
387
+ count = non_negative_integer(beat_count)
388
+ beat_index = count.positive? ? count - 1 : 0
389
+
390
+ subdivision_pulses = BEAT_SUBDIVISIONS.transform_values do |divisions|
391
+ beat_detected || crossed_subdivision?(previous_phase, @beat_phase, divisions)
392
+ end
393
+
394
+ {
395
+ beat_phase: @beat_phase,
396
+ bar_phase: (((beat_index % BEATS_PER_BAR) + @beat_phase) / BEATS_PER_BAR.to_f).clamp(0.0, 1.0),
397
+ bar_count: beat_index / BEATS_PER_BAR,
398
+ phrase_count: beat_index / BEATS_PER_PHRASE
399
+ }.merge(subdivision_pulses)
400
+ end
401
+
402
+ def next_beat_phase(beat_detected:, bpm:)
403
+ return 0.0 if beat_detected
404
+
405
+ numeric_bpm = Float(bpm)
406
+ return 0.0 unless numeric_bpm.positive? && @analysis_frame_rate.positive?
407
+
408
+ (@beat_phase + (numeric_bpm / 60.0 / @analysis_frame_rate)) % 1.0
409
+ rescue ArgumentError, TypeError
410
+ 0.0
411
+ end
412
+
413
+ def crossed_subdivision?(previous_phase, current_phase, divisions)
414
+ previous_step = (Float(previous_phase) * divisions).floor
415
+ current_step = (Float(current_phase) * divisions).floor
416
+ current_phase < previous_phase || current_step > previous_step
417
+ rescue ArgumentError, TypeError
418
+ false
419
+ end
420
+
421
+ def non_negative_integer(value)
422
+ [Integer(value || 0), 0].max
423
+ rescue ArgumentError, TypeError
424
+ 0
425
+ end
426
+
427
+ def bpm_confidence
428
+ return 1.0 if @locked_bpm
429
+ return @bpm_estimator.confidence.to_f if @bpm_estimator.respond_to?(:confidence)
430
+
431
+ @last_bpm.to_f.positive? ? 1.0 : 0.0
432
+ rescue StandardError
433
+ 0.0
434
+ end
435
+
436
+ def spectral_features(magnitudes, spectrum_preview)
437
+ values = Array(magnitudes).map { |value| Float(value).abs }
438
+ return zero_spectral_features if values.empty?
439
+
440
+ total = values.sum
441
+ return zero_spectral_features unless total.positive?
442
+
443
+ {
444
+ centroid: spectral_centroid(values, total),
445
+ rolloff: spectral_rolloff(values, total),
446
+ flatness: spectral_flatness(values),
447
+ flux: spectral_flux(spectrum_preview)
448
+ }
449
+ rescue StandardError
450
+ zero_spectral_features
451
+ end
452
+
453
+ def zero_spectral_features
454
+ { centroid: 0.0, rolloff: 0.0, flatness: 0.0, flux: 0.0 }
455
+ end
456
+
457
+ def spectral_centroid(values, total)
458
+ weighted = values.each_with_index.sum do |magnitude, index|
459
+ @fft_processor.bin_frequency(index) * magnitude
460
+ end
461
+ weighted / total
462
+ end
463
+
464
+ def spectral_rolloff(values, total, threshold: 0.85)
465
+ target = total * threshold
466
+ running = 0.0
467
+ index = values.index do |magnitude|
468
+ running += magnitude
469
+ running >= target
470
+ end
471
+ @fft_processor.bin_frequency(index || 0)
472
+ end
473
+
474
+ def spectral_flatness(values)
475
+ epsilon = 1e-12
476
+ arithmetic_mean = values.sum / values.length.to_f
477
+ return 0.0 unless arithmetic_mean.positive?
478
+
479
+ log_mean = values.sum { |value| Math.log([value, epsilon].max) } / values.length.to_f
480
+ (Math.exp(log_mean) / arithmetic_mean).clamp(0.0, 1.0)
481
+ end
482
+
483
+ def spectral_flux(spectrum_preview)
484
+ current = Array(spectrum_preview).map { |value| Float(value).clamp(0.0, 1.0) }
485
+ previous = @previous_flux_spectrum
486
+ @previous_flux_spectrum = current
487
+ return 0.0 unless previous && previous.length == current.length
488
+
489
+ Math.sqrt(current.each_with_index.sum { |value, index| [value - previous[index], 0.0].max**2 }).clamp(0.0, 1.0)
490
+ rescue StandardError
491
+ 0.0
492
+ end
493
+
263
494
  def preview_spectrum(magnitudes, bins: 32)
264
495
  values = Array(magnitudes)
265
496
  return Array.new(bins, 0.0) if values.empty?
@@ -284,6 +515,24 @@ module Vizcore
284
515
  0.0
285
516
  end
286
517
 
518
+ def peak_level(samples)
519
+ Array(samples).map { |sample| Float(sample).abs }.max.to_f.clamp(0.0, 1.0)
520
+ rescue StandardError
521
+ 0.0
522
+ end
523
+
524
+ def zero_crossing_rate(samples)
525
+ values = Array(samples).map { |sample| Float(sample) }
526
+ return 0.0 if values.length < 2
527
+
528
+ crossings = values.each_cons(2).count do |previous, current|
529
+ (previous.negative? && current >= 0.0) || (previous.positive? && current <= 0.0)
530
+ end
531
+ (crossings / (values.length - 1).to_f).clamp(0.0, 1.0)
532
+ rescue StandardError
533
+ 0.0
534
+ end
535
+
287
536
  def symbolize_hash(value)
288
537
  Hash(value).each_with_object({}) do |(key, entry), output|
289
538
  output[key.to_sym] = entry
@@ -66,8 +66,23 @@ module Vizcore
66
66
  def bpm_from_intervals
67
67
  return nil if @intervals.empty?
68
68
 
69
- average_interval = @intervals.sum / @intervals.length.to_f
70
- (60_000.0 / average_interval).clamp(@min_bpm, @max_bpm)
69
+ interval = robust_interval(@intervals)
70
+ (60_000.0 / interval).clamp(@min_bpm, @max_bpm)
71
+ end
72
+
73
+ def robust_interval(intervals)
74
+ sorted = intervals.sort
75
+ return median(sorted) if sorted.length < 4
76
+
77
+ trimmed = sorted[1...-1]
78
+ trimmed.sum / trimmed.length.to_f
79
+ end
80
+
81
+ def median(sorted)
82
+ midpoint = sorted.length / 2
83
+ return sorted[midpoint] if sorted.length.odd?
84
+
85
+ (sorted[midpoint - 1] + sorted[midpoint]) / 2.0
71
86
  end
72
87
  end
73
88
  end
@@ -0,0 +1,156 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "input_manager"
4
+
5
+ module Vizcore
6
+ module Audio
7
+ # Samples an audio input and derives practical level/noise-gate metrics.
8
+ class Calibration
9
+ DEFAULT_DURATION = 3.0
10
+ DEFAULT_FPS = 20.0
11
+
12
+ Result = Struct.new(
13
+ :source,
14
+ :sample_rate,
15
+ :frame_size,
16
+ :frames,
17
+ :rms_mean,
18
+ :rms_p95,
19
+ :peak_max,
20
+ :clip_ratio,
21
+ :recommended_noise_gate,
22
+ keyword_init: true
23
+ ) do
24
+ def to_h
25
+ {
26
+ source: source,
27
+ sample_rate: sample_rate,
28
+ frame_size: frame_size,
29
+ frames: frames,
30
+ rms_mean: rms_mean,
31
+ rms_p95: rms_p95,
32
+ peak_max: peak_max,
33
+ clip_ratio: clip_ratio,
34
+ recommended_noise_gate: recommended_noise_gate
35
+ }
36
+ end
37
+ end
38
+
39
+ # @param source [String, Symbol]
40
+ # @param file_path [String, nil]
41
+ # @param audio_device [String, nil]
42
+ # @param duration [Numeric]
43
+ # @param fps [Numeric]
44
+ # @param input_manager_factory [#call]
45
+ # @param sleeper [#call]
46
+ def initialize(
47
+ source: :mic,
48
+ file_path: nil,
49
+ audio_device: nil,
50
+ duration: DEFAULT_DURATION,
51
+ fps: DEFAULT_FPS,
52
+ input_manager_factory: nil,
53
+ sleeper: ->(seconds) { sleep(seconds) }
54
+ )
55
+ @source = source.to_sym
56
+ @file_path = file_path
57
+ @audio_device = audio_device
58
+ @duration = positive_float(duration, "duration")
59
+ @fps = positive_float(fps, "fps")
60
+ @input_manager_factory = input_manager_factory || method(:build_input_manager)
61
+ @sleeper = sleeper
62
+ end
63
+
64
+ # @return [Result]
65
+ def call
66
+ manager = @input_manager_factory.call(source: @source, file_path: @file_path, audio_device: @audio_device)
67
+ rms_values = []
68
+ peak_values = []
69
+ manager.start
70
+ frame_count.times do
71
+ samples = manager.capture_frame(manager.realtime_capture_size(@fps))
72
+ rms_values << rms(samples)
73
+ peak_values << peak(samples)
74
+ @sleeper.call(1.0 / @fps) if @source == :mic
75
+ end
76
+
77
+ build_result(manager, rms_values, peak_values)
78
+ ensure
79
+ manager&.stop
80
+ end
81
+
82
+ private
83
+
84
+ def build_input_manager(source:, file_path:, audio_device:)
85
+ Vizcore::Audio::InputManager.new(source: source, file_path: file_path, audio_device: audio_device)
86
+ end
87
+
88
+ def build_result(manager, rms_values, peak_values)
89
+ peak_max = peak_values.max.to_f
90
+ Result.new(
91
+ source: manager.source_name.to_s,
92
+ sample_rate: manager.sample_rate,
93
+ frame_size: manager.frame_size,
94
+ frames: rms_values.length,
95
+ rms_mean: round_metric(mean(rms_values)),
96
+ rms_p95: round_metric(percentile(rms_values, 0.95)),
97
+ peak_max: round_metric(peak_max),
98
+ clip_ratio: round_metric(clip_ratio(peak_values)),
99
+ recommended_noise_gate: round_metric(recommended_noise_gate(rms_values))
100
+ )
101
+ end
102
+
103
+ def frame_count
104
+ [(@duration * @fps).ceil, 1].max
105
+ end
106
+
107
+ def rms(samples)
108
+ values = Array(samples)
109
+ return 0.0 if values.empty?
110
+
111
+ Math.sqrt(values.sum { |sample| sample.to_f * sample.to_f } / values.length.to_f)
112
+ end
113
+
114
+ def peak(samples)
115
+ Array(samples).map { |sample| sample.to_f.abs }.max.to_f
116
+ end
117
+
118
+ def mean(values)
119
+ return 0.0 if values.empty?
120
+
121
+ values.sum / values.length.to_f
122
+ end
123
+
124
+ def percentile(values, ratio)
125
+ sorted = values.sort
126
+ return 0.0 if sorted.empty?
127
+
128
+ sorted[((sorted.length - 1) * ratio).round]
129
+ end
130
+
131
+ def clip_ratio(peaks)
132
+ return 0.0 if peaks.empty?
133
+
134
+ peaks.count { |value| value >= 0.98 } / peaks.length.to_f
135
+ end
136
+
137
+ def recommended_noise_gate(rms_values)
138
+ baseline = percentile(rms_values, 0.50)
139
+ [[baseline * 1.5, 0.001].max, 0.25].min
140
+ end
141
+
142
+ def round_metric(value)
143
+ value.to_f.round(6)
144
+ end
145
+
146
+ def positive_float(value, label)
147
+ numeric = Float(value)
148
+ raise ArgumentError, "#{label} must be positive" unless numeric.positive?
149
+
150
+ numeric
151
+ rescue ArgumentError, TypeError
152
+ raise ArgumentError, "#{label} must be positive"
153
+ end
154
+ end
155
+ end
156
+ end
@@ -67,6 +67,34 @@ module Vizcore
67
67
  self
68
68
  end
69
69
 
70
+ # @return [Float] current playback position in seconds (looped)
71
+ def transport_position_seconds
72
+ @state_mutex.synchronize do
73
+ return 0.0 if @samples.empty?
74
+
75
+ rate = @stream_sample_rate.to_f.positive? ? @stream_sample_rate.to_f : sample_rate.to_f
76
+ return 0.0 if rate <= 0
77
+
78
+ @cursor.to_f / rate
79
+ end
80
+ rescue StandardError
81
+ 0.0
82
+ end
83
+
84
+ # @return [Float] looped track duration in seconds
85
+ def track_duration_seconds
86
+ @state_mutex.synchronize do
87
+ return 0.0 if @samples.empty?
88
+
89
+ rate = @stream_sample_rate.to_f.positive? ? @stream_sample_rate.to_f : sample_rate.to_f
90
+ return 0.0 if rate <= 0
91
+
92
+ @samples.length.to_f / rate
93
+ end
94
+ rescue StandardError
95
+ 0.0
96
+ end
97
+
70
98
  private
71
99
 
72
100
  def load_samples