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.
- checksums.yaml +4 -4
- data/frontend/index.html +24 -2
- data/frontend/src/audio-inspector.js +9 -0
- data/frontend/src/live-controls.js +219 -7
- data/frontend/src/main.js +447 -57
- data/frontend/src/midi-learn.js +22 -2
- data/frontend/src/performance-monitor.js +137 -1
- data/frontend/src/renderer/engine.js +391 -10
- data/frontend/src/renderer/layer-manager.js +472 -71
- data/frontend/src/runtime-control-preset.js +44 -0
- data/frontend/src/scene-patches.js +159 -0
- data/frontend/src/shader-error-overlay.js +1 -0
- data/frontend/src/visuals/image-renderer.js +19 -0
- data/frontend/src/visuals/particle-system.js +10 -0
- data/frontend/src/visuals/shape-renderer.js +13 -0
- data/frontend/src/visuals/spectrogram-renderer.js +14 -0
- data/frontend/src/visuals/text-renderer.js +13 -0
- data/frontend/src/websocket-client.js +6 -0
- data/lib/vizcore/analysis/adaptive_normalizer.rb +20 -2
- data/lib/vizcore/analysis/bpm_estimator.rb +18 -8
- data/lib/vizcore/analysis/feature_recorder.rb +117 -7
- data/lib/vizcore/analysis/feature_replay.rb +48 -9
- data/lib/vizcore/analysis/pipeline.rb +258 -9
- data/lib/vizcore/analysis/tap_tempo.rb +17 -2
- data/lib/vizcore/audio/calibration.rb +156 -0
- data/lib/vizcore/audio/file_input.rb +28 -0
- data/lib/vizcore/audio/input_manager.rb +36 -1
- data/lib/vizcore/audio/midi_input.rb +5 -0
- data/lib/vizcore/audio/ring_buffer.rb +22 -0
- data/lib/vizcore/audio.rb +1 -0
- data/lib/vizcore/cli/dsl_reference.rb +64 -8
- data/lib/vizcore/cli/plugin_checker.rb +93 -0
- data/lib/vizcore/cli/scene_diagnostics.rb +2 -2
- data/lib/vizcore/cli/scene_inspector.rb +35 -1
- data/lib/vizcore/cli/scene_validator.rb +487 -39
- data/lib/vizcore/cli/shader_template.rb +7 -2
- data/lib/vizcore/cli/shader_uniform_docs.rb +11 -0
- data/lib/vizcore/cli.rb +268 -15
- data/lib/vizcore/config.rb +40 -3
- data/lib/vizcore/control_preset.rb +29 -0
- data/lib/vizcore/deep_copy.rb +21 -0
- data/lib/vizcore/dsl/color_helpers.rb +155 -0
- data/lib/vizcore/dsl/engine.rb +219 -23
- data/lib/vizcore/dsl/layer_builder.rb +278 -15
- data/lib/vizcore/dsl/layer_group_builder.rb +10 -12
- data/lib/vizcore/dsl/layout_helpers.rb +290 -0
- data/lib/vizcore/dsl/mapping_preset_builder.rb +41 -0
- data/lib/vizcore/dsl/mapping_resolver.rb +404 -22
- data/lib/vizcore/dsl/mapping_transform_builder.rb +50 -0
- data/lib/vizcore/dsl/midi_map_executor.rb +219 -23
- data/lib/vizcore/dsl/reaction_builder.rb +1 -0
- data/lib/vizcore/dsl/scene_builder.rb +83 -13
- data/lib/vizcore/dsl/shader_source_resolver.rb +1 -10
- data/lib/vizcore/dsl/style_builder.rb +3 -0
- data/lib/vizcore/dsl/timeline_builder.rb +91 -8
- data/lib/vizcore/dsl/transition_controller.rb +157 -18
- data/lib/vizcore/dsl.rb +2 -0
- data/lib/vizcore/layer_catalog.rb +1 -0
- data/lib/vizcore/plugin_asset_policy.rb +55 -0
- data/lib/vizcore/project_manifest.rb +12 -2
- data/lib/vizcore/renderer/render_sequence.rb +104 -13
- data/lib/vizcore/renderer/scene_frame_source.rb +179 -14
- data/lib/vizcore/renderer/scene_serializer.rb +38 -0
- data/lib/vizcore/renderer/snapshot.rb +4 -3
- data/lib/vizcore/renderer/snapshot_renderer.rb +134 -8
- data/lib/vizcore/scene_trust.rb +31 -0
- data/lib/vizcore/server/frame_broadcaster.rb +469 -23
- data/lib/vizcore/server/rack_app.rb +151 -4
- data/lib/vizcore/server/runner.rb +676 -82
- data/lib/vizcore/server/websocket_handler.rb +236 -14
- data/lib/vizcore/server.rb +21 -0
- data/lib/vizcore/shape.rb +39 -16
- data/lib/vizcore/sync/osc_message.rb +66 -9
- data/lib/vizcore/version.rb +1 -1
- data/lib/vizcore.rb +33 -0
- data/scripts/browser_capture.mjs +31 -2
- data/sig/vizcore.rbs +55 -4
- 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
|
-
|
|
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
|
-
|
|
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.
|
|
34
|
-
self.
|
|
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:
|
|
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
|
-
|
|
208
|
-
|
|
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 ==
|
|
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
|
-
|
|
70
|
-
(60_000.0 /
|
|
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
|