Module: RTLSDR::Demod

Defined in:
lib/rtlsdr/demod.rb

Overview

Demodulation algorithms for common radio signals

The Demod module provides methods for demodulating FM, AM, and SSB signals from complex IQ samples. All demodulators output real-valued audio samples that can be played back or written to audio files.

Examples:

Demodulate FM radio

samples = device.read_samples(262144)
audio = RTLSDR::Demod.fm(samples, sample_rate: 2_048_000)

Demodulate AM signal

audio = RTLSDR::Demod.am(samples, sample_rate: 2_048_000)

Demodulate SSB (upper sideband)

audio = RTLSDR::Demod.usb(samples, sample_rate: 2_048_000, bfo_offset: 1500)

Class Method Summary collapse

Class Method Details

.am(samples, sample_rate:, audio_rate: 48_000, audio_bandwidth: 5_000) ⇒ Array<Float>

AM demodulation using envelope detection

Demodulates AM signals by extracting the magnitude (envelope) of the complex signal. This is the simplest AM demodulation method.

Examples:

Demodulate AM broadcast

audio = RTLSDR::Demod.am(samples, sample_rate: 2_048_000)

Parameters:

  • samples (Array<Complex>)

    Input IQ samples

  • sample_rate (Integer)

    Input sample rate in Hz

  • audio_rate (Integer) (defaults to: 48_000)

    Output audio sample rate (default: 48000)

  • audio_bandwidth (Integer) (defaults to: 5_000)

    Audio lowpass filter cutoff (default: 5000)

Returns:

  • (Array<Float>)

    Demodulated audio samples



192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
# File 'lib/rtlsdr/demod.rb', line 192

def self.am(samples, sample_rate:, audio_rate: 48_000, audio_bandwidth: 5_000)
  return [] if samples.empty?

  # Step 1: Envelope detection (magnitude)
  envelope = DSP.magnitude(samples)

  # Step 2: Remove DC (carrier component)
  # Use a simple high-pass by subtracting mean
  mean = envelope.sum / envelope.length.to_f
  audio = envelope.map { |s| s - mean }

  # Step 3: Lowpass filter to audio bandwidth
  if audio_bandwidth < sample_rate / 2
    filter = DSP::Filter.lowpass(
      cutoff: audio_bandwidth,
      sample_rate: sample_rate,
      taps: 63
    )
    complex_audio = audio.map { |s| Complex(s, 0) }
    audio = filter.apply(complex_audio).map(&:real)
  end

  # Step 4: Decimate to audio rate
  if sample_rate != audio_rate
    complex_audio = audio.map { |s| Complex(s, 0) }
    resampled = DSP.resample(complex_audio, from_rate: sample_rate, to_rate: audio_rate)
    audio = resampled.map(&:real)
  end

  normalize_audio(audio)
end

.am_sync(samples, sample_rate:, audio_rate: 48_000, audio_bandwidth: 5_000) ⇒ Array<Float>

AM demodulation with synchronous detection

Demodulates AM using synchronous detection, which provides better performance than envelope detection, especially for weak signals or signals with selective fading.

Parameters:

  • samples (Array<Complex>)

    Input IQ samples

  • sample_rate (Integer)

    Input sample rate in Hz

  • audio_rate (Integer) (defaults to: 48_000)

    Output audio sample rate (default: 48000)

  • audio_bandwidth (Integer) (defaults to: 5_000)

    Audio lowpass filter cutoff (default: 5000)

Returns:

  • (Array<Float>)

    Demodulated audio samples



235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
# File 'lib/rtlsdr/demod.rb', line 235

def self.am_sync(samples, sample_rate:, audio_rate: 48_000, audio_bandwidth: 5_000)
  return [] if samples.empty?

  # Synchronous AM detection:
  # 1. Estimate carrier phase using simple PLL-like approach
  # 2. Multiply by recovered carrier to get baseband
  # 3. Take real part

  # Simple carrier recovery: use average phase
  # For better performance, a proper PLL would be needed
  phases = DSP.phase(samples)
  avg_phase = phases.sum / phases.length.to_f

  # Mix to baseband using recovered carrier phase
  audio = samples.map do |sample|
    # Multiply by exp(-j*avg_phase) and take real part
    rotated = sample * Complex(Math.cos(-avg_phase), Math.sin(-avg_phase))
    rotated.real
  end

  # Remove DC
  mean = audio.sum / audio.length.to_f
  audio = audio.map { |s| s - mean }

  # Lowpass filter
  if audio_bandwidth < sample_rate / 2
    filter = DSP::Filter.lowpass(
      cutoff: audio_bandwidth,
      sample_rate: sample_rate,
      taps: 63
    )
    complex_audio = audio.map { |s| Complex(s, 0) }
    audio = filter.apply(complex_audio).map(&:real)
  end

  # Decimate to audio rate
  if sample_rate != audio_rate
    complex_audio = audio.map { |s| Complex(s, 0) }
    resampled = DSP.resample(complex_audio, from_rate: sample_rate, to_rate: audio_rate)
    audio = resampled.map(&:real)
  end

  normalize_audio(audio)
end

.complex_oscillator(length, frequency, sample_rate) ⇒ Array<Complex>

Generate a complex oscillator (carrier signal)

Creates an array of complex exponentials: exp(j * 2 * pi * freq * t) Used for frequency shifting (mixing) signals.

Examples:

Generate 1 kHz oscillator at 48 kHz sample rate

osc = RTLSDR::Demod.complex_oscillator(1024, 1000, 48_000)

Parameters:

  • length (Integer)

    Number of samples to generate

  • frequency (Numeric)

    Oscillator frequency in Hz

  • sample_rate (Numeric)

    Sample rate in Hz

Returns:

  • (Array<Complex>)

    Complex oscillator samples



35
36
37
38
# File 'lib/rtlsdr/demod.rb', line 35

def self.complex_oscillator(length, frequency, sample_rate)
  omega = 2.0 * Math::PI * frequency / sample_rate
  Array.new(length) { |i| Complex(Math.cos(omega * i), Math.sin(omega * i)) }
end

.deemphasis(samples, tau, sample_rate) ⇒ Array<Float>

Apply de-emphasis filter for FM audio

FM broadcast uses pre-emphasis to boost high frequencies before transmission. This filter reverses that effect. Standard time constants are 75µs (US/Korea) or 50µs (Europe/Australia).

Parameters:

  • samples (Array<Float>)

    Input audio samples

  • tau (Float)

    Time constant in seconds (75e-6 for US, 50e-6 for EU)

  • sample_rate (Numeric)

    Sample rate in Hz

Returns:

  • (Array<Float>)

    De-emphasized audio samples



95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
# File 'lib/rtlsdr/demod.rb', line 95

def self.deemphasis(samples, tau, sample_rate)
  return samples if samples.empty? || tau <= 0

  # First-order IIR lowpass: y[n] = (1-alpha)*x[n] + alpha*y[n-1]
  alpha = Math.exp(-1.0 / (tau * sample_rate))
  one_minus_alpha = 1.0 - alpha

  result = Array.new(samples.length)
  result[0] = samples[0] * one_minus_alpha

  (1...samples.length).each do |i|
    result[i] = (samples[i] * one_minus_alpha) + (result[i - 1] * alpha)
  end

  result
end

.fm(samples, sample_rate:, audio_rate: 48_000, deviation: 75_000, tau: 7.5e-5) ⇒ Array<Float>

Wideband FM demodulation (broadcast radio)

Demodulates wideband FM signals such as broadcast FM radio (88-108 MHz). Applies a polar discriminator followed by de-emphasis filtering and decimation to the audio sample rate.

Examples:

Demodulate FM broadcast

audio = RTLSDR::Demod.fm(samples, sample_rate: 2_048_000)

European de-emphasis

audio = RTLSDR::Demod.fm(samples, sample_rate: 2_048_000, tau: 50e-6)

Parameters:

  • samples (Array<Complex>)

    Input IQ samples

  • sample_rate (Integer)

    Input sample rate in Hz

  • audio_rate (Integer) (defaults to: 48_000)

    Output audio sample rate (default: 48000)

  • deviation (Integer) (defaults to: 75_000)

    FM deviation in Hz (default: 75000 for WBFM)

  • tau (Float, nil) (defaults to: 7.5e-5)

    De-emphasis time constant (75e-6 US, 50e-6 EU, nil to disable)

Returns:

  • (Array<Float>)

    Demodulated audio samples (normalized to -1.0 to 1.0)



132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
# File 'lib/rtlsdr/demod.rb', line 132

def self.fm(samples, sample_rate:, audio_rate: 48_000, deviation: 75_000, tau: 7.5e-5)
  return [] if samples.empty?

  # Step 1: FM discriminator (phase difference)
  demodulated = phase_diff(samples)

  # Step 2: Scale by deviation to get normalized audio
  # The discriminator output is in radians per sample
  # Scale factor: sample_rate / (2 * pi * deviation)
  scale = sample_rate.to_f / (2.0 * Math::PI * deviation)
  demodulated = demodulated.map { |s| s * scale }

  # Step 3: Apply de-emphasis filter (if tau specified)
  demodulated = deemphasis(demodulated, tau, sample_rate) if tau&.positive?

  # Step 4: Decimate to audio rate
  if sample_rate != audio_rate
    # Convert to complex for DSP.resample, then back to real
    complex_samples = demodulated.map { |s| Complex(s, 0) }
    resampled = DSP.resample(complex_samples, from_rate: sample_rate, to_rate: audio_rate)
    demodulated = resampled.map(&:real)
  end

  # Normalize output
  normalize_audio(demodulated)
end

.fsk(samples, sample_rate:, baud_rate:, invert: false) ⇒ Array<Integer>

FSK (Frequency Shift Keying) demodulation

Demodulates FSK signals by using an FM discriminator to extract instantaneous frequency, then thresholding to recover bits. FSK encodes data by switching between two frequencies (mark and space).

Examples:

Demodulate 1200 baud FSK

bits = RTLSDR::Demod.fsk(samples, sample_rate: 48_000, baud_rate: 1200)

Demodulate RTTY at 45.45 baud

bits = RTLSDR::Demod.fsk(samples, sample_rate: 48_000, baud_rate: 45.45)

Parameters:

  • samples (Array<Complex>)

    Input IQ samples

  • sample_rate (Integer)

    Input sample rate in Hz

  • baud_rate (Numeric)

    Symbol rate in baud (symbols per second)

  • invert (Boolean) (defaults to: false)

    Swap mark/space interpretation (default: false)

Returns:

  • (Array<Integer>)

    Recovered bits (0 or 1)



385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
# File 'lib/rtlsdr/demod.rb', line 385

def self.fsk(samples, sample_rate:, baud_rate:, invert: false)
  return [] if samples.empty? || samples.length < 2

  # Step 1: FM discriminator to get instantaneous frequency
  freq = phase_diff(samples)
  return [] if freq.empty?

  # Step 2: Lowpass filter to smooth transitions (cutoff at 1.5x baud rate)
  filter_cutoff = [baud_rate * 1.5, (sample_rate / 2.0) - 1].min
  filter = DSP::Filter.lowpass(
    cutoff: filter_cutoff,
    sample_rate: sample_rate,
    taps: 63
  )
  complex_freq = freq.map { |f| Complex(f, 0) }
  smoothed = filter.apply(complex_freq).map(&:real)

  # Step 3: Decimate to ~4x baud rate for bit decisions
  target_rate = (baud_rate * 4).to_i
  target_rate = [target_rate, sample_rate].min

  if sample_rate > target_rate && target_rate.positive?
    decimated = DSP.resample(
      smoothed.map { |s| Complex(s, 0) },
      from_rate: sample_rate,
      to_rate: target_rate
    ).map(&:real)
    effective_rate = target_rate
  else
    decimated = smoothed
    effective_rate = sample_rate
  end

  return [] if decimated.empty?

  # Step 4: Threshold at midpoint to get raw bits
  threshold = decimated.sum / decimated.length.to_f
  raw_bits = decimated.map { |s| s > threshold ? 1 : 0 }
  raw_bits = raw_bits.map { |b| 1 - b } if invert

  # Step 5: Sample at symbol centers
  samples_per_symbol = effective_rate.to_f / baud_rate
  return raw_bits if samples_per_symbol < 1

  output_bits = []
  offset = (samples_per_symbol / 2.0).to_i
  index = offset

  while index < raw_bits.length
    output_bits << raw_bits[index]
    index += samples_per_symbol.round
  end

  output_bits
end

.fsk_raw(samples, sample_rate:, baud_rate:) ⇒ Array<Float>

FSK demodulation returning raw discriminator output

Returns the smoothed frequency discriminator output without bit slicing. Useful for visualizing FSK signals, debugging, or implementing custom clock recovery algorithms.

Examples:

Get raw FSK waveform for plotting

waveform = RTLSDR::Demod.fsk_raw(samples, sample_rate: 48_000, baud_rate: 1200)

Parameters:

  • samples (Array<Complex>)

    Input IQ samples

  • sample_rate (Integer)

    Input sample rate in Hz

  • baud_rate (Numeric)

    Symbol rate in baud (used for filter cutoff)

Returns:

  • (Array<Float>)

    Smoothed discriminator output



453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
# File 'lib/rtlsdr/demod.rb', line 453

def self.fsk_raw(samples, sample_rate:, baud_rate:)
  return [] if samples.empty? || samples.length < 2

  # FM discriminator
  freq = phase_diff(samples)
  return [] if freq.empty?

  # Lowpass filter
  filter_cutoff = [baud_rate * 1.5, (sample_rate / 2.0) - 1].min
  filter = DSP::Filter.lowpass(
    cutoff: filter_cutoff,
    sample_rate: sample_rate,
    taps: 63
  )
  complex_freq = freq.map { |f| Complex(f, 0) }
  filter.apply(complex_freq).map(&:real)
end

.lsb(samples, sample_rate:, audio_rate: 48_000, bfo_offset: 1500, audio_bandwidth: 3_000) ⇒ Array<Float>

Lower Sideband (LSB) demodulation

Demodulates LSB signals commonly used in amateur radio below 10 MHz. Uses a Beat Frequency Oscillator (BFO) to convert the sideband to audio.

Examples:

Demodulate LSB signal

audio = RTLSDR::Demod.lsb(samples, sample_rate: 2_048_000, bfo_offset: 1500)

Parameters:

  • samples (Array<Complex>)

    Input IQ samples

  • sample_rate (Integer)

    Input sample rate in Hz

  • audio_rate (Integer) (defaults to: 48_000)

    Output audio sample rate (default: 48000)

  • bfo_offset (Integer) (defaults to: 1500)

    BFO offset frequency in Hz (default: 1500)

  • audio_bandwidth (Integer) (defaults to: 3_000)

    Audio lowpass filter cutoff (default: 3000)

Returns:

  • (Array<Float>)

    Demodulated audio samples



338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
# File 'lib/rtlsdr/demod.rb', line 338

def self.lsb(samples, sample_rate:, audio_rate: 48_000, bfo_offset: 1500, audio_bandwidth: 3_000)
  return [] if samples.empty?

  # LSB: Mix up by BFO offset, take real part
  # The lower sideband appears below the carrier, so we shift up
  mixed = mix(samples, bfo_offset, sample_rate)

  # Lowpass filter to audio bandwidth
  filter = DSP::Filter.lowpass(
    cutoff: audio_bandwidth,
    sample_rate: sample_rate,
    taps: 127
  )
  filtered = filter.apply(mixed)

  # Take real part for audio
  audio = filtered.map(&:real)

  # Decimate to audio rate
  if sample_rate != audio_rate
    complex_audio = audio.map { |s| Complex(s, 0) }
    resampled = DSP.resample(complex_audio, from_rate: sample_rate, to_rate: audio_rate)
    audio = resampled.map(&:real)
  end

  normalize_audio(audio)
end

.mix(samples, frequency, sample_rate) ⇒ Array<Complex>

Mix (frequency shift) a signal

Multiplies the input signal by a complex oscillator to shift its frequency. Positive frequency shifts up, negative shifts down.

Examples:

Shift signal down by 10 kHz

shifted = RTLSDR::Demod.mix(samples, -10_000, 2_048_000)

Parameters:

  • samples (Array<Complex>)

    Input complex samples

  • frequency (Numeric)

    Shift frequency in Hz (negative = shift down)

  • sample_rate (Numeric)

    Sample rate in Hz

Returns:

  • (Array<Complex>)

    Frequency-shifted samples



51
52
53
54
55
56
# File 'lib/rtlsdr/demod.rb', line 51

def self.mix(samples, frequency, sample_rate)
  omega = 2.0 * Math::PI * frequency / sample_rate
  samples.each_with_index.map do |sample, i|
    sample * Complex(Math.cos(omega * i), Math.sin(omega * i))
  end
end

.nfm(samples, sample_rate:, audio_rate: 48_000, deviation: 5_000) ⇒ Array<Float>

Narrowband FM demodulation (voice radio)

Demodulates narrowband FM signals such as amateur radio, FRS/GMRS, and public safety communications. Uses smaller deviation than WBFM.

Examples:

Demodulate NBFM voice

audio = RTLSDR::Demod.nfm(samples, sample_rate: 2_048_000)

Parameters:

  • samples (Array<Complex>)

    Input IQ samples

  • sample_rate (Integer)

    Input sample rate in Hz

  • audio_rate (Integer) (defaults to: 48_000)

    Output audio sample rate (default: 48000)

  • deviation (Integer) (defaults to: 5_000)

    FM deviation in Hz (default: 5000 for NBFM)

Returns:

  • (Array<Float>)

    Demodulated audio samples



171
172
173
174
# File 'lib/rtlsdr/demod.rb', line 171

def self.nfm(samples, sample_rate:, audio_rate: 48_000, deviation: 5_000)
  # NBFM doesn't use de-emphasis
  fm(samples, sample_rate: sample_rate, audio_rate: audio_rate, deviation: deviation, tau: nil)
end

.phase_diff(samples) ⇒ Array<Float>

Compute instantaneous phase difference (FM discriminator core)

Calculates the phase difference between consecutive samples using the polar discriminator method. This is the core of FM demodulation.

Examples:

Get FM baseband signal

phase_diff = RTLSDR::Demod.phase_diff(samples)

Parameters:

  • samples (Array<Complex>)

    Input complex samples

Returns:

  • (Array<Float>)

    Phase differences in radians (-π to π)



67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
# File 'lib/rtlsdr/demod.rb', line 67

def self.phase_diff(samples)
  return [] if samples.length < 2

  result = Array.new(samples.length - 1)
  (1...samples.length).each do |i|
    prev = samples[i - 1]
    curr = samples[i]
    # Polar discriminator: arg(curr * conj(prev))
    # = atan2(curr.imag*prev.real - curr.real*prev.imag,
    #         curr.real*prev.real + curr.imag*prev.imag)
    result[i - 1] = Math.atan2(
      (curr.imag * prev.real) - (curr.real * prev.imag),
      (curr.real * prev.real) + (curr.imag * prev.imag)
    )
  end
  result
end

.usb(samples, sample_rate:, audio_rate: 48_000, bfo_offset: 1500, audio_bandwidth: 3_000) ⇒ Array<Float>

Upper Sideband (USB) demodulation

Demodulates USB signals commonly used in amateur radio above 10 MHz. Uses a Beat Frequency Oscillator (BFO) to convert the sideband to audio.

Examples:

Demodulate USB signal

audio = RTLSDR::Demod.usb(samples, sample_rate: 2_048_000, bfo_offset: 1500)

Parameters:

  • samples (Array<Complex>)

    Input IQ samples

  • sample_rate (Integer)

    Input sample rate in Hz

  • audio_rate (Integer) (defaults to: 48_000)

    Output audio sample rate (default: 48000)

  • bfo_offset (Integer) (defaults to: 1500)

    BFO offset frequency in Hz (default: 1500)

  • audio_bandwidth (Integer) (defaults to: 3_000)

    Audio lowpass filter cutoff (default: 3000)

Returns:

  • (Array<Float>)

    Demodulated audio samples



297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
# File 'lib/rtlsdr/demod.rb', line 297

def self.usb(samples, sample_rate:, audio_rate: 48_000, bfo_offset: 1500, audio_bandwidth: 3_000)
  return [] if samples.empty?

  # USB: Mix down by BFO offset, take real part
  # The upper sideband appears above the carrier, so we shift down
  mixed = mix(samples, -bfo_offset, sample_rate)

  # Lowpass filter to audio bandwidth
  filter = DSP::Filter.lowpass(
    cutoff: audio_bandwidth,
    sample_rate: sample_rate,
    taps: 127
  )
  filtered = filter.apply(mixed)

  # Take real part for audio
  audio = filtered.map(&:real)

  # Decimate to audio rate
  if sample_rate != audio_rate
    complex_audio = audio.map { |s| Complex(s, 0) }
    resampled = DSP.resample(complex_audio, from_rate: sample_rate, to_rate: audio_rate)
    audio = resampled.map(&:real)
  end

  normalize_audio(audio)
end