rtlsdr 0.1.12 → 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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b6555286674227e3eecacbaad200294e4392ac041e30db601cf824b634c998e7
4
- data.tar.gz: 174fe5589fd1a1804a622287a19a0b40181987fc8cb9cf6c0817df2119196be6
3
+ metadata.gz: 11d3df08cd7b98efaa65bb9f88b05b0f39c021b335f1d2608b19c7a2802587dd
4
+ data.tar.gz: b130ec333e4589206502d251c2bd06dc5986f4980562618c5bd41bdfd4bd1463
5
5
  SHA512:
6
- metadata.gz: 71f8218b6bb7453004071b41cc618f388541e99e98a549c3c6c82382ee89b6a82dc0a8ddbcb5e40639f610dc8990d8d3d0c0399070ddb90f62979d3945f6263e
7
- data.tar.gz: 9430d11b48acc14d91e8e43cfe5d1ba4b60807d91ef11dab3fff5c025d1f27aa4cde4cb00d5e8560d1edd4bac5a92c548915026c26da1b2c835d64ceeec535b0
6
+ metadata.gz: 7a6c4c85e4adf587e671ebc8bfc09e95a377726f06b1463acd1c174c306c3ab801b55b873ed2ae07eb57971f27334feca58c242dc1b70a7dba23c84df4b705a3
7
+ data.tar.gz: c8013753f9247e5d994a44ea403ca09b98cea991fb5eb7e0b4d99c22fa8fce8ec39b8c4e098f9eef177a04db16bce920cf03ba63a7a9a2b7ad8c26a3957e5fd0
data/CHANGELOG.md CHANGED
@@ -2,6 +2,15 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.1.13] - 2025-09-26
6
+
7
+ ### Added
8
+
9
+ ### Changed
10
+
11
+ ### Fixed
12
+
13
+
5
14
  ## [0.1.12] - 2025-09-26
6
15
 
7
16
  ### Added
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 2.7 or later
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
 
@@ -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