rtlsdr 0.1.12 → 0.2.1
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/CHANGELOG.md +9 -0
- data/README.md +193 -1
- data/lib/rtlsdr/demod.rb +386 -0
- data/lib/rtlsdr/dsp/filter.rb +311 -0
- data/lib/rtlsdr/dsp.rb +302 -0
- data/lib/rtlsdr/fftw.rb +174 -0
- data/lib/rtlsdr/version.rb +1 -1
- data/lib/rtlsdr.rb +3 -0
- metadata +5 -2
|
@@ -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
|