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.
Files changed (71) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +170 -0
  4. data/docs/GETTING_STARTED.md +105 -0
  5. data/examples/assets/complex_demo_loop.wav +0 -0
  6. data/examples/basic.rb +9 -0
  7. data/examples/complex_audio_showcase.rb +261 -0
  8. data/examples/custom_shader.rb +21 -0
  9. data/examples/file_audio_demo.rb +74 -0
  10. data/examples/intro_drop.rb +38 -0
  11. data/examples/midi_scene_switch.rb +32 -0
  12. data/examples/shaders/custom_wave.frag +30 -0
  13. data/exe/vizcore +6 -0
  14. data/frontend/index.html +148 -0
  15. data/frontend/src/main.js +304 -0
  16. data/frontend/src/renderer/engine.js +135 -0
  17. data/frontend/src/renderer/layer-manager.js +456 -0
  18. data/frontend/src/renderer/shader-manager.js +69 -0
  19. data/frontend/src/shaders/builtins.js +244 -0
  20. data/frontend/src/shaders/post-effects.js +85 -0
  21. data/frontend/src/visuals/geometry.js +66 -0
  22. data/frontend/src/visuals/particle-system.js +148 -0
  23. data/frontend/src/visuals/text-renderer.js +143 -0
  24. data/frontend/src/visuals/vj-effects.js +56 -0
  25. data/frontend/src/websocket-client.js +131 -0
  26. data/lib/vizcore/analysis/band_splitter.rb +63 -0
  27. data/lib/vizcore/analysis/beat_detector.rb +70 -0
  28. data/lib/vizcore/analysis/bpm_estimator.rb +86 -0
  29. data/lib/vizcore/analysis/fft_processor.rb +224 -0
  30. data/lib/vizcore/analysis/fftw_ffi.rb +50 -0
  31. data/lib/vizcore/analysis/pipeline.rb +72 -0
  32. data/lib/vizcore/analysis/smoother.rb +74 -0
  33. data/lib/vizcore/analysis.rb +14 -0
  34. data/lib/vizcore/audio/base_input.rb +39 -0
  35. data/lib/vizcore/audio/dummy_sine_input.rb +40 -0
  36. data/lib/vizcore/audio/file_input.rb +163 -0
  37. data/lib/vizcore/audio/input_manager.rb +133 -0
  38. data/lib/vizcore/audio/mic_input.rb +121 -0
  39. data/lib/vizcore/audio/midi_input.rb +246 -0
  40. data/lib/vizcore/audio/portaudio_ffi.rb +243 -0
  41. data/lib/vizcore/audio/ring_buffer.rb +92 -0
  42. data/lib/vizcore/audio.rb +16 -0
  43. data/lib/vizcore/cli.rb +115 -0
  44. data/lib/vizcore/config.rb +46 -0
  45. data/lib/vizcore/dsl/engine.rb +229 -0
  46. data/lib/vizcore/dsl/file_watcher.rb +108 -0
  47. data/lib/vizcore/dsl/layer_builder.rb +182 -0
  48. data/lib/vizcore/dsl/mapping_resolver.rb +81 -0
  49. data/lib/vizcore/dsl/midi_map_executor.rb +188 -0
  50. data/lib/vizcore/dsl/scene_builder.rb +44 -0
  51. data/lib/vizcore/dsl/shader_source_resolver.rb +71 -0
  52. data/lib/vizcore/dsl/transition_controller.rb +166 -0
  53. data/lib/vizcore/dsl.rb +16 -0
  54. data/lib/vizcore/errors.rb +27 -0
  55. data/lib/vizcore/renderer/frame_scheduler.rb +75 -0
  56. data/lib/vizcore/renderer/scene_serializer.rb +73 -0
  57. data/lib/vizcore/renderer.rb +10 -0
  58. data/lib/vizcore/server/frame_broadcaster.rb +351 -0
  59. data/lib/vizcore/server/rack_app.rb +183 -0
  60. data/lib/vizcore/server/runner.rb +357 -0
  61. data/lib/vizcore/server/websocket_handler.rb +163 -0
  62. data/lib/vizcore/server.rb +12 -0
  63. data/lib/vizcore/templates/basic_scene.rb +10 -0
  64. data/lib/vizcore/templates/custom_shader_scene.rb +22 -0
  65. data/lib/vizcore/templates/custom_wave.frag +31 -0
  66. data/lib/vizcore/templates/intro_drop_scene.rb +40 -0
  67. data/lib/vizcore/templates/midi_control_scene.rb +33 -0
  68. data/lib/vizcore/templates/project_readme.md +35 -0
  69. data/lib/vizcore/version.rb +6 -0
  70. data/lib/vizcore.rb +37 -0
  71. 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