Module: PWN::SDR::Decoder::DSP

Defined in:
lib/pwn/sdr/decoder/dsp.rb

Overview

Pure-Ruby DSP primitives shared by every PWN::SDR::Decoder::* module.

Nothing in here shells out. Everything operates on plain Ruby Arrays of Float samples (normalised -1.0..1.0), so decoders can consume the 48 kHz s16le mono audio that PWN::SDR::GQRX streams over UDP without any external sox / multimon-ng / minimodem / etc dependency.

These are intentionally simple, readable, allocation-heavy reference implementations — good enough for ≤48 kHz audio-rate work on a modern CPU. For MHz-rate raw I/Q you would want SIMD/native code, which is out of scope for a pure-Ruby decoder namespace.

Constant Summary collapse

TWO_PI =
Math::PI * 2
BAUDOT_LTRS =

ITA2 / Baudot 5-bit → ASCII (LTRS + FIGS shift tables). Index = code.

[
  "\0", 'E', "\n", 'A', ' ', 'S', 'I', 'U',
  "\r", 'D', 'R', 'J', 'N', 'F', 'C', 'K',
  'T',  'Z', 'L',  'W', 'H', 'Y', 'P', 'Q',
  'O',  'B', 'G',  nil, 'M', 'X', 'V', nil
].freeze
BAUDOT_FIGS =
[
  "\0", '3', "\n", '-', ' ', "'", '8', '7',
  "\r", '$', '4',  "\a", ',', '!', ':', '(',
  '5',  '+', ')',  '2', '#', '6', '0', '1',
  '9',  '?', '&',  nil, '.', '/', ';', nil
].freeze
MORSE_TABLE =

International Morse Code (dit='.', dah='-') → ASCII.

{
  '.-' => 'A', '-...' => 'B', '-.-.' => 'C', '-..' => 'D', '.' => 'E',
  '..-.' => 'F', '--.' => 'G', '....' => 'H', '..' => 'I', '.---' => 'J',
  '-.-' => 'K', '.-..' => 'L', '--' => 'M', '-.' => 'N', '---' => 'O',
  '.--.' => 'P', '--.-' => 'Q', '.-.' => 'R', '...' => 'S', '-' => 'T',
  '..-' => 'U', '...-' => 'V', '.--' => 'W', '-..-' => 'X', '-.--' => 'Y',
  '--..' => 'Z', '-----' => '0', '.----' => '1', '..---' => '2',
  '...--' => '3', '....-' => '4', '.....' => '5', '-....' => '6',
  '--...' => '7', '---..' => '8', '----.' => '9', '.-.-.-' => '.',
  '--..--' => ',', '..--..' => '?', '-..-.' => '/', '-....-' => '-',
  '-.--.' => '(', '-.--.-' => ')', '.-...' => '&', '---...' => ':',
  '-...-' => '=', '.-.-.' => '+', '.--.-.' => '@'
}.freeze

Class Method Summary collapse

Class Method Details

.authorsObject

Author(s)

0day Inc. support@0dayinc.com



365
366
367
# File 'lib/pwn/sdr/decoder/dsp.rb', line 365

public_class_method def self.authors
  "AUTHOR(S):\n  0day Inc. <support@0dayinc.com>\n"
end

.baudot_decode(opts = {}) ⇒ Object

Supported Method Parameters

txt = PWN::SDR::Decoder::DSP.baudot_decode(bits: Array<0|1>) 5-bit ITA2 with LTRS(31)/FIGS(27) shift, LSB-first per character.



330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
# File 'lib/pwn/sdr/decoder/dsp.rb', line 330

public_class_method def self.baudot_decode(opts = {})
  bits = opts[:bits]
  figs = false
  out  = +''
  bits.each_slice(5) do |ch|
    next if ch.length < 5

    code = ch.each_with_index.sum { |b, i| b << i }
    case code
    when 31 then figs = false
    when 27 then figs = true
    else
      tbl = figs ? BAUDOT_FIGS : BAUDOT_LTRS
      c = tbl[code]
      out << c if c
    end
  end
  out
end

.bch_31_21_syndrome(opts = {}) ⇒ Object

BCH(31,21) syndrome — generator poly 0b11101101001 (0x769). Used by POCSAG and FLEX codewords (bits 31..1 are BCH, bit 0 parity).

Supported Method Parameters

syn = PWN::SDR::Decoder::DSP.bch_31_21_syndrome(word: Integer)



316
317
318
319
320
321
322
323
324
# File 'lib/pwn/sdr/decoder/dsp.rb', line 316

public_class_method def self.bch_31_21_syndrome(opts = {})
  word = (opts[:word].to_i >> 1) & 0x7FFFFFFF
  gen  = 0x769
  reg  = word
  30.downto(10) do |i|
    reg ^= (gen << (i - 10)) if (reg >> i).odd?
  end
  reg & 0x3FF
end

.bits_to_int(opts = {}) ⇒ Object

Supported Method Parameters

int = PWN::SDR::Decoder::DSP.bits_to_int(bits: [1,0,1,...])



293
294
295
296
297
298
# File 'lib/pwn/sdr/decoder/dsp.rb', line 293

public_class_method def self.bits_to_int(opts = {})
  bits = opts[:bits]
  v = 0
  bits.each { |b| v = (v << 1) | (b & 1) }
  v
end

.dc_block(opts = {}) ⇒ Object

Supported Method Parameters

y = PWN::SDR::Decoder::DSP.dc_block( samples: 'required - Array', alpha: 'optional - pole (default 0.995)' )



146
147
148
149
150
151
152
153
154
155
156
157
# File 'lib/pwn/sdr/decoder/dsp.rb', line 146

public_class_method def self.dc_block(opts = {})
  samples = opts[:samples]
  alpha   = (opts[:alpha] || 0.995).to_f
  y_prev = 0.0
  x_prev = 0.0
  samples.map do |x|
    y = x - x_prev + (alpha * y_prev)
    x_prev = x
    y_prev = y
    y
  end
end

.envelope(opts = {}) ⇒ Object

Supported Method Parameters

env = PWN::SDR::Decoder::DSP.envelope( samples: 'required - Array', window: 'optional - moving-average window in samples (default 32)' )



123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
# File 'lib/pwn/sdr/decoder/dsp.rb', line 123

public_class_method def self.envelope(opts = {})
  samples = opts[:samples]
  window  = (opts[:window] || 32).to_i
  window  = 1 if window < 1
  acc = 0.0
  buf = Array.new(window, 0.0)
  out = Array.new(samples.length)
  samples.each_with_index do |x, i|
    v = x.abs
    slot = i % window
    acc += v - buf[slot]
    buf[slot] = v
    out[i] = acc / window
  end
  out
end

.envelope_signed(opts = {}) ⇒ Object

Signed moving average (like envelope but keeps sign).

Supported Method Parameters

y = PWN::SDR::Decoder::DSP.envelope_signed(samples:, window:)



202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
# File 'lib/pwn/sdr/decoder/dsp.rb', line 202

public_class_method def self.envelope_signed(opts = {})
  samples = opts[:samples]
  window  = (opts[:window] || 8).to_i
  window  = 1 if window < 1
  acc = 0.0
  buf = Array.new(window, 0.0)
  out = Array.new(samples.length)
  samples.each_with_index do |x, i|
    slot = i % window
    acc += x - buf[slot]
    buf[slot] = x
    out[i] = acc / window
  end
  out
end

.even_parity_ok?(opts = {}) ⇒ Boolean

Supported Method Parameters

ok = PWN::SDR::Decoder::DSP.even_parity_ok?(word: Integer, width: 32)

Returns:

  • (Boolean)


303
304
305
306
307
308
309
# File 'lib/pwn/sdr/decoder/dsp.rb', line 303

public_class_method def self.even_parity_ok?(opts = {})
  word  = opts[:word].to_i
  width = (opts[:width] || 32).to_i
  p = 0
  width.times { |i| p ^= (word >> i) & 1 }
  p.zero?
end

.find_sync(opts = {}) ⇒ Object

Supported Method Parameters

idx = PWN::SDR::Decoder::DSP.find_sync( bits: 'required - Array<0|1>', pattern: 'required - Array<0|1> or Integer (MSB-first)', width: 'optional - bit-width when pattern is Integer', max_err: 'optional - allowed bit errors (default 0)', from: 'optional - start index (default 0)' )



259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
# File 'lib/pwn/sdr/decoder/dsp.rb', line 259

public_class_method def self.find_sync(opts = {})
  bits    = opts[:bits]
  pattern = opts[:pattern]
  width   = opts[:width]
  max_err = (opts[:max_err] || 0).to_i
  from    = (opts[:from] || 0).to_i
  pat = if pattern.is_a?(Integer)
          w = width || pattern.bit_length
          Array.new(w) { |i| (pattern >> (w - 1 - i)) & 1 }
        else
          pattern
        end
  plen = pat.length
  upto = bits.length - plen
  i = from
  while i <= upto
    err = 0
    j = 0
    while j < plen
      err += 1 if bits[i + j] != pat[j]
      break if err > max_err

      j += 1
    end
    return i if err <= max_err

    i += 1
  end
  nil
end

.fsk_slice(opts = {}) ⇒ Object

Supported Method Parameters

bits = PWN::SDR::Decoder::DSP.fsk_slice( samples: 'required - Array', rate: 'required - sample rate (Hz)', baud: 'required - symbol rate', mark_hz: 'required - mark tone (bit=1)', space_hz:'required - space tone (bit=0)' ) Non-coherent 2-FSK: per-symbol Goertzel on mark/space, pick the winner.



228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
# File 'lib/pwn/sdr/decoder/dsp.rb', line 228

public_class_method def self.fsk_slice(opts = {})
  samples  = opts[:samples]
  rate     = opts[:rate].to_f
  baud     = opts[:baud].to_f
  mark_hz  = opts[:mark_hz].to_f
  space_hz = opts[:space_hz].to_f
  spb      = rate / baud
  nsym     = (samples.length / spb).floor
  bits     = Array.new(nsym)
  i = 0
  while i < nsym
    a = (i * spb).floor
    b = ((i + 1) * spb).floor
    win = samples[a...b]
    pm = goertzel(samples: win, rate: rate, freq: mark_hz)
    ps = goertzel(samples: win, rate: rate, freq: space_hz)
    bits[i] = pm >= ps ? 1 : 0
    i += 1
  end
  bits
end

.goertzel(opts = {}) ⇒ Object

Supported Method Parameters

power = PWN::SDR::Decoder::DSP.goertzel( samples: 'required - Array', rate: 'required - sample rate (Hz)', freq: 'required - target tone frequency (Hz)' )



97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
# File 'lib/pwn/sdr/decoder/dsp.rb', line 97

public_class_method def self.goertzel(opts = {})
  samples = opts[:samples]
  rate    = opts[:rate].to_f
  freq    = opts[:freq].to_f
  n       = samples.length
  return 0.0 if n.zero?

  k     = (0.5 + ((n * freq) / rate)).floor
  w     = (TWO_PI * k) / n
  coeff = 2.0 * Math.cos(w)
  s1 = 0.0
  s2 = 0.0
  samples.each do |x|
    s0 = x + (coeff * s1) - s2
    s2 = s1
    s1 = s0
  end
  ((s1 * s1) + (s2 * s2) - (coeff * s1 * s2)) / n
end

.helpObject

Display Usage for this Module



371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
# File 'lib/pwn/sdr/decoder/dsp.rb', line 371

public_class_method def self.help
  puts "USAGE (pure-Ruby DSP primitives — no external binaries):
    #{self}.unpack_s16le(data: raw_bytes)
    #{self}.resample(samples:, src_rate:, dst_rate:)
    #{self}.goertzel(samples:, rate:, freq:)
    #{self}.envelope(samples:, window: 32)
    #{self}.envelope_signed(samples:, window: 8)
    #{self}.dc_block(samples:, alpha: 0.995)
    #{self}.nrz_slice(samples:, rate:, baud:, invert: false)
    #{self}.fsk_slice(samples:, rate:, baud:, mark_hz:, space_hz:)
    #{self}.find_sync(bits:, pattern:, width:, max_err: 0, from: 0)
    #{self}.bits_to_int(bits:)
    #{self}.even_parity_ok?(word:, width: 32)
    #{self}.bch_31_21_syndrome(word:)
    #{self}.baudot_decode(bits:)
    #{self}.rms_dbfs(samples:)

    Constants: MORSE_TABLE, BAUDOT_LTRS, BAUDOT_FIGS

    #{self}.authors
  "
end

.nrz_slice(opts = {}) ⇒ Object

Supported Method Parameters

bits = PWN::SDR::Decoder::DSP.nrz_slice( samples: 'required - Array (post-FM-discriminator baseband)', rate: 'required - sample rate (Hz)', baud: 'required - symbol rate', invert: 'optional - flip bit polarity (default false)' ) Simple mid-bit sampler with zero-crossing resync. Returns Array<0|1>.



168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
# File 'lib/pwn/sdr/decoder/dsp.rb', line 168

public_class_method def self.nrz_slice(opts = {})
  samples = opts[:samples]
  rate    = opts[:rate].to_f
  baud    = opts[:baud].to_f
  invert  = opts[:invert]
  spb     = rate / baud
  return [] if spb < 2.0 || samples.empty?

  # DC-block then low-pass via short moving average (~1/4 symbol).
  lp_win = [(spb / 4.0).round, 1].max
  filt   = envelope_signed(samples: dc_block(samples: samples), window: lp_win)

  bits  = []
  phase = spb / 2.0
  prev  = filt.first.to_f
  filt.each do |v|
    # zero-crossing → resync to mid-symbol
    phase = spb / 2.0 if (prev.negative? && v >= 0) || (prev.positive? && v.negative?)
    phase -= 1.0
    if phase <= 0
      b = v.negative? ? 0 : 1
      b ^= 1 if invert
      bits << b
      phase += spb
    end
    prev = v
  end
  bits
end

.resample(opts = {}) ⇒ Object

Supported Method Parameters

out = PWN::SDR::Decoder::DSP.resample( samples: 'required - Array', src_rate: 'required - input sample rate (Hz)', dst_rate: 'required - output sample rate (Hz)' )



68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
# File 'lib/pwn/sdr/decoder/dsp.rb', line 68

public_class_method def self.resample(opts = {})
  samples  = opts[:samples]
  src_rate = opts[:src_rate].to_f
  dst_rate = opts[:dst_rate].to_f
  return samples.dup if (src_rate - dst_rate).abs < 1e-6

  ratio = src_rate / dst_rate
  out_len = (samples.length / ratio).floor
  out = Array.new(out_len)
  i = 0
  while i < out_len
    pos  = i * ratio
    idx  = pos.floor
    frac = pos - idx
    a = samples[idx] || 0.0
    b = samples[idx + 1] || a
    out[i] = a + ((b - a) * frac)
    i += 1
  end
  out
end

.rms_dbfs(opts = {}) ⇒ Object

Supported Method Parameters

dbfs = PWN::SDR::Decoder::DSP.rms_dbfs(samples: Array)



353
354
355
356
357
358
359
360
361
# File 'lib/pwn/sdr/decoder/dsp.rb', line 353

public_class_method def self.rms_dbfs(opts = {})
  samples = opts[:samples]
  return -120.0 if samples.nil? || samples.empty?

  ms = samples.sum { |v| v * v } / samples.length
  return -120.0 if ms <= 0

  10.0 * Math.log10(ms)
end

.unpack_s16le(opts = {}) ⇒ Object

Supported Method Parameters

samples = PWN::SDR::Decoder::DSP.unpack_s16le( data: 'required - raw String of little-endian signed 16-bit PCM' )



55
56
57
58
59
# File 'lib/pwn/sdr/decoder/dsp.rb', line 55

public_class_method def self.unpack_s16le(opts = {})
  data = opts[:data].to_s
  norm = 1.0 / 32_768.0
  data.unpack('s<*').map { |v| v * norm }
end