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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: '019e21a841f8f9f9aaf543741487c734d4b1c7b2ff4b2bfd20e37cf56fbfcf00'
|
|
4
|
+
data.tar.gz: c4c3657ae68d2b5f8f2b931c8256447b9ab8feb0d0bbdfe61e7d035e34ee4053
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: cf8de21d50dbf21fd92dfec27cdeab872ccf83e3bbe1132ebbeab74bba98ae4e84da90f794574fc8c742da761768f2f49b26f5ea3c345b49960646a0e63ea053
|
|
7
|
+
data.tar.gz: 5d587f36838184747666800ac202e52d33584169926f9cd2bd72db1d39a5f4189be4b7025898c675f274134ba326537f08782f9682d7e6fe58dce0921f21b497
|
data/CHANGELOG.md
CHANGED
data/README.md
CHANGED
|
@@ -30,6 +30,20 @@ sudo apt-get install librtlsdr-dev
|
|
|
30
30
|
brew install librtlsdr
|
|
31
31
|
```
|
|
32
32
|
|
|
33
|
+
### Optional: FFTW3 for FFT Support
|
|
34
|
+
|
|
35
|
+
To enable FFT features (fast spectrum analysis, frequency response, etc.), install FFTW3:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
# Ubuntu/Debian
|
|
39
|
+
sudo apt-get install libfftw3-dev
|
|
40
|
+
|
|
41
|
+
# macOS
|
|
42
|
+
brew install fftw
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
FFT features are optional - the gem works without FFTW3, but FFT-related functions will raise an error if called.
|
|
46
|
+
|
|
33
47
|
### From Source
|
|
34
48
|
|
|
35
49
|
If you need to build librtlsdr from source, the gem will automatically try to build it:
|
|
@@ -169,6 +183,128 @@ power_spectrum = RTLSDR::DSP.power_spectrum(samples, 1024)
|
|
|
169
183
|
peak_bin, peak_power = RTLSDR::DSP.find_peak(power_spectrum)
|
|
170
184
|
```
|
|
171
185
|
|
|
186
|
+
### FFT Analysis (requires FFTW3)
|
|
187
|
+
|
|
188
|
+
```ruby
|
|
189
|
+
# Check if FFT is available
|
|
190
|
+
if RTLSDR::DSP.fft_available?
|
|
191
|
+
# Forward FFT
|
|
192
|
+
spectrum = RTLSDR::DSP.fft(samples)
|
|
193
|
+
|
|
194
|
+
# Power spectrum in dB with windowing
|
|
195
|
+
power_db = RTLSDR::DSP.fft_power_db(samples, window: :hanning)
|
|
196
|
+
|
|
197
|
+
# Shift DC to center (like numpy.fft.fftshift)
|
|
198
|
+
centered = RTLSDR::DSP.fft_shift(spectrum)
|
|
199
|
+
|
|
200
|
+
# Inverse FFT
|
|
201
|
+
reconstructed = RTLSDR::DSP.ifft(spectrum)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# Available window functions: :hanning, :hamming, :blackman, :none
|
|
205
|
+
windowed = RTLSDR::DSP.apply_window(samples, :blackman)
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
### Digital Filters
|
|
209
|
+
|
|
210
|
+
```ruby
|
|
211
|
+
# Create lowpass filter (100 kHz cutoff at 2.048 MHz sample rate)
|
|
212
|
+
lpf = RTLSDR::DSP::Filter.lowpass(
|
|
213
|
+
cutoff: 100_000,
|
|
214
|
+
sample_rate: 2_048_000,
|
|
215
|
+
taps: 63,
|
|
216
|
+
window: :hamming
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
# Create highpass filter
|
|
220
|
+
hpf = RTLSDR::DSP::Filter.highpass(cutoff: 1000, sample_rate: 48_000)
|
|
221
|
+
|
|
222
|
+
# Create bandpass filter (voice: 300-3000 Hz)
|
|
223
|
+
bpf = RTLSDR::DSP::Filter.bandpass(low: 300, high: 3000, sample_rate: 48_000)
|
|
224
|
+
|
|
225
|
+
# Create bandstop (notch) filter
|
|
226
|
+
notch = RTLSDR::DSP::Filter.bandstop(low: 50, high: 60, sample_rate: 48_000)
|
|
227
|
+
|
|
228
|
+
# Apply filter
|
|
229
|
+
filtered = lpf.apply(samples)
|
|
230
|
+
|
|
231
|
+
# Zero-phase filtering (no phase distortion)
|
|
232
|
+
filtered = lpf.apply_zero_phase(samples)
|
|
233
|
+
|
|
234
|
+
# Get filter properties
|
|
235
|
+
puts lpf.group_delay # Delay in samples
|
|
236
|
+
puts lpf.taps # Number of filter taps
|
|
237
|
+
|
|
238
|
+
# Get frequency response (requires FFTW3)
|
|
239
|
+
response = lpf.frequency_response(256)
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
### Decimation and Resampling
|
|
243
|
+
|
|
244
|
+
```ruby
|
|
245
|
+
# Decimate by factor of 4 (with anti-aliasing filter)
|
|
246
|
+
decimated = RTLSDR::DSP.decimate(samples, 4)
|
|
247
|
+
|
|
248
|
+
# Interpolate by factor of 2
|
|
249
|
+
interpolated = RTLSDR::DSP.interpolate(samples, 2)
|
|
250
|
+
|
|
251
|
+
# Resample from 2.4 MHz to 48 kHz
|
|
252
|
+
audio = RTLSDR::DSP.resample(samples, from_rate: 2_400_000, to_rate: 48_000)
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
## Demodulation
|
|
256
|
+
|
|
257
|
+
The gem includes demodulators for common radio signals:
|
|
258
|
+
|
|
259
|
+
### FM Demodulation
|
|
260
|
+
|
|
261
|
+
```ruby
|
|
262
|
+
# Wideband FM (broadcast radio 88-108 MHz)
|
|
263
|
+
audio = RTLSDR::Demod.fm(samples, sample_rate: 2_048_000, audio_rate: 48_000)
|
|
264
|
+
|
|
265
|
+
# With European de-emphasis (50µs instead of US 75µs)
|
|
266
|
+
audio = RTLSDR::Demod.fm(samples, sample_rate: 2_048_000, tau: 50e-6)
|
|
267
|
+
|
|
268
|
+
# Narrowband FM (voice radio, ham, FRS)
|
|
269
|
+
audio = RTLSDR::Demod.nfm(samples, sample_rate: 2_048_000)
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
### AM Demodulation
|
|
273
|
+
|
|
274
|
+
```ruby
|
|
275
|
+
# Envelope detection (simple AM)
|
|
276
|
+
audio = RTLSDR::Demod.am(samples, sample_rate: 2_048_000)
|
|
277
|
+
|
|
278
|
+
# Synchronous AM (better quality)
|
|
279
|
+
audio = RTLSDR::Demod.am_sync(samples, sample_rate: 2_048_000)
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
### SSB Demodulation
|
|
283
|
+
|
|
284
|
+
```ruby
|
|
285
|
+
# Upper Sideband (ham radio above 10 MHz)
|
|
286
|
+
audio = RTLSDR::Demod.usb(samples, sample_rate: 2_048_000, bfo_offset: 1500)
|
|
287
|
+
|
|
288
|
+
# Lower Sideband (ham radio below 10 MHz)
|
|
289
|
+
audio = RTLSDR::Demod.lsb(samples, sample_rate: 2_048_000, bfo_offset: 1500)
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
### Helper Functions
|
|
293
|
+
|
|
294
|
+
```ruby
|
|
295
|
+
# Generate complex oscillator for mixing
|
|
296
|
+
osc = RTLSDR::Demod.complex_oscillator(1024, 1000, 48_000)
|
|
297
|
+
|
|
298
|
+
# Frequency shift a signal
|
|
299
|
+
shifted = RTLSDR::Demod.mix(samples, -10_000, 2_048_000)
|
|
300
|
+
|
|
301
|
+
# FM discriminator (phase difference)
|
|
302
|
+
baseband = RTLSDR::Demod.phase_diff(samples)
|
|
303
|
+
|
|
304
|
+
# De-emphasis filter for FM audio
|
|
305
|
+
filtered = RTLSDR::Demod.deemphasis(audio, 75e-6, 48_000)
|
|
306
|
+
```
|
|
307
|
+
|
|
172
308
|
## Frequency Scanning
|
|
173
309
|
|
|
174
310
|
Scan frequency ranges to find active signals:
|
|
@@ -349,6 +485,8 @@ ruby examples/spectrum_analyzer.rb
|
|
|
349
485
|
|
|
350
486
|
### DSP Functions
|
|
351
487
|
|
|
488
|
+
#### Basic Functions
|
|
489
|
+
|
|
352
490
|
- `RTLSDR::DSP.iq_to_complex(data)` - Convert IQ bytes to complex samples
|
|
353
491
|
- `RTLSDR::DSP.average_power(samples)` - Calculate average power
|
|
354
492
|
- `RTLSDR::DSP.power_spectrum(samples, window_size)` - Power spectrum
|
|
@@ -358,6 +496,59 @@ ruby examples/spectrum_analyzer.rb
|
|
|
358
496
|
- `RTLSDR::DSP.phase(samples)` - Extract phase information
|
|
359
497
|
- `RTLSDR::DSP.estimate_frequency(samples, sample_rate)` - Frequency estimation
|
|
360
498
|
|
|
499
|
+
#### FFT Functions (requires FFTW3)
|
|
500
|
+
|
|
501
|
+
- `RTLSDR::DSP.fft_available?` - Check if FFTW3 is available
|
|
502
|
+
- `RTLSDR::DSP.fft(samples)` - Forward FFT
|
|
503
|
+
- `RTLSDR::DSP.ifft(spectrum)` - Inverse FFT
|
|
504
|
+
- `RTLSDR::DSP.fft_power_db(samples, window:)` - Power spectrum in dB
|
|
505
|
+
- `RTLSDR::DSP.fft_shift(spectrum)` - Shift DC to center
|
|
506
|
+
- `RTLSDR::DSP.ifft_shift(spectrum)` - Reverse fft_shift
|
|
507
|
+
- `RTLSDR::DSP.apply_window(samples, type)` - Apply window function
|
|
508
|
+
|
|
509
|
+
#### Decimation and Resampling
|
|
510
|
+
|
|
511
|
+
- `RTLSDR::DSP.decimate(samples, factor)` - Decimate with anti-aliasing
|
|
512
|
+
- `RTLSDR::DSP.interpolate(samples, factor)` - Interpolate samples
|
|
513
|
+
- `RTLSDR::DSP.resample(samples, from_rate:, to_rate:)` - Rational resampling
|
|
514
|
+
|
|
515
|
+
### Filter Class
|
|
516
|
+
|
|
517
|
+
- `Filter.lowpass(cutoff:, sample_rate:, taps:, window:)` - Design lowpass filter
|
|
518
|
+
- `Filter.highpass(cutoff:, sample_rate:, taps:, window:)` - Design highpass filter
|
|
519
|
+
- `Filter.bandpass(low:, high:, sample_rate:, taps:, window:)` - Design bandpass filter
|
|
520
|
+
- `Filter.bandstop(low:, high:, sample_rate:, taps:, window:)` - Design bandstop filter
|
|
521
|
+
- `#apply(samples)` - Apply filter to samples
|
|
522
|
+
- `#apply_zero_phase(samples)` - Zero-phase filtering
|
|
523
|
+
- `#frequency_response(points)` - Get filter frequency response
|
|
524
|
+
- `#group_delay` - Get filter group delay
|
|
525
|
+
- `#coefficients` - Get filter coefficients
|
|
526
|
+
- `#taps` - Number of filter taps
|
|
527
|
+
|
|
528
|
+
### Demod Module
|
|
529
|
+
|
|
530
|
+
#### FM Demodulation
|
|
531
|
+
|
|
532
|
+
- `Demod.fm(samples, sample_rate:, audio_rate:, deviation:, tau:)` - Wideband FM
|
|
533
|
+
- `Demod.nfm(samples, sample_rate:, audio_rate:, deviation:)` - Narrowband FM
|
|
534
|
+
|
|
535
|
+
#### AM Demodulation
|
|
536
|
+
|
|
537
|
+
- `Demod.am(samples, sample_rate:, audio_rate:, audio_bandwidth:)` - Envelope detection
|
|
538
|
+
- `Demod.am_sync(samples, sample_rate:, audio_rate:, audio_bandwidth:)` - Synchronous AM
|
|
539
|
+
|
|
540
|
+
#### SSB Demodulation
|
|
541
|
+
|
|
542
|
+
- `Demod.usb(samples, sample_rate:, audio_rate:, bfo_offset:)` - Upper Sideband
|
|
543
|
+
- `Demod.lsb(samples, sample_rate:, audio_rate:, bfo_offset:)` - Lower Sideband
|
|
544
|
+
|
|
545
|
+
#### Helper Functions
|
|
546
|
+
|
|
547
|
+
- `Demod.complex_oscillator(length, frequency, sample_rate)` - Generate carrier
|
|
548
|
+
- `Demod.mix(samples, frequency, sample_rate)` - Frequency shift signal
|
|
549
|
+
- `Demod.phase_diff(samples)` - FM discriminator
|
|
550
|
+
- `Demod.deemphasis(samples, tau, sample_rate)` - De-emphasis filter
|
|
551
|
+
|
|
361
552
|
### Scanner Class
|
|
362
553
|
|
|
363
554
|
- `Scanner.new(device, options)` - Create frequency scanner
|
|
@@ -384,9 +575,10 @@ This gem is licensed under the MIT license.
|
|
|
384
575
|
|
|
385
576
|
## Requirements
|
|
386
577
|
|
|
387
|
-
- Ruby
|
|
578
|
+
- Ruby 3.3 or later
|
|
388
579
|
- librtlsdr (installed system-wide or built locally)
|
|
389
580
|
- FFI gem
|
|
581
|
+
- libfftw3 (optional, for FFT functions)
|
|
390
582
|
|
|
391
583
|
## Supported Platforms
|
|
392
584
|
|
data/lib/rtlsdr/demod.rb
ADDED
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RTLSDR
|
|
4
|
+
# Demodulation algorithms for common radio signals
|
|
5
|
+
#
|
|
6
|
+
# The Demod module provides methods for demodulating FM, AM, and SSB signals
|
|
7
|
+
# from complex IQ samples. All demodulators output real-valued audio samples
|
|
8
|
+
# that can be played back or written to audio files.
|
|
9
|
+
#
|
|
10
|
+
# @example Demodulate FM radio
|
|
11
|
+
# samples = device.read_samples(262144)
|
|
12
|
+
# audio = RTLSDR::Demod.fm(samples, sample_rate: 2_048_000)
|
|
13
|
+
#
|
|
14
|
+
# @example Demodulate AM signal
|
|
15
|
+
# audio = RTLSDR::Demod.am(samples, sample_rate: 2_048_000)
|
|
16
|
+
#
|
|
17
|
+
# @example Demodulate SSB (upper sideband)
|
|
18
|
+
# audio = RTLSDR::Demod.usb(samples, sample_rate: 2_048_000, bfo_offset: 1500)
|
|
19
|
+
module Demod
|
|
20
|
+
# =========================================================================
|
|
21
|
+
# Helper Functions
|
|
22
|
+
# =========================================================================
|
|
23
|
+
|
|
24
|
+
# Generate a complex oscillator (carrier signal)
|
|
25
|
+
#
|
|
26
|
+
# Creates an array of complex exponentials: exp(j * 2 * pi * freq * t)
|
|
27
|
+
# Used for frequency shifting (mixing) signals.
|
|
28
|
+
#
|
|
29
|
+
# @param [Integer] length Number of samples to generate
|
|
30
|
+
# @param [Numeric] frequency Oscillator frequency in Hz
|
|
31
|
+
# @param [Numeric] sample_rate Sample rate in Hz
|
|
32
|
+
# @return [Array<Complex>] Complex oscillator samples
|
|
33
|
+
# @example Generate 1 kHz oscillator at 48 kHz sample rate
|
|
34
|
+
# osc = RTLSDR::Demod.complex_oscillator(1024, 1000, 48_000)
|
|
35
|
+
def self.complex_oscillator(length, frequency, sample_rate)
|
|
36
|
+
omega = 2.0 * Math::PI * frequency / sample_rate
|
|
37
|
+
Array.new(length) { |i| Complex(Math.cos(omega * i), Math.sin(omega * i)) }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Mix (frequency shift) a signal
|
|
41
|
+
#
|
|
42
|
+
# Multiplies the input signal by a complex oscillator to shift its
|
|
43
|
+
# frequency. Positive frequency shifts up, negative shifts down.
|
|
44
|
+
#
|
|
45
|
+
# @param [Array<Complex>] samples Input complex samples
|
|
46
|
+
# @param [Numeric] frequency Shift frequency in Hz (negative = shift down)
|
|
47
|
+
# @param [Numeric] sample_rate Sample rate in Hz
|
|
48
|
+
# @return [Array<Complex>] Frequency-shifted samples
|
|
49
|
+
# @example Shift signal down by 10 kHz
|
|
50
|
+
# shifted = RTLSDR::Demod.mix(samples, -10_000, 2_048_000)
|
|
51
|
+
def self.mix(samples, frequency, sample_rate)
|
|
52
|
+
omega = 2.0 * Math::PI * frequency / sample_rate
|
|
53
|
+
samples.each_with_index.map do |sample, i|
|
|
54
|
+
sample * Complex(Math.cos(omega * i), Math.sin(omega * i))
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Compute instantaneous phase difference (FM discriminator core)
|
|
59
|
+
#
|
|
60
|
+
# Calculates the phase difference between consecutive samples using
|
|
61
|
+
# the polar discriminator method. This is the core of FM demodulation.
|
|
62
|
+
#
|
|
63
|
+
# @param [Array<Complex>] samples Input complex samples
|
|
64
|
+
# @return [Array<Float>] Phase differences in radians (-π to π)
|
|
65
|
+
# @example Get FM baseband signal
|
|
66
|
+
# phase_diff = RTLSDR::Demod.phase_diff(samples)
|
|
67
|
+
def self.phase_diff(samples)
|
|
68
|
+
return [] if samples.length < 2
|
|
69
|
+
|
|
70
|
+
result = Array.new(samples.length - 1)
|
|
71
|
+
(1...samples.length).each do |i|
|
|
72
|
+
prev = samples[i - 1]
|
|
73
|
+
curr = samples[i]
|
|
74
|
+
# Polar discriminator: arg(curr * conj(prev))
|
|
75
|
+
# = atan2(curr.imag*prev.real - curr.real*prev.imag,
|
|
76
|
+
# curr.real*prev.real + curr.imag*prev.imag)
|
|
77
|
+
result[i - 1] = Math.atan2(
|
|
78
|
+
(curr.imag * prev.real) - (curr.real * prev.imag),
|
|
79
|
+
(curr.real * prev.real) + (curr.imag * prev.imag)
|
|
80
|
+
)
|
|
81
|
+
end
|
|
82
|
+
result
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Apply de-emphasis filter for FM audio
|
|
86
|
+
#
|
|
87
|
+
# FM broadcast uses pre-emphasis to boost high frequencies before
|
|
88
|
+
# transmission. This filter reverses that effect. Standard time
|
|
89
|
+
# constants are 75µs (US/Korea) or 50µs (Europe/Australia).
|
|
90
|
+
#
|
|
91
|
+
# @param [Array<Float>] samples Input audio samples
|
|
92
|
+
# @param [Float] tau Time constant in seconds (75e-6 for US, 50e-6 for EU)
|
|
93
|
+
# @param [Numeric] sample_rate Sample rate in Hz
|
|
94
|
+
# @return [Array<Float>] De-emphasized audio samples
|
|
95
|
+
def self.deemphasis(samples, tau, sample_rate)
|
|
96
|
+
return samples if samples.empty? || tau <= 0
|
|
97
|
+
|
|
98
|
+
# First-order IIR lowpass: y[n] = (1-alpha)*x[n] + alpha*y[n-1]
|
|
99
|
+
alpha = Math.exp(-1.0 / (tau * sample_rate))
|
|
100
|
+
one_minus_alpha = 1.0 - alpha
|
|
101
|
+
|
|
102
|
+
result = Array.new(samples.length)
|
|
103
|
+
result[0] = samples[0] * one_minus_alpha
|
|
104
|
+
|
|
105
|
+
(1...samples.length).each do |i|
|
|
106
|
+
result[i] = (samples[i] * one_minus_alpha) + (result[i - 1] * alpha)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
result
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# =========================================================================
|
|
113
|
+
# FM Demodulation
|
|
114
|
+
# =========================================================================
|
|
115
|
+
|
|
116
|
+
# Wideband FM demodulation (broadcast radio)
|
|
117
|
+
#
|
|
118
|
+
# Demodulates wideband FM signals such as broadcast FM radio (88-108 MHz).
|
|
119
|
+
# Applies a polar discriminator followed by de-emphasis filtering and
|
|
120
|
+
# decimation to the audio sample rate.
|
|
121
|
+
#
|
|
122
|
+
# @param [Array<Complex>] samples Input IQ samples
|
|
123
|
+
# @param [Integer] sample_rate Input sample rate in Hz
|
|
124
|
+
# @param [Integer] audio_rate Output audio sample rate (default: 48000)
|
|
125
|
+
# @param [Integer] deviation FM deviation in Hz (default: 75000 for WBFM)
|
|
126
|
+
# @param [Float, nil] tau De-emphasis time constant (75e-6 US, 50e-6 EU, nil to disable)
|
|
127
|
+
# @return [Array<Float>] Demodulated audio samples (normalized to -1.0 to 1.0)
|
|
128
|
+
# @example Demodulate FM broadcast
|
|
129
|
+
# audio = RTLSDR::Demod.fm(samples, sample_rate: 2_048_000)
|
|
130
|
+
# @example European de-emphasis
|
|
131
|
+
# audio = RTLSDR::Demod.fm(samples, sample_rate: 2_048_000, tau: 50e-6)
|
|
132
|
+
def self.fm(samples, sample_rate:, audio_rate: 48_000, deviation: 75_000, tau: 7.5e-5)
|
|
133
|
+
return [] if samples.empty?
|
|
134
|
+
|
|
135
|
+
# Step 1: FM discriminator (phase difference)
|
|
136
|
+
demodulated = phase_diff(samples)
|
|
137
|
+
|
|
138
|
+
# Step 2: Scale by deviation to get normalized audio
|
|
139
|
+
# The discriminator output is in radians per sample
|
|
140
|
+
# Scale factor: sample_rate / (2 * pi * deviation)
|
|
141
|
+
scale = sample_rate.to_f / (2.0 * Math::PI * deviation)
|
|
142
|
+
demodulated = demodulated.map { |s| s * scale }
|
|
143
|
+
|
|
144
|
+
# Step 3: Apply de-emphasis filter (if tau specified)
|
|
145
|
+
demodulated = deemphasis(demodulated, tau, sample_rate) if tau&.positive?
|
|
146
|
+
|
|
147
|
+
# Step 4: Decimate to audio rate
|
|
148
|
+
if sample_rate != audio_rate
|
|
149
|
+
# Convert to complex for DSP.resample, then back to real
|
|
150
|
+
complex_samples = demodulated.map { |s| Complex(s, 0) }
|
|
151
|
+
resampled = DSP.resample(complex_samples, from_rate: sample_rate, to_rate: audio_rate)
|
|
152
|
+
demodulated = resampled.map(&:real)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Normalize output
|
|
156
|
+
normalize_audio(demodulated)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Narrowband FM demodulation (voice radio)
|
|
160
|
+
#
|
|
161
|
+
# Demodulates narrowband FM signals such as amateur radio, FRS/GMRS,
|
|
162
|
+
# and public safety communications. Uses smaller deviation than WBFM.
|
|
163
|
+
#
|
|
164
|
+
# @param [Array<Complex>] samples Input IQ samples
|
|
165
|
+
# @param [Integer] sample_rate Input sample rate in Hz
|
|
166
|
+
# @param [Integer] audio_rate Output audio sample rate (default: 48000)
|
|
167
|
+
# @param [Integer] deviation FM deviation in Hz (default: 5000 for NBFM)
|
|
168
|
+
# @return [Array<Float>] Demodulated audio samples
|
|
169
|
+
# @example Demodulate NBFM voice
|
|
170
|
+
# audio = RTLSDR::Demod.nfm(samples, sample_rate: 2_048_000)
|
|
171
|
+
def self.nfm(samples, sample_rate:, audio_rate: 48_000, deviation: 5_000)
|
|
172
|
+
# NBFM doesn't use de-emphasis
|
|
173
|
+
fm(samples, sample_rate: sample_rate, audio_rate: audio_rate, deviation: deviation, tau: nil)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# =========================================================================
|
|
177
|
+
# AM Demodulation
|
|
178
|
+
# =========================================================================
|
|
179
|
+
|
|
180
|
+
# AM demodulation using envelope detection
|
|
181
|
+
#
|
|
182
|
+
# Demodulates AM signals by extracting the magnitude (envelope) of the
|
|
183
|
+
# complex signal. This is the simplest AM demodulation method.
|
|
184
|
+
#
|
|
185
|
+
# @param [Array<Complex>] samples Input IQ samples
|
|
186
|
+
# @param [Integer] sample_rate Input sample rate in Hz
|
|
187
|
+
# @param [Integer] audio_rate Output audio sample rate (default: 48000)
|
|
188
|
+
# @param [Integer] audio_bandwidth Audio lowpass filter cutoff (default: 5000)
|
|
189
|
+
# @return [Array<Float>] Demodulated audio samples
|
|
190
|
+
# @example Demodulate AM broadcast
|
|
191
|
+
# audio = RTLSDR::Demod.am(samples, sample_rate: 2_048_000)
|
|
192
|
+
def self.am(samples, sample_rate:, audio_rate: 48_000, audio_bandwidth: 5_000)
|
|
193
|
+
return [] if samples.empty?
|
|
194
|
+
|
|
195
|
+
# Step 1: Envelope detection (magnitude)
|
|
196
|
+
envelope = DSP.magnitude(samples)
|
|
197
|
+
|
|
198
|
+
# Step 2: Remove DC (carrier component)
|
|
199
|
+
# Use a simple high-pass by subtracting mean
|
|
200
|
+
mean = envelope.sum / envelope.length.to_f
|
|
201
|
+
audio = envelope.map { |s| s - mean }
|
|
202
|
+
|
|
203
|
+
# Step 3: Lowpass filter to audio bandwidth
|
|
204
|
+
if audio_bandwidth < sample_rate / 2
|
|
205
|
+
filter = DSP::Filter.lowpass(
|
|
206
|
+
cutoff: audio_bandwidth,
|
|
207
|
+
sample_rate: sample_rate,
|
|
208
|
+
taps: 63
|
|
209
|
+
)
|
|
210
|
+
complex_audio = audio.map { |s| Complex(s, 0) }
|
|
211
|
+
audio = filter.apply(complex_audio).map(&:real)
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# Step 4: Decimate to audio rate
|
|
215
|
+
if sample_rate != audio_rate
|
|
216
|
+
complex_audio = audio.map { |s| Complex(s, 0) }
|
|
217
|
+
resampled = DSP.resample(complex_audio, from_rate: sample_rate, to_rate: audio_rate)
|
|
218
|
+
audio = resampled.map(&:real)
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
normalize_audio(audio)
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# AM demodulation with synchronous detection
|
|
225
|
+
#
|
|
226
|
+
# Demodulates AM using synchronous detection, which provides better
|
|
227
|
+
# performance than envelope detection, especially for weak signals
|
|
228
|
+
# or signals with selective fading.
|
|
229
|
+
#
|
|
230
|
+
# @param [Array<Complex>] samples Input IQ samples
|
|
231
|
+
# @param [Integer] sample_rate Input sample rate in Hz
|
|
232
|
+
# @param [Integer] audio_rate Output audio sample rate (default: 48000)
|
|
233
|
+
# @param [Integer] audio_bandwidth Audio lowpass filter cutoff (default: 5000)
|
|
234
|
+
# @return [Array<Float>] Demodulated audio samples
|
|
235
|
+
def self.am_sync(samples, sample_rate:, audio_rate: 48_000, audio_bandwidth: 5_000)
|
|
236
|
+
return [] if samples.empty?
|
|
237
|
+
|
|
238
|
+
# Synchronous AM detection:
|
|
239
|
+
# 1. Estimate carrier phase using simple PLL-like approach
|
|
240
|
+
# 2. Multiply by recovered carrier to get baseband
|
|
241
|
+
# 3. Take real part
|
|
242
|
+
|
|
243
|
+
# Simple carrier recovery: use average phase
|
|
244
|
+
# For better performance, a proper PLL would be needed
|
|
245
|
+
phases = DSP.phase(samples)
|
|
246
|
+
avg_phase = phases.sum / phases.length.to_f
|
|
247
|
+
|
|
248
|
+
# Mix to baseband using recovered carrier phase
|
|
249
|
+
audio = samples.map do |sample|
|
|
250
|
+
# Multiply by exp(-j*avg_phase) and take real part
|
|
251
|
+
rotated = sample * Complex(Math.cos(-avg_phase), Math.sin(-avg_phase))
|
|
252
|
+
rotated.real
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# Remove DC
|
|
256
|
+
mean = audio.sum / audio.length.to_f
|
|
257
|
+
audio = audio.map { |s| s - mean }
|
|
258
|
+
|
|
259
|
+
# Lowpass filter
|
|
260
|
+
if audio_bandwidth < sample_rate / 2
|
|
261
|
+
filter = DSP::Filter.lowpass(
|
|
262
|
+
cutoff: audio_bandwidth,
|
|
263
|
+
sample_rate: sample_rate,
|
|
264
|
+
taps: 63
|
|
265
|
+
)
|
|
266
|
+
complex_audio = audio.map { |s| Complex(s, 0) }
|
|
267
|
+
audio = filter.apply(complex_audio).map(&:real)
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# Decimate to audio rate
|
|
271
|
+
if sample_rate != audio_rate
|
|
272
|
+
complex_audio = audio.map { |s| Complex(s, 0) }
|
|
273
|
+
resampled = DSP.resample(complex_audio, from_rate: sample_rate, to_rate: audio_rate)
|
|
274
|
+
audio = resampled.map(&:real)
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
normalize_audio(audio)
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
# =========================================================================
|
|
281
|
+
# SSB Demodulation
|
|
282
|
+
# =========================================================================
|
|
283
|
+
|
|
284
|
+
# Upper Sideband (USB) demodulation
|
|
285
|
+
#
|
|
286
|
+
# Demodulates USB signals commonly used in amateur radio above 10 MHz.
|
|
287
|
+
# Uses a Beat Frequency Oscillator (BFO) to convert the sideband to audio.
|
|
288
|
+
#
|
|
289
|
+
# @param [Array<Complex>] samples Input IQ samples
|
|
290
|
+
# @param [Integer] sample_rate Input sample rate in Hz
|
|
291
|
+
# @param [Integer] audio_rate Output audio sample rate (default: 48000)
|
|
292
|
+
# @param [Integer] bfo_offset BFO offset frequency in Hz (default: 1500)
|
|
293
|
+
# @param [Integer] audio_bandwidth Audio lowpass filter cutoff (default: 3000)
|
|
294
|
+
# @return [Array<Float>] Demodulated audio samples
|
|
295
|
+
# @example Demodulate USB signal
|
|
296
|
+
# audio = RTLSDR::Demod.usb(samples, sample_rate: 2_048_000, bfo_offset: 1500)
|
|
297
|
+
def self.usb(samples, sample_rate:, audio_rate: 48_000, bfo_offset: 1500, audio_bandwidth: 3_000)
|
|
298
|
+
return [] if samples.empty?
|
|
299
|
+
|
|
300
|
+
# USB: Mix down by BFO offset, take real part
|
|
301
|
+
# The upper sideband appears above the carrier, so we shift down
|
|
302
|
+
mixed = mix(samples, -bfo_offset, sample_rate)
|
|
303
|
+
|
|
304
|
+
# Lowpass filter to audio bandwidth
|
|
305
|
+
filter = DSP::Filter.lowpass(
|
|
306
|
+
cutoff: audio_bandwidth,
|
|
307
|
+
sample_rate: sample_rate,
|
|
308
|
+
taps: 127
|
|
309
|
+
)
|
|
310
|
+
filtered = filter.apply(mixed)
|
|
311
|
+
|
|
312
|
+
# Take real part for audio
|
|
313
|
+
audio = filtered.map(&:real)
|
|
314
|
+
|
|
315
|
+
# Decimate to audio rate
|
|
316
|
+
if sample_rate != audio_rate
|
|
317
|
+
complex_audio = audio.map { |s| Complex(s, 0) }
|
|
318
|
+
resampled = DSP.resample(complex_audio, from_rate: sample_rate, to_rate: audio_rate)
|
|
319
|
+
audio = resampled.map(&:real)
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
normalize_audio(audio)
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
# Lower Sideband (LSB) demodulation
|
|
326
|
+
#
|
|
327
|
+
# Demodulates LSB signals commonly used in amateur radio below 10 MHz.
|
|
328
|
+
# Uses a Beat Frequency Oscillator (BFO) to convert the sideband to audio.
|
|
329
|
+
#
|
|
330
|
+
# @param [Array<Complex>] samples Input IQ samples
|
|
331
|
+
# @param [Integer] sample_rate Input sample rate in Hz
|
|
332
|
+
# @param [Integer] audio_rate Output audio sample rate (default: 48000)
|
|
333
|
+
# @param [Integer] bfo_offset BFO offset frequency in Hz (default: 1500)
|
|
334
|
+
# @param [Integer] audio_bandwidth Audio lowpass filter cutoff (default: 3000)
|
|
335
|
+
# @return [Array<Float>] Demodulated audio samples
|
|
336
|
+
# @example Demodulate LSB signal
|
|
337
|
+
# audio = RTLSDR::Demod.lsb(samples, sample_rate: 2_048_000, bfo_offset: 1500)
|
|
338
|
+
def self.lsb(samples, sample_rate:, audio_rate: 48_000, bfo_offset: 1500, audio_bandwidth: 3_000)
|
|
339
|
+
return [] if samples.empty?
|
|
340
|
+
|
|
341
|
+
# LSB: Mix up by BFO offset, take real part
|
|
342
|
+
# The lower sideband appears below the carrier, so we shift up
|
|
343
|
+
mixed = mix(samples, bfo_offset, sample_rate)
|
|
344
|
+
|
|
345
|
+
# Lowpass filter to audio bandwidth
|
|
346
|
+
filter = DSP::Filter.lowpass(
|
|
347
|
+
cutoff: audio_bandwidth,
|
|
348
|
+
sample_rate: sample_rate,
|
|
349
|
+
taps: 127
|
|
350
|
+
)
|
|
351
|
+
filtered = filter.apply(mixed)
|
|
352
|
+
|
|
353
|
+
# Take real part for audio
|
|
354
|
+
audio = filtered.map(&:real)
|
|
355
|
+
|
|
356
|
+
# Decimate to audio rate
|
|
357
|
+
if sample_rate != audio_rate
|
|
358
|
+
complex_audio = audio.map { |s| Complex(s, 0) }
|
|
359
|
+
resampled = DSP.resample(complex_audio, from_rate: sample_rate, to_rate: audio_rate)
|
|
360
|
+
audio = resampled.map(&:real)
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
normalize_audio(audio)
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
# =========================================================================
|
|
367
|
+
# Private Helpers
|
|
368
|
+
# =========================================================================
|
|
369
|
+
|
|
370
|
+
# Normalize audio to -1.0 to 1.0 range
|
|
371
|
+
#
|
|
372
|
+
# @param [Array<Float>] samples Audio samples
|
|
373
|
+
# @return [Array<Float>] Normalized samples
|
|
374
|
+
def self.normalize_audio(samples)
|
|
375
|
+
return samples if samples.empty?
|
|
376
|
+
|
|
377
|
+
max_val = samples.map(&:abs).max
|
|
378
|
+
return samples if max_val.zero? || max_val < 1e-10
|
|
379
|
+
|
|
380
|
+
scale = 1.0 / max_val
|
|
381
|
+
samples.map { |s| s * scale * 0.9 } # Leave 10% headroom
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
private_class_method :normalize_audio
|
|
385
|
+
end
|
|
386
|
+
end
|