vizcore 0.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.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +170 -0
- data/docs/GETTING_STARTED.md +105 -0
- data/examples/assets/complex_demo_loop.wav +0 -0
- data/examples/basic.rb +9 -0
- data/examples/complex_audio_showcase.rb +261 -0
- data/examples/custom_shader.rb +21 -0
- data/examples/file_audio_demo.rb +74 -0
- data/examples/intro_drop.rb +38 -0
- data/examples/midi_scene_switch.rb +32 -0
- data/examples/shaders/custom_wave.frag +30 -0
- data/exe/vizcore +6 -0
- data/frontend/index.html +148 -0
- data/frontend/src/main.js +304 -0
- data/frontend/src/renderer/engine.js +135 -0
- data/frontend/src/renderer/layer-manager.js +456 -0
- data/frontend/src/renderer/shader-manager.js +69 -0
- data/frontend/src/shaders/builtins.js +244 -0
- data/frontend/src/shaders/post-effects.js +85 -0
- data/frontend/src/visuals/geometry.js +66 -0
- data/frontend/src/visuals/particle-system.js +148 -0
- data/frontend/src/visuals/text-renderer.js +143 -0
- data/frontend/src/visuals/vj-effects.js +56 -0
- data/frontend/src/websocket-client.js +131 -0
- data/lib/vizcore/analysis/band_splitter.rb +63 -0
- data/lib/vizcore/analysis/beat_detector.rb +70 -0
- data/lib/vizcore/analysis/bpm_estimator.rb +86 -0
- data/lib/vizcore/analysis/fft_processor.rb +224 -0
- data/lib/vizcore/analysis/fftw_ffi.rb +50 -0
- data/lib/vizcore/analysis/pipeline.rb +72 -0
- data/lib/vizcore/analysis/smoother.rb +74 -0
- data/lib/vizcore/analysis.rb +14 -0
- data/lib/vizcore/audio/base_input.rb +39 -0
- data/lib/vizcore/audio/dummy_sine_input.rb +40 -0
- data/lib/vizcore/audio/file_input.rb +163 -0
- data/lib/vizcore/audio/input_manager.rb +133 -0
- data/lib/vizcore/audio/mic_input.rb +121 -0
- data/lib/vizcore/audio/midi_input.rb +246 -0
- data/lib/vizcore/audio/portaudio_ffi.rb +243 -0
- data/lib/vizcore/audio/ring_buffer.rb +92 -0
- data/lib/vizcore/audio.rb +16 -0
- data/lib/vizcore/cli.rb +115 -0
- data/lib/vizcore/config.rb +46 -0
- data/lib/vizcore/dsl/engine.rb +229 -0
- data/lib/vizcore/dsl/file_watcher.rb +108 -0
- data/lib/vizcore/dsl/layer_builder.rb +182 -0
- data/lib/vizcore/dsl/mapping_resolver.rb +81 -0
- data/lib/vizcore/dsl/midi_map_executor.rb +188 -0
- data/lib/vizcore/dsl/scene_builder.rb +44 -0
- data/lib/vizcore/dsl/shader_source_resolver.rb +71 -0
- data/lib/vizcore/dsl/transition_controller.rb +166 -0
- data/lib/vizcore/dsl.rb +16 -0
- data/lib/vizcore/errors.rb +27 -0
- data/lib/vizcore/renderer/frame_scheduler.rb +75 -0
- data/lib/vizcore/renderer/scene_serializer.rb +73 -0
- data/lib/vizcore/renderer.rb +10 -0
- data/lib/vizcore/server/frame_broadcaster.rb +351 -0
- data/lib/vizcore/server/rack_app.rb +183 -0
- data/lib/vizcore/server/runner.rb +357 -0
- data/lib/vizcore/server/websocket_handler.rb +163 -0
- data/lib/vizcore/server.rb +12 -0
- data/lib/vizcore/templates/basic_scene.rb +10 -0
- data/lib/vizcore/templates/custom_shader_scene.rb +22 -0
- data/lib/vizcore/templates/custom_wave.frag +31 -0
- data/lib/vizcore/templates/intro_drop_scene.rb +40 -0
- data/lib/vizcore/templates/midi_control_scene.rb +33 -0
- data/lib/vizcore/templates/project_readme.md +35 -0
- data/lib/vizcore/version.rb +6 -0
- data/lib/vizcore.rb +37 -0
- metadata +186 -0
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
export const VJ_EFFECT_SHADERS = {
|
|
2
|
+
mirror: `#version 300 es
|
|
3
|
+
precision mediump float;
|
|
4
|
+
in vec2 v_uv;
|
|
5
|
+
uniform sampler2D u_texture;
|
|
6
|
+
uniform float u_intensity;
|
|
7
|
+
out vec4 outColor;
|
|
8
|
+
|
|
9
|
+
void main() {
|
|
10
|
+
vec2 uv = v_uv;
|
|
11
|
+
if (uv.x > 0.5) {
|
|
12
|
+
uv.x = 1.0 - uv.x;
|
|
13
|
+
}
|
|
14
|
+
vec4 left = texture(u_texture, uv);
|
|
15
|
+
vec4 base = texture(u_texture, v_uv);
|
|
16
|
+
float mixAmount = clamp(0.4 + u_intensity * 0.6, 0.0, 1.0);
|
|
17
|
+
outColor = mix(base, left, mixAmount);
|
|
18
|
+
}
|
|
19
|
+
`,
|
|
20
|
+
color_shift: `#version 300 es
|
|
21
|
+
precision mediump float;
|
|
22
|
+
in vec2 v_uv;
|
|
23
|
+
uniform sampler2D u_texture;
|
|
24
|
+
uniform float u_intensity;
|
|
25
|
+
out vec4 outColor;
|
|
26
|
+
|
|
27
|
+
void main() {
|
|
28
|
+
vec2 shift = vec2(0.008 * (0.2 + u_intensity), 0.0);
|
|
29
|
+
float r = texture(u_texture, v_uv + shift).r;
|
|
30
|
+
float g = texture(u_texture, v_uv).g;
|
|
31
|
+
float b = texture(u_texture, v_uv - shift).b;
|
|
32
|
+
float a = texture(u_texture, v_uv).a;
|
|
33
|
+
outColor = vec4(r, g, b, a);
|
|
34
|
+
}
|
|
35
|
+
`,
|
|
36
|
+
pixelate: `#version 300 es
|
|
37
|
+
precision mediump float;
|
|
38
|
+
in vec2 v_uv;
|
|
39
|
+
uniform sampler2D u_texture;
|
|
40
|
+
uniform vec2 u_resolution;
|
|
41
|
+
uniform float u_intensity;
|
|
42
|
+
out vec4 outColor;
|
|
43
|
+
|
|
44
|
+
void main() {
|
|
45
|
+
float blocks = mix(260.0, 40.0, clamp(u_intensity, 0.0, 1.0));
|
|
46
|
+
vec2 grid = vec2(blocks, blocks * (u_resolution.y / max(u_resolution.x, 1.0)));
|
|
47
|
+
vec2 uv = floor(v_uv * grid) / grid;
|
|
48
|
+
outColor = texture(u_texture, uv);
|
|
49
|
+
}
|
|
50
|
+
`
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export const getVJEffectShader = (name) => {
|
|
54
|
+
const key = String(name || "").trim().toLowerCase();
|
|
55
|
+
return VJ_EFFECT_SHADERS[key] || null;
|
|
56
|
+
};
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
const RECONNECT_INTERVAL_MS = 1000;
|
|
2
|
+
const READY_STATE_CONNECTING = 0;
|
|
3
|
+
const READY_STATE_OPEN = 1;
|
|
4
|
+
|
|
5
|
+
export class WebSocketClient {
|
|
6
|
+
constructor(url, callbacks = {}) {
|
|
7
|
+
this.url = url;
|
|
8
|
+
this.onFrame = callbacks.onFrame || (() => {});
|
|
9
|
+
this.onSceneChange = callbacks.onSceneChange || (() => {});
|
|
10
|
+
this.onConfigUpdate = callbacks.onConfigUpdate || (() => {});
|
|
11
|
+
this.onStatus = callbacks.onStatus || (() => {});
|
|
12
|
+
this.socket = null;
|
|
13
|
+
this.reconnectTimer = null;
|
|
14
|
+
this.shouldReconnect = true;
|
|
15
|
+
this.connectionSerial = 0;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
connect() {
|
|
19
|
+
if (this.socket && (this.socket.readyState === READY_STATE_CONNECTING || this.socket.readyState === READY_STATE_OPEN)) {
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
this.shouldReconnect = true;
|
|
23
|
+
if (this.reconnectTimer) {
|
|
24
|
+
clearTimeout(this.reconnectTimer);
|
|
25
|
+
this.reconnectTimer = null;
|
|
26
|
+
}
|
|
27
|
+
this.onStatus("connecting");
|
|
28
|
+
|
|
29
|
+
const serial = this.connectionSerial + 1;
|
|
30
|
+
this.connectionSerial = serial;
|
|
31
|
+
const socket = new WebSocket(this.url);
|
|
32
|
+
this.socket = socket;
|
|
33
|
+
socket.addEventListener("open", () => {
|
|
34
|
+
if (!this.isActiveSocket(socket, serial)) {
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
this.onStatus("connected");
|
|
38
|
+
});
|
|
39
|
+
socket.addEventListener("close", () => {
|
|
40
|
+
if (!this.isActiveSocket(socket, serial)) {
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
this.socket = null;
|
|
44
|
+
if (!this.shouldReconnect) {
|
|
45
|
+
this.onStatus("disconnected");
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
this.scheduleReconnect();
|
|
49
|
+
});
|
|
50
|
+
socket.addEventListener("error", () => {
|
|
51
|
+
if (!this.isActiveSocket(socket, serial)) {
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
this.onStatus("error");
|
|
55
|
+
});
|
|
56
|
+
socket.addEventListener("message", (event) => {
|
|
57
|
+
if (!this.isActiveSocket(socket, serial)) {
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
this.handleMessage(event.data);
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
disconnect() {
|
|
65
|
+
this.shouldReconnect = false;
|
|
66
|
+
if (this.reconnectTimer) {
|
|
67
|
+
clearTimeout(this.reconnectTimer);
|
|
68
|
+
this.reconnectTimer = null;
|
|
69
|
+
}
|
|
70
|
+
const socket = this.socket;
|
|
71
|
+
this.socket = null;
|
|
72
|
+
if (socket && (socket.readyState === READY_STATE_CONNECTING || socket.readyState === READY_STATE_OPEN)) {
|
|
73
|
+
socket.close();
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
scheduleReconnect() {
|
|
78
|
+
if (!this.shouldReconnect || this.reconnectTimer) {
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
this.onStatus("reconnecting");
|
|
82
|
+
this.reconnectTimer = setTimeout(() => {
|
|
83
|
+
this.reconnectTimer = null;
|
|
84
|
+
this.connect();
|
|
85
|
+
}, RECONNECT_INTERVAL_MS);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
handleMessage(rawMessage) {
|
|
89
|
+
let message;
|
|
90
|
+
try {
|
|
91
|
+
message = JSON.parse(rawMessage);
|
|
92
|
+
} catch {
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (!message || !message.type || !message.payload) {
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (message.type === "audio_frame") {
|
|
101
|
+
this.onFrame(message.payload);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (message.type === "scene_change") {
|
|
106
|
+
this.onSceneChange(message.payload);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (message.type === "config_update") {
|
|
111
|
+
this.onConfigUpdate(message.payload);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
isActiveSocket(socket, serial) {
|
|
116
|
+
return this.socket === socket && this.connectionSerial === serial;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
send(type, payload = {}) {
|
|
120
|
+
if (!this.socket || this.socket.readyState !== READY_STATE_OPEN) {
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
try {
|
|
125
|
+
this.socket.send(JSON.stringify({ type, payload }));
|
|
126
|
+
return true;
|
|
127
|
+
} catch {
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Vizcore
|
|
4
|
+
module Analysis
|
|
5
|
+
# Converts FFT magnitudes into normalized sub/low/mid/high band energies.
|
|
6
|
+
class BandSplitter
|
|
7
|
+
# Frequency ranges for each named band in Hz.
|
|
8
|
+
BANDS = {
|
|
9
|
+
sub: [20.0, 60.0],
|
|
10
|
+
low: [60.0, 250.0],
|
|
11
|
+
mid: [250.0, 4000.0],
|
|
12
|
+
high: [4000.0, 20_000.0]
|
|
13
|
+
}.freeze
|
|
14
|
+
|
|
15
|
+
attr_reader :sample_rate, :fft_size
|
|
16
|
+
|
|
17
|
+
# @param sample_rate [Integer] input sample rate
|
|
18
|
+
# @param fft_size [Integer] FFT frame size used to compute magnitudes
|
|
19
|
+
def initialize(sample_rate: 44_100, fft_size: 1024)
|
|
20
|
+
@sample_rate = Integer(sample_rate)
|
|
21
|
+
@fft_size = Integer(fft_size)
|
|
22
|
+
@bin_hz = @sample_rate.to_f / @fft_size.to_f
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# @param magnitudes [Array<Numeric>] FFT magnitude bins
|
|
26
|
+
# @return [Hash] normalized energy values for `:sub/:low/:mid/:high`
|
|
27
|
+
def call(magnitudes)
|
|
28
|
+
values = normalize_magnitudes(magnitudes)
|
|
29
|
+
return BANDS.transform_values { 0.0 } if values.empty?
|
|
30
|
+
|
|
31
|
+
scale = [values.max, 1.0e-9].max
|
|
32
|
+
|
|
33
|
+
BANDS.transform_values do |(low_hz, high_hz)|
|
|
34
|
+
indices = band_indices(low_hz, high_hz, values.length)
|
|
35
|
+
next 0.0 if indices.empty?
|
|
36
|
+
|
|
37
|
+
average = indices.sum { |index| values[index] } / indices.length.to_f
|
|
38
|
+
(average / scale).clamp(0.0, 1.0)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def normalize_magnitudes(magnitudes)
|
|
45
|
+
Array(magnitudes).map { |value| Float(value).abs }
|
|
46
|
+
rescue ArgumentError, TypeError
|
|
47
|
+
[]
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def band_indices(low_hz, high_hz, length)
|
|
51
|
+
return [] if length.zero?
|
|
52
|
+
|
|
53
|
+
first = (low_hz / @bin_hz).floor
|
|
54
|
+
last = (high_hz / @bin_hz).ceil
|
|
55
|
+
first = first.clamp(0, length - 1)
|
|
56
|
+
last = last.clamp(0, length - 1)
|
|
57
|
+
return [] if first > last
|
|
58
|
+
|
|
59
|
+
(first..last).to_a
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Vizcore
|
|
4
|
+
module Analysis
|
|
5
|
+
# Detects beat onsets using short-term energy thresholding.
|
|
6
|
+
class BeatDetector
|
|
7
|
+
attr_reader :beat_count
|
|
8
|
+
|
|
9
|
+
# @param history_size [Integer] number of historical frames to keep
|
|
10
|
+
# @param sensitivity [Float] multiplier applied to moving average energy
|
|
11
|
+
# @param refractory_frames [Integer] minimum frames between beat events
|
|
12
|
+
# @param min_history [Integer] minimum history size before detecting beats
|
|
13
|
+
def initialize(history_size: 43, sensitivity: 1.35, refractory_frames: 4, min_history: 8)
|
|
14
|
+
@history_size = Integer(history_size)
|
|
15
|
+
@sensitivity = Float(sensitivity)
|
|
16
|
+
@refractory_frames = Integer(refractory_frames)
|
|
17
|
+
@min_history = Integer(min_history)
|
|
18
|
+
@energy_history = []
|
|
19
|
+
@frame_index = 0
|
|
20
|
+
@last_beat_frame = -@refractory_frames
|
|
21
|
+
@beat_count = 0
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# @param samples [Array<Numeric>] PCM frame samples
|
|
25
|
+
# @return [Hash] beat flag and detector internals
|
|
26
|
+
def call(samples)
|
|
27
|
+
instant_energy = frame_energy(samples)
|
|
28
|
+
average_energy = average(@energy_history)
|
|
29
|
+
threshold = average_energy * @sensitivity
|
|
30
|
+
enough_history = @energy_history.length >= @min_history
|
|
31
|
+
refractory_ok = (@frame_index - @last_beat_frame) > @refractory_frames
|
|
32
|
+
beat = enough_history && refractory_ok && instant_energy > threshold && instant_energy.positive?
|
|
33
|
+
|
|
34
|
+
if beat
|
|
35
|
+
@beat_count += 1
|
|
36
|
+
@last_beat_frame = @frame_index
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
@energy_history << instant_energy
|
|
40
|
+
@energy_history.shift while @energy_history.length > @history_size
|
|
41
|
+
@frame_index += 1
|
|
42
|
+
|
|
43
|
+
{
|
|
44
|
+
beat: beat,
|
|
45
|
+
beat_count: @beat_count,
|
|
46
|
+
instant_energy: instant_energy,
|
|
47
|
+
average_energy: average_energy,
|
|
48
|
+
threshold: threshold
|
|
49
|
+
}
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def frame_energy(samples)
|
|
55
|
+
values = Array(samples).map { |sample| Float(sample) }
|
|
56
|
+
return 0.0 if values.empty?
|
|
57
|
+
|
|
58
|
+
values.reduce(0.0) { |sum, value| sum + value * value } / values.length.to_f
|
|
59
|
+
rescue ArgumentError, TypeError
|
|
60
|
+
0.0
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def average(values)
|
|
64
|
+
return 0.0 if values.empty?
|
|
65
|
+
|
|
66
|
+
values.sum / values.length.to_f
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Vizcore
|
|
4
|
+
module Analysis
|
|
5
|
+
# Estimates tempo (BPM) from beat onsets using lag autocorrelation.
|
|
6
|
+
class BPMEstimator
|
|
7
|
+
attr_reader :frame_rate
|
|
8
|
+
|
|
9
|
+
# @param frame_rate [Float] analysis frames per second
|
|
10
|
+
# @param min_bpm [Float] minimum candidate BPM
|
|
11
|
+
# @param max_bpm [Float] maximum candidate BPM
|
|
12
|
+
# @param history_seconds [Float] history duration used for autocorrelation
|
|
13
|
+
# @param smoothing [Float] EMA factor for stable BPM output
|
|
14
|
+
# @param min_onsets [Integer] minimum onsets before estimation
|
|
15
|
+
def initialize(frame_rate:, min_bpm: 60.0, max_bpm: 200.0, history_seconds: 10.0, smoothing: 0.25, min_onsets: 4)
|
|
16
|
+
@frame_rate = Float(frame_rate)
|
|
17
|
+
@min_bpm = Float(min_bpm)
|
|
18
|
+
@max_bpm = Float(max_bpm)
|
|
19
|
+
@history_size = [(@frame_rate * Float(history_seconds)).to_i, 8].max
|
|
20
|
+
@smoothing = Float(smoothing)
|
|
21
|
+
@min_onsets = Integer(min_onsets)
|
|
22
|
+
@history = []
|
|
23
|
+
@current_bpm = 0.0
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# @param beat [Boolean] whether the current frame contains a beat onset
|
|
27
|
+
# @return [Float] smoothed BPM estimate
|
|
28
|
+
def call(beat:)
|
|
29
|
+
@history << (beat ? 1.0 : 0.0)
|
|
30
|
+
@history.shift while @history.length > @history_size
|
|
31
|
+
|
|
32
|
+
return @current_bpm if onset_count < @min_onsets
|
|
33
|
+
|
|
34
|
+
candidate = estimate_candidate_bpm
|
|
35
|
+
return @current_bpm if candidate <= 0.0
|
|
36
|
+
|
|
37
|
+
@current_bpm =
|
|
38
|
+
if @current_bpm <= 0.0
|
|
39
|
+
candidate
|
|
40
|
+
else
|
|
41
|
+
@current_bpm + (candidate - @current_bpm) * @smoothing
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
@current_bpm
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def onset_count
|
|
50
|
+
@history.count { |value| value.positive? }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def estimate_candidate_bpm
|
|
54
|
+
n = @history.length
|
|
55
|
+
return 0.0 if n < 2
|
|
56
|
+
|
|
57
|
+
min_lag = [(60.0 * @frame_rate / @max_bpm).round, 1].max
|
|
58
|
+
max_lag = [(60.0 * @frame_rate / @min_bpm).round, n - 1].min
|
|
59
|
+
return 0.0 if min_lag > max_lag
|
|
60
|
+
|
|
61
|
+
best_lag = nil
|
|
62
|
+
best_score = -Float::INFINITY
|
|
63
|
+
|
|
64
|
+
(min_lag..max_lag).each do |lag|
|
|
65
|
+
score = autocorrelation_at_lag(lag)
|
|
66
|
+
next unless score > best_score
|
|
67
|
+
|
|
68
|
+
best_score = score
|
|
69
|
+
best_lag = lag
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
return 0.0 unless best_lag && best_score.positive?
|
|
73
|
+
|
|
74
|
+
(60.0 * @frame_rate / best_lag).clamp(@min_bpm, @max_bpm)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def autocorrelation_at_lag(lag)
|
|
78
|
+
score = 0.0
|
|
79
|
+
(lag...@history.length).each do |index|
|
|
80
|
+
score += @history[index] * @history[index - lag]
|
|
81
|
+
end
|
|
82
|
+
score
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "fftw_ffi"
|
|
4
|
+
|
|
5
|
+
module Vizcore
|
|
6
|
+
module Analysis
|
|
7
|
+
# Performs FFT analysis with optional FFTW acceleration and Ruby fallback.
|
|
8
|
+
class FFTProcessor
|
|
9
|
+
# Supported windowing functions applied before FFT.
|
|
10
|
+
SUPPORTED_WINDOWS = %i[hamming hann blackman none].freeze
|
|
11
|
+
# Supported transform backends.
|
|
12
|
+
SUPPORTED_BACKENDS = %i[auto ruby fftw].freeze
|
|
13
|
+
|
|
14
|
+
attr_reader :sample_rate, :fft_size, :window, :backend_name
|
|
15
|
+
|
|
16
|
+
# @return [Boolean] true when FFTW3 is available.
|
|
17
|
+
def self.fftw_available?
|
|
18
|
+
FFTWFFI.available?
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# @param sample_rate [Integer] input sample rate
|
|
22
|
+
# @param fft_size [Integer] FFT frame size (power of two)
|
|
23
|
+
# @param window [Symbol] one of {SUPPORTED_WINDOWS}
|
|
24
|
+
# @param backend [Symbol] one of {SUPPORTED_BACKENDS}
|
|
25
|
+
def initialize(sample_rate: 44_100, fft_size: 1024, window: :hamming, backend: :auto)
|
|
26
|
+
@sample_rate = Integer(sample_rate)
|
|
27
|
+
@fft_size = Integer(fft_size)
|
|
28
|
+
@window = window.to_sym
|
|
29
|
+
@backend_requested = backend.to_sym
|
|
30
|
+
|
|
31
|
+
raise ArgumentError, "fft_size must be power of two" unless power_of_two?(@fft_size)
|
|
32
|
+
raise ArgumentError, "unsupported window: #{@window}" unless SUPPORTED_WINDOWS.include?(@window)
|
|
33
|
+
raise ArgumentError, "unsupported backend: #{@backend_requested}" unless SUPPORTED_BACKENDS.include?(@backend_requested)
|
|
34
|
+
|
|
35
|
+
@backend = resolve_backend(@backend_requested)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# @param samples [Array<Numeric>] PCM frame samples
|
|
39
|
+
# @return [Hash] FFT result with magnitudes, complex spectrum, and peak info
|
|
40
|
+
def call(samples)
|
|
41
|
+
frame = prepare_frame(samples)
|
|
42
|
+
windowed = apply_window(frame)
|
|
43
|
+
spectrum = execute_transform(windowed)
|
|
44
|
+
half_spectrum = spectrum.first(@fft_size / 2)
|
|
45
|
+
magnitudes = half_spectrum.map(&:abs)
|
|
46
|
+
peak_bin = peak_index(magnitudes)
|
|
47
|
+
|
|
48
|
+
{
|
|
49
|
+
magnitudes: magnitudes,
|
|
50
|
+
spectrum: half_spectrum,
|
|
51
|
+
peak_bin: peak_bin,
|
|
52
|
+
peak_frequency: bin_frequency(peak_bin)
|
|
53
|
+
}
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# @param bin_index [Integer]
|
|
57
|
+
# @return [Float] frequency in Hz corresponding to the FFT bin
|
|
58
|
+
def bin_frequency(bin_index)
|
|
59
|
+
Integer(bin_index) * sample_rate.to_f / fft_size.to_f
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def prepare_frame(samples)
|
|
65
|
+
values = Array(samples).map { |sample| Float(sample) }
|
|
66
|
+
values = values.first(fft_size)
|
|
67
|
+
return values if values.length == fft_size
|
|
68
|
+
|
|
69
|
+
values + Array.new(fft_size - values.length, 0.0)
|
|
70
|
+
rescue ArgumentError, TypeError
|
|
71
|
+
Array.new(fft_size, 0.0)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def apply_window(frame)
|
|
75
|
+
return frame if window == :none
|
|
76
|
+
|
|
77
|
+
frame.each_with_index.map do |value, index|
|
|
78
|
+
value * window_coefficient(index, frame.length)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def execute_transform(windowed)
|
|
83
|
+
@backend.transform(windowed)
|
|
84
|
+
rescue StandardError
|
|
85
|
+
raise unless @backend_requested == :auto && @backend_name == :fftw
|
|
86
|
+
|
|
87
|
+
@backend_name = :ruby
|
|
88
|
+
@backend = RubyBackend.new
|
|
89
|
+
@backend.transform(windowed)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def resolve_backend(requested)
|
|
93
|
+
case requested
|
|
94
|
+
when :ruby
|
|
95
|
+
@backend_name = :ruby
|
|
96
|
+
RubyBackend.new
|
|
97
|
+
when :fftw
|
|
98
|
+
raise ArgumentError, "fftw backend is unavailable on this system" unless self.class.fftw_available?
|
|
99
|
+
|
|
100
|
+
@backend_name = :fftw
|
|
101
|
+
FFTWBackend.new(fft_size)
|
|
102
|
+
else
|
|
103
|
+
if self.class.fftw_available?
|
|
104
|
+
@backend_name = :fftw
|
|
105
|
+
FFTWBackend.new(fft_size)
|
|
106
|
+
else
|
|
107
|
+
@backend_name = :ruby
|
|
108
|
+
RubyBackend.new
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def window_coefficient(index, size)
|
|
114
|
+
angle = 2.0 * Math::PI * index.to_f / (size - 1).to_f
|
|
115
|
+
|
|
116
|
+
case window
|
|
117
|
+
when :hamming
|
|
118
|
+
0.54 - 0.46 * Math.cos(angle)
|
|
119
|
+
when :hann
|
|
120
|
+
0.5 * (1.0 - Math.cos(angle))
|
|
121
|
+
when :blackman
|
|
122
|
+
0.42 - 0.5 * Math.cos(angle) + 0.08 * Math.cos(2 * angle)
|
|
123
|
+
else
|
|
124
|
+
1.0
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def power_of_two?(value)
|
|
129
|
+
value.positive? && (value & (value - 1)).zero?
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def peak_index(magnitudes)
|
|
133
|
+
pair = magnitudes.each_with_index.max_by { |magnitude, _index| magnitude }
|
|
134
|
+
pair ? pair.last : 0
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Pure-Ruby Cooley-Tukey FFT backend.
|
|
138
|
+
# @api private
|
|
139
|
+
class RubyBackend
|
|
140
|
+
# @param values [Array<Float>]
|
|
141
|
+
# @return [Array<Complex>]
|
|
142
|
+
def transform(values)
|
|
143
|
+
fft(values.map { |value| Complex(value, 0.0) })
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
private
|
|
147
|
+
|
|
148
|
+
def fft(values)
|
|
149
|
+
n = values.length
|
|
150
|
+
bit_reversed = bit_reverse_copy(values)
|
|
151
|
+
|
|
152
|
+
len = 2
|
|
153
|
+
while len <= n
|
|
154
|
+
angle = -2.0 * Math::PI / len
|
|
155
|
+
twiddle_step = Complex(Math.cos(angle), Math.sin(angle))
|
|
156
|
+
|
|
157
|
+
(0...n).step(len) do |offset|
|
|
158
|
+
twiddle = Complex(1.0, 0.0)
|
|
159
|
+
half = len / 2
|
|
160
|
+
|
|
161
|
+
half.times do |index|
|
|
162
|
+
even = bit_reversed[offset + index]
|
|
163
|
+
odd = bit_reversed[offset + index + half] * twiddle
|
|
164
|
+
bit_reversed[offset + index] = even + odd
|
|
165
|
+
bit_reversed[offset + index + half] = even - odd
|
|
166
|
+
twiddle *= twiddle_step
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
len <<= 1
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
bit_reversed
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def bit_reverse_copy(values)
|
|
177
|
+
n = values.length
|
|
178
|
+
output = values.dup
|
|
179
|
+
j = 0
|
|
180
|
+
|
|
181
|
+
(1...n).each do |i|
|
|
182
|
+
bit = n >> 1
|
|
183
|
+
while j & bit != 0
|
|
184
|
+
j ^= bit
|
|
185
|
+
bit >>= 1
|
|
186
|
+
end
|
|
187
|
+
j ^= bit
|
|
188
|
+
output[i], output[j] = output[j], output[i] if i < j
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
output
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# FFTW3-backed transform backend.
|
|
196
|
+
# @api private
|
|
197
|
+
class FFTWBackend
|
|
198
|
+
# @param fft_size [Integer]
|
|
199
|
+
def initialize(fft_size)
|
|
200
|
+
@fft_size = fft_size
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# @param values [Array<Float>]
|
|
204
|
+
# @return [Array<Complex>]
|
|
205
|
+
def transform(values)
|
|
206
|
+
input = FFI::MemoryPointer.new(:double, @fft_size)
|
|
207
|
+
bins = (@fft_size / 2) + 1
|
|
208
|
+
output = FFI::MemoryPointer.new(:double, bins * 2)
|
|
209
|
+
input.write_array_of_double(values)
|
|
210
|
+
|
|
211
|
+
plan = FFTWFFI.fftw_plan_dft_r2c_1d(@fft_size, input, output, FFTWFFI::ESTIMATE)
|
|
212
|
+
raise RuntimeError, "fftw failed to create transform plan" if plan.null?
|
|
213
|
+
|
|
214
|
+
FFTWFFI.fftw_execute(plan)
|
|
215
|
+
output.read_array_of_double(bins * 2).each_slice(2).map do |real, imag|
|
|
216
|
+
Complex(real, imag)
|
|
217
|
+
end
|
|
218
|
+
ensure
|
|
219
|
+
FFTWFFI.fftw_destroy_plan(plan) if plan && !plan.null?
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ffi"
|
|
4
|
+
|
|
5
|
+
module Vizcore
|
|
6
|
+
module Analysis
|
|
7
|
+
# Thin FFI wrapper for FFTW3 availability checks and symbol binding.
|
|
8
|
+
module FFTWFFI
|
|
9
|
+
extend FFI::Library
|
|
10
|
+
|
|
11
|
+
# Candidate library names across macOS/Linux/Windows.
|
|
12
|
+
LIBRARY_NAMES = %w[
|
|
13
|
+
fftw3
|
|
14
|
+
libfftw3.so.3
|
|
15
|
+
libfftw3.so
|
|
16
|
+
libfftw3-3
|
|
17
|
+
libfftw3.dylib
|
|
18
|
+
libfftw3.3.dylib
|
|
19
|
+
fftw3-3.dll
|
|
20
|
+
libfftw3-3.dll
|
|
21
|
+
].freeze
|
|
22
|
+
|
|
23
|
+
# FFTW planning flag (`FFTW_ESTIMATE`).
|
|
24
|
+
ESTIMATE = 64
|
|
25
|
+
|
|
26
|
+
class << self
|
|
27
|
+
# @return [Boolean] true when FFTW bindings can be attached.
|
|
28
|
+
def available?
|
|
29
|
+
attach_bindings!
|
|
30
|
+
true
|
|
31
|
+
rescue LoadError, FFI::NotFoundError
|
|
32
|
+
false
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def attach_bindings!
|
|
38
|
+
return if @bindings_attached
|
|
39
|
+
|
|
40
|
+
ffi_lib LIBRARY_NAMES
|
|
41
|
+
attach_function :fftw_plan_dft_r2c_1d, %i[int pointer pointer uint], :pointer
|
|
42
|
+
attach_function :fftw_execute, [:pointer], :void
|
|
43
|
+
attach_function :fftw_destroy_plan, [:pointer], :void
|
|
44
|
+
|
|
45
|
+
@bindings_attached = true
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|