rtlsdr 0.1.11 → 0.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.
@@ -0,0 +1,311 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RTLSDR
4
+ module DSP
5
+ # FIR (Finite Impulse Response) filter class
6
+ #
7
+ # Provides methods for designing and applying FIR filters to complex
8
+ # or real-valued samples. Supports lowpass, highpass, and bandpass
9
+ # filter types using the windowed sinc design method.
10
+ #
11
+ # @example Create and apply a lowpass filter
12
+ # filter = RTLSDR::DSP::Filter.lowpass(cutoff: 100_000, sample_rate: 2_048_000)
13
+ # filtered = filter.apply(samples)
14
+ #
15
+ # @example Chain multiple filters
16
+ # lpf = Filter.lowpass(cutoff: 100_000, sample_rate: 2_048_000)
17
+ # hpf = Filter.highpass(cutoff: 1000, sample_rate: 2_048_000)
18
+ # filtered = hpf.apply(lpf.apply(samples))
19
+ class Filter
20
+ # @return [Array<Float>] Filter coefficients
21
+ attr_reader :coefficients
22
+
23
+ # @return [Integer] Number of filter taps
24
+ attr_reader :taps
25
+
26
+ # @return [Symbol] Filter type (:lowpass, :highpass, :bandpass)
27
+ attr_reader :filter_type
28
+
29
+ # @return [Symbol] Window function used (:hamming, :hanning, :blackman, :kaiser)
30
+ attr_reader :window
31
+
32
+ # Design a lowpass FIR filter
33
+ #
34
+ # Creates a lowpass filter that passes frequencies below the cutoff
35
+ # and attenuates frequencies above it.
36
+ #
37
+ # @param [Numeric] cutoff Cutoff frequency in Hz
38
+ # @param [Numeric] sample_rate Sample rate in Hz
39
+ # @param [Integer] taps Number of filter taps (more = sharper rolloff, more delay)
40
+ # @param [Symbol] window Window function (:hamming, :hanning, :blackman)
41
+ # @return [Filter] Configured lowpass filter
42
+ # @example 100 kHz lowpass at 2.048 MHz sample rate
43
+ # filter = Filter.lowpass(cutoff: 100_000, sample_rate: 2_048_000, taps: 64)
44
+ def self.lowpass(cutoff:, sample_rate:, taps: 63, window: :hamming)
45
+ normalized_cutoff = cutoff.to_f / sample_rate
46
+ coeffs = design_sinc_filter(normalized_cutoff, taps, window)
47
+ new(coeffs, filter_type: :lowpass, window: window)
48
+ end
49
+
50
+ # Design a highpass FIR filter
51
+ #
52
+ # Creates a highpass filter that passes frequencies above the cutoff
53
+ # and attenuates frequencies below it. Implemented via spectral inversion
54
+ # of a lowpass filter.
55
+ #
56
+ # @param [Numeric] cutoff Cutoff frequency in Hz
57
+ # @param [Numeric] sample_rate Sample rate in Hz
58
+ # @param [Integer] taps Number of filter taps (must be odd for highpass)
59
+ # @param [Symbol] window Window function (:hamming, :hanning, :blackman)
60
+ # @return [Filter] Configured highpass filter
61
+ # @example 1 kHz highpass at 48 kHz sample rate
62
+ # filter = Filter.highpass(cutoff: 1000, sample_rate: 48_000, taps: 63)
63
+ def self.highpass(cutoff:, sample_rate:, taps: 63, window: :hamming)
64
+ # Ensure odd number of taps for highpass
65
+ taps += 1 unless taps.odd?
66
+
67
+ normalized_cutoff = cutoff.to_f / sample_rate
68
+ coeffs = design_sinc_filter(normalized_cutoff, taps, window)
69
+
70
+ # Spectral inversion: negate all coefficients, add 1 to center tap
71
+ mid = taps / 2
72
+ coeffs = coeffs.map.with_index do |c, i|
73
+ if i == mid
74
+ 1.0 - c
75
+ else
76
+ -c
77
+ end
78
+ end
79
+
80
+ new(coeffs, filter_type: :highpass, window: window)
81
+ end
82
+
83
+ # Design a bandpass FIR filter
84
+ #
85
+ # Creates a bandpass filter that passes frequencies between low and high
86
+ # cutoffs and attenuates frequencies outside that range.
87
+ #
88
+ # @param [Numeric] low Lower cutoff frequency in Hz
89
+ # @param [Numeric] high Upper cutoff frequency in Hz
90
+ # @param [Numeric] sample_rate Sample rate in Hz
91
+ # @param [Integer] taps Number of filter taps
92
+ # @param [Symbol] window Window function
93
+ # @return [Filter] Configured bandpass filter
94
+ # @example 300-3000 Hz bandpass (voice) at 48 kHz
95
+ # filter = Filter.bandpass(low: 300, high: 3000, sample_rate: 48_000)
96
+ def self.bandpass(low:, high:, sample_rate:, taps: 63, window: :hamming)
97
+ raise ArgumentError, "low must be less than high" if low >= high
98
+
99
+ # Ensure odd number of taps
100
+ taps += 1 unless taps.odd?
101
+
102
+ # Design as difference of two lowpass filters
103
+ norm_low = low.to_f / sample_rate
104
+ norm_high = high.to_f / sample_rate
105
+
106
+ low_coeffs = design_sinc_filter(norm_low, taps, window)
107
+ high_coeffs = design_sinc_filter(norm_high, taps, window)
108
+
109
+ # Bandpass = highpass(low) convolved with lowpass(high)
110
+ # Simpler: lowpass(high) - lowpass(low) then spectral shift
111
+ # Even simpler: subtract lowpass from highpass equivalent
112
+ coeffs = high_coeffs.zip(low_coeffs).map { |h, l| h - l }
113
+
114
+ new(coeffs, filter_type: :bandpass, window: window)
115
+ end
116
+
117
+ # Design a bandstop (notch) FIR filter
118
+ #
119
+ # Creates a filter that attenuates frequencies between low and high
120
+ # cutoffs and passes frequencies outside that range.
121
+ #
122
+ # @param [Numeric] low Lower cutoff frequency in Hz
123
+ # @param [Numeric] high Upper cutoff frequency in Hz
124
+ # @param [Numeric] sample_rate Sample rate in Hz
125
+ # @param [Integer] taps Number of filter taps
126
+ # @param [Symbol] window Window function
127
+ # @return [Filter] Configured bandstop filter
128
+ def self.bandstop(low:, high:, sample_rate:, taps: 63, window: :hamming)
129
+ raise ArgumentError, "low must be less than high" if low >= high
130
+
131
+ taps += 1 unless taps.odd?
132
+
133
+ norm_low = low.to_f / sample_rate
134
+ norm_high = high.to_f / sample_rate
135
+
136
+ low_coeffs = design_sinc_filter(norm_low, taps, window)
137
+ high_coeffs = design_sinc_filter(norm_high, taps, window)
138
+
139
+ # Bandstop = allpass - bandpass = lowpass(low) + highpass(high)
140
+ # highpass = spectral inversion of lowpass
141
+ mid = taps / 2
142
+
143
+ # Create highpass from high cutoff
144
+ hp_coeffs = high_coeffs.map.with_index do |c, i|
145
+ i == mid ? 1.0 - c : -c
146
+ end
147
+
148
+ # Add lowpass(low) + highpass(high)
149
+ coeffs = low_coeffs.zip(hp_coeffs).map { |l, h| l + h }
150
+
151
+ new(coeffs, filter_type: :bandstop, window: window)
152
+ end
153
+
154
+ # Create a filter from existing coefficients
155
+ #
156
+ # @param [Array<Float>] coefficients Filter coefficients
157
+ # @param [Symbol] filter_type Type of filter
158
+ # @param [Symbol] window Window function used
159
+ def initialize(coefficients, filter_type: :custom, window: :hamming)
160
+ @coefficients = coefficients.freeze
161
+ @taps = coefficients.length
162
+ @filter_type = filter_type
163
+ @window = window
164
+ end
165
+
166
+ # Apply the filter to samples using convolution
167
+ #
168
+ # @param [Array<Complex, Float>] samples Input samples
169
+ # @return [Array<Complex, Float>] Filtered samples
170
+ # @example Filter complex IQ samples
171
+ # filtered = filter.apply(iq_samples)
172
+ def apply(samples)
173
+ return [] if samples.empty?
174
+
175
+ convolve(samples, @coefficients)
176
+ end
177
+
178
+ # Apply filter with zero-phase (forward-backward filtering)
179
+ #
180
+ # Filters the signal twice (forward then backward) to eliminate
181
+ # phase distortion. The effective filter order is doubled.
182
+ #
183
+ # @param [Array<Complex, Float>] samples Input samples
184
+ # @return [Array<Complex, Float>] Zero-phase filtered samples
185
+ def apply_zero_phase(samples)
186
+ return [] if samples.empty?
187
+
188
+ # Forward filter
189
+ forward = convolve(samples, @coefficients)
190
+ # Reverse
191
+ reversed = forward.reverse
192
+ # Backward filter
193
+ backward = convolve(reversed, @coefficients)
194
+ # Reverse again
195
+ backward.reverse
196
+ end
197
+
198
+ # Get the frequency response of the filter
199
+ #
200
+ # Computes the magnitude response at the specified number of frequency points.
201
+ # Requires FFTW3 to be available.
202
+ #
203
+ # @param [Integer] points Number of frequency points
204
+ # @return [Array<Float>] Magnitude response (linear scale)
205
+ # @raise [RuntimeError] if FFTW3 is not available
206
+ def frequency_response(points = 512)
207
+ raise "FFTW3 required for frequency response" unless DSP.fft_available?
208
+
209
+ # Zero-pad coefficients to desired length
210
+ padded = @coefficients + Array.new(points - @taps, 0.0)
211
+ # Convert to complex
212
+ complex_padded = padded.map { |c| Complex(c, 0) }
213
+ # FFT
214
+ spectrum = DSP.fft(complex_padded)
215
+ # Return magnitude
216
+ spectrum.map(&:abs)
217
+ end
218
+
219
+ # Get the group delay of the filter
220
+ #
221
+ # For a symmetric FIR filter, the group delay is constant and equal
222
+ # to (taps - 1) / 2 samples.
223
+ #
224
+ # @return [Float] Group delay in samples
225
+ def group_delay
226
+ (@taps - 1) / 2.0
227
+ end
228
+
229
+ # @return [String] Human-readable filter description
230
+ def to_s
231
+ "#{@filter_type.capitalize} FIR filter (#{@taps} taps, #{@window} window)"
232
+ end
233
+
234
+ # Design windowed sinc filter coefficients
235
+ #
236
+ # @param [Float] cutoff Normalized cutoff frequency (0 to 0.5)
237
+ # @param [Integer] taps Number of taps
238
+ # @param [Symbol] window Window function
239
+ # @return [Array<Float>] Filter coefficients
240
+ def self.design_sinc_filter(cutoff, taps, window)
241
+ # Ensure odd number of taps for symmetric filter
242
+ taps += 1 unless taps.odd?
243
+ mid = (taps - 1) / 2.0
244
+
245
+ coeffs = Array.new(taps) do |n|
246
+ m = n - mid
247
+
248
+ # Sinc function (impulse response of ideal lowpass)
249
+ sinc = if m.abs < 1e-10
250
+ 2 * cutoff
251
+ else
252
+ Math.sin(2 * Math::PI * cutoff * m) / (Math::PI * m)
253
+ end
254
+
255
+ # Apply window
256
+ w = window_function(n, taps, window)
257
+ sinc * w
258
+ end
259
+
260
+ # Normalize for unity gain at DC
261
+ sum = coeffs.sum
262
+ coeffs.map { |c| c / sum }
263
+ end
264
+
265
+ # Calculate window function value
266
+ #
267
+ # @param [Integer] index Sample index
268
+ # @param [Integer] length Window length
269
+ # @param [Symbol] type Window type (:hamming, :hanning, :blackman, :rectangular, :none)
270
+ # @return [Float] Window value
271
+ def self.window_function(index, length, type)
272
+ # Default to Hamming for unknown types
273
+ type = :hamming unless %i[hamming hanning blackman rectangular none].include?(type)
274
+
275
+ case type
276
+ when :hamming
277
+ 0.54 - (0.46 * Math.cos(2 * Math::PI * index / (length - 1)))
278
+ when :hanning
279
+ 0.5 * (1 - Math.cos(2 * Math::PI * index / (length - 1)))
280
+ when :blackman
281
+ 0.42 - (0.5 * Math.cos(2 * Math::PI * index / (length - 1))) +
282
+ (0.08 * Math.cos(4 * Math::PI * index / (length - 1)))
283
+ else # :rectangular, :none
284
+ 1.0
285
+ end
286
+ end
287
+
288
+ private_class_method :design_sinc_filter, :window_function
289
+
290
+ private
291
+
292
+ # Convolve samples with filter coefficients
293
+ def convolve(samples, filter)
294
+ n = samples.length
295
+ m = filter.length
296
+ result = Array.new(n)
297
+
298
+ n.times do |i|
299
+ sum = samples[0].is_a?(Complex) ? Complex(0, 0) : 0.0
300
+ m.times do |j|
301
+ k = i - j + (m / 2)
302
+ sum += samples[k] * filter[j] if k >= 0 && k < n
303
+ end
304
+ result[i] = sum
305
+ end
306
+
307
+ result
308
+ end
309
+ end
310
+ end
311
+ end
data/lib/rtlsdr/dsp.rb CHANGED
@@ -14,11 +14,13 @@ module RTLSDR
14
14
  #
15
15
  # Features:
16
16
  # * IQ data conversion to complex samples
17
+ # * FFT and IFFT via FFTW3 (when available)
17
18
  # * Power spectrum analysis with windowing
18
19
  # * Peak detection and frequency estimation
19
20
  # * DC removal and filtering
20
21
  # * Magnitude and phase extraction
21
22
  # * Average power calculation
23
+ # * Decimation and resampling
22
24
  #
23
25
  # @example Basic signal analysis
24
26
  # raw_data = device.read_sync(2048)
@@ -196,5 +198,305 @@ module RTLSDR
196
198
  time_duration = samples.length.to_f / sample_rate
197
199
  (zero_crossings / 2.0) / time_duration
198
200
  end
201
+
202
+ # =========================================================================
203
+ # FFT Methods (require FFTW3)
204
+ # =========================================================================
205
+
206
+ # Check if FFT is available (FFTW3 loaded)
207
+ #
208
+ # @return [Boolean] true if FFTW3 is available for FFT operations
209
+ # @example Check FFT availability
210
+ # if RTLSDR::DSP.fft_available?
211
+ # spectrum = RTLSDR::DSP.fft(samples)
212
+ # end
213
+ def self.fft_available?
214
+ defined?(RTLSDR::FFTW) && RTLSDR::FFTW.available?
215
+ end
216
+
217
+ # Compute forward FFT of complex samples
218
+ #
219
+ # Performs a Fast Fourier Transform using FFTW3. The result is an array
220
+ # of complex frequency bins from DC to Nyquist to negative frequencies.
221
+ #
222
+ # @param [Array<Complex>] samples Input complex time-domain samples
223
+ # @return [Array<Complex>] Complex frequency-domain bins
224
+ # @raise [RuntimeError] if FFTW3 is not available
225
+ # @example Compute FFT
226
+ # spectrum = RTLSDR::DSP.fft(samples)
227
+ # magnitudes = spectrum.map(&:abs)
228
+ def self.fft(samples)
229
+ raise "FFTW3 not available. Install libfftw3." unless fft_available?
230
+
231
+ RTLSDR::FFTW.forward(samples)
232
+ end
233
+
234
+ # Compute inverse FFT of complex spectrum
235
+ #
236
+ # Performs an Inverse Fast Fourier Transform using FFTW3. Converts
237
+ # frequency-domain data back to time-domain samples.
238
+ #
239
+ # @param [Array<Complex>] spectrum Input complex frequency-domain bins
240
+ # @return [Array<Complex>] Complex time-domain samples
241
+ # @raise [RuntimeError] if FFTW3 is not available
242
+ # @example Reconstruct time domain
243
+ # reconstructed = RTLSDR::DSP.ifft(spectrum)
244
+ def self.ifft(spectrum)
245
+ raise "FFTW3 not available. Install libfftw3." unless fft_available?
246
+
247
+ RTLSDR::FFTW.backward(spectrum)
248
+ end
249
+
250
+ # Compute power spectrum in decibels
251
+ #
252
+ # Calculates the power spectrum using FFT and returns values in dB.
253
+ # Applies optional windowing to reduce spectral leakage.
254
+ #
255
+ # @param [Array<Complex>] samples Input complex samples
256
+ # @param [Symbol] window Window type (:hanning, :hamming, :blackman, :none)
257
+ # @return [Array<Float>] Power spectrum in dB
258
+ # @example Get dB spectrum
259
+ # power_db = RTLSDR::DSP.fft_power_db(samples, window: :hanning)
260
+ def self.fft_power_db(samples, window: :hanning)
261
+ windowed = apply_window(samples, window)
262
+ spectrum = fft(windowed)
263
+ spectrum.map { |s| 10 * Math.log10(s.abs2 + 1e-20) }
264
+ end
265
+
266
+ # Shift FFT output to center DC component
267
+ #
268
+ # Rearranges FFT output so that DC (0 Hz) is in the center, with
269
+ # negative frequencies on the left and positive on the right.
270
+ # Similar to numpy.fft.fftshift.
271
+ #
272
+ # @param [Array] spectrum FFT output array
273
+ # @return [Array] Shifted spectrum with DC centered
274
+ # @example Center the spectrum
275
+ # centered = RTLSDR::DSP.fft_shift(spectrum)
276
+ def self.fft_shift(spectrum)
277
+ n = spectrum.length
278
+ mid = n / 2
279
+ spectrum[mid..] + spectrum[0...mid]
280
+ end
281
+
282
+ # Inverse of fft_shift
283
+ #
284
+ # Reverses the fft_shift operation to restore original FFT ordering.
285
+ #
286
+ # @param [Array] spectrum Shifted spectrum
287
+ # @return [Array] Unshifted spectrum
288
+ def self.ifft_shift(spectrum)
289
+ n = spectrum.length
290
+ mid = (n + 1) / 2
291
+ spectrum[mid..] + spectrum[0...mid]
292
+ end
293
+
294
+ # Apply window function to samples
295
+ #
296
+ # Applies a window function to reduce spectral leakage in FFT analysis.
297
+ # Supported windows: :hanning, :hamming, :blackman, :none
298
+ #
299
+ # @param [Array<Complex>] samples Input samples
300
+ # @param [Symbol] window_type Window function to apply
301
+ # @return [Array<Complex>] Windowed samples
302
+ # @example Apply Hanning window
303
+ # windowed = RTLSDR::DSP.apply_window(samples, :hanning)
304
+ def self.apply_window(samples, window_type = :hanning)
305
+ n = samples.length
306
+ return samples if n.zero? || window_type == :none
307
+
308
+ samples.each_with_index.map do |sample, i|
309
+ window = case window_type
310
+ when :hanning
311
+ 0.5 * (1 - Math.cos(2 * Math::PI * i / (n - 1)))
312
+ when :hamming
313
+ 0.54 - (0.46 * Math.cos(2 * Math::PI * i / (n - 1)))
314
+ when :blackman
315
+ 0.42 - (0.5 * Math.cos(2 * Math::PI * i / (n - 1))) +
316
+ (0.08 * Math.cos(4 * Math::PI * i / (n - 1)))
317
+ else
318
+ 1.0
319
+ end
320
+ sample * window
321
+ end
322
+ end
323
+
324
+ # =========================================================================
325
+ # Decimation and Resampling
326
+ # =========================================================================
327
+
328
+ # Decimate samples by an integer factor
329
+ #
330
+ # Reduces the sample rate by applying a lowpass anti-aliasing filter
331
+ # and then downsampling. The cutoff frequency is automatically set
332
+ # to prevent aliasing.
333
+ #
334
+ # @param [Array<Complex>] samples Input samples
335
+ # @param [Integer] factor Decimation factor (must be >= 1)
336
+ # @param [Integer] filter_taps Number of filter taps (more = sharper rolloff)
337
+ # @return [Array<Complex>] Decimated samples
338
+ # @example Decimate by 4
339
+ # decimated = RTLSDR::DSP.decimate(samples, 4)
340
+ def self.decimate(samples, factor, filter_taps: 31)
341
+ return samples if factor <= 1
342
+
343
+ # Design lowpass filter with cutoff at 0.5/factor of Nyquist
344
+ cutoff = 0.5 / factor
345
+ filter = design_lowpass(cutoff, filter_taps)
346
+
347
+ # Apply filter
348
+ filtered = convolve(samples, filter)
349
+
350
+ # Downsample
351
+ result = []
352
+ (0...filtered.length).step(factor) { |i| result << filtered[i] }
353
+ result
354
+ end
355
+
356
+ # Interpolate samples by an integer factor
357
+ #
358
+ # Increases the sample rate by inserting zeros and then applying
359
+ # a lowpass interpolation filter.
360
+ #
361
+ # @param [Array<Complex>] samples Input samples
362
+ # @param [Integer] factor Interpolation factor (must be >= 1)
363
+ # @param [Integer] filter_taps Number of filter taps
364
+ # @return [Array<Complex>] Interpolated samples
365
+ # @example Interpolate by 2
366
+ # interpolated = RTLSDR::DSP.interpolate(samples, 2)
367
+ def self.interpolate(samples, factor, filter_taps: 31)
368
+ return samples if factor <= 1
369
+
370
+ # Insert zeros (upsample)
371
+ upsampled = []
372
+ samples.each do |sample|
373
+ upsampled << sample
374
+ (factor - 1).times { upsampled << Complex(0, 0) }
375
+ end
376
+
377
+ # Design lowpass filter
378
+ cutoff = 0.5 / factor
379
+ filter = design_lowpass(cutoff, filter_taps)
380
+
381
+ # Apply filter and scale
382
+ filtered = convolve(upsampled, filter)
383
+ filtered.map { |s| s * factor }
384
+ end
385
+
386
+ # Resample to a new sample rate using rational resampling
387
+ #
388
+ # Resamples by first interpolating then decimating. The interpolation
389
+ # and decimation factors are determined by the ratio of sample rates.
390
+ #
391
+ # @param [Array<Complex>] samples Input samples
392
+ # @param [Integer] from_rate Original sample rate in Hz
393
+ # @param [Integer] to_rate Target sample rate in Hz
394
+ # @param [Integer] filter_taps Number of filter taps
395
+ # @return [Array<Complex>] Resampled samples
396
+ # @example Resample from 2.4 MHz to 48 kHz
397
+ # resampled = RTLSDR::DSP.resample(samples, from_rate: 2_400_000, to_rate: 48_000)
398
+ def self.resample(samples, from_rate:, to_rate:, filter_taps: 31)
399
+ return samples if from_rate == to_rate
400
+
401
+ # Find GCD to minimize interpolation/decimation factors
402
+ gcd = from_rate.gcd(to_rate)
403
+ interp_factor = to_rate / gcd
404
+ decim_factor = from_rate / gcd
405
+
406
+ # Limit factors to reasonable values
407
+ max_factor = 100
408
+ if interp_factor > max_factor || decim_factor > max_factor
409
+ # Fall back to simple linear interpolation for large ratios
410
+ return linear_resample(samples, from_rate, to_rate)
411
+ end
412
+
413
+ # Interpolate then decimate
414
+ result = samples
415
+ result = interpolate(result, interp_factor, filter_taps: filter_taps) if interp_factor > 1
416
+ result = decimate(result, decim_factor, filter_taps: filter_taps) if decim_factor > 1
417
+ result
418
+ end
419
+
420
+ # Design a lowpass FIR filter using windowed sinc
421
+ #
422
+ # @param [Float] cutoff Normalized cutoff frequency (0 to 0.5)
423
+ # @param [Integer] taps Number of filter taps (should be odd)
424
+ # @return [Array<Float>] Filter coefficients
425
+ def self.design_lowpass(cutoff, taps = 31)
426
+ # Ensure odd number of taps for symmetry
427
+ taps += 1 unless taps.odd?
428
+ mid = (taps - 1) / 2.0
429
+
430
+ coeffs = Array.new(taps) do |n|
431
+ m = n - mid
432
+ # Sinc function
433
+ sinc = if m.zero?
434
+ 2 * cutoff
435
+ else
436
+ Math.sin(2 * Math::PI * cutoff * m) / (Math::PI * m)
437
+ end
438
+ # Hamming window
439
+ window = 0.54 - (0.46 * Math.cos(2 * Math::PI * n / (taps - 1)))
440
+ sinc * window
441
+ end
442
+
443
+ # Normalize to unity gain at DC
444
+ sum = coeffs.sum
445
+ coeffs.map { |c| c / sum }
446
+ end
447
+
448
+ # Convolve samples with filter coefficients
449
+ #
450
+ # @param [Array<Complex>] samples Input samples
451
+ # @param [Array<Float>] filter Filter coefficients
452
+ # @return [Array<Complex>] Filtered samples
453
+ def self.convolve(samples, filter)
454
+ return samples if filter.empty?
455
+
456
+ n = samples.length
457
+ m = filter.length
458
+ result = Array.new(n, Complex(0, 0))
459
+
460
+ n.times do |i|
461
+ sum = Complex(0, 0)
462
+ m.times do |j|
463
+ k = i - j + (m / 2)
464
+ sum += samples[k] * filter[j] if k >= 0 && k < n
465
+ end
466
+ result[i] = sum
467
+ end
468
+
469
+ result
470
+ end
471
+
472
+ private_class_method :design_lowpass, :convolve
473
+
474
+ # Simple linear interpolation for large resampling ratios
475
+ #
476
+ # @param [Array<Complex>] samples Input samples
477
+ # @param [Integer] from_rate Original sample rate
478
+ # @param [Integer] to_rate Target sample rate
479
+ # @return [Array<Complex>] Resampled samples
480
+ def self.linear_resample(samples, from_rate, to_rate)
481
+ return samples if samples.empty?
482
+
483
+ ratio = from_rate.to_f / to_rate
484
+ output_length = (samples.length / ratio).to_i
485
+ return samples if output_length <= 0
486
+
487
+ Array.new(output_length) do |i|
488
+ pos = i * ratio
489
+ idx = pos.to_i
490
+ frac = pos - idx
491
+
492
+ if idx + 1 < samples.length
493
+ (samples[idx] * (1 - frac)) + (samples[idx + 1] * frac)
494
+ else
495
+ samples[idx] || Complex(0, 0)
496
+ end
497
+ end
498
+ end
499
+
500
+ private_class_method :linear_resample
199
501
  end
200
502
  end