gr-apollo/src/apollo/voice_subcarrier_demod.py
Ryan Malloy 0ee7ff0ad7 Implement full Apollo USB downlink decoder chain
Complete signal processing pipeline from complex baseband to decoded
PCM telemetry, verified against the 1965 NAA Study Guide (A-624):

Core demod (Phase 1):
  - PM demodulator with carrier PLL recovery
  - 1.024 MHz subcarrier extractor (bandpass + downconvert)
  - BPSK demodulator with Costas loop + symbol sync
  - Convenience hier_block2 combining subcarrier + BPSK

PCM frame processing (Phase 2):
  - 32-bit frame sync with Hamming distance correlator
  - SEARCH/VERIFY/LOCKED state machine, complement-on-odd handling
  - Frame demultiplexer (128-word, A/D voltage scaling)
  - AGC downlink decoder (15-bit word reassembly from DNTM1/DNTM2)

Voice and analog (Phase 3):
  - 1.25 MHz FM voice subcarrier demod to 8 kHz audio
  - SCO demodulator for 9 analog sensor channels (14.5-165 kHz)

Virtual AGC integration (Phase 4):
  - TCP bridge client with auto-reconnect and channel filtering
  - DSKY uplink encoder (VERB/NOUN/DATA command sequences)

Top-level receiver (Phase 5):
  - usb_downlink_receiver hier_block2: one block, complex in, PDUs out
  - 14 GRC block YAML definitions for GNU Radio Companion
  - Example scripts for signal analysis and full-chain demo

Infrastructure:
  - constants.py with all timing/frequency/frame parameters
  - protocol.py for sync word + AGC packet encode/decode
  - Synthetic USB signal generator for testing
  - 222 tests passing, ruff lint clean
2026-02-20 13:18:42 -07:00

134 lines
4.5 KiB
Python

"""
Apollo Voice Subcarrier Demodulator — 1.25 MHz FM to audio.
Hierarchical block that extracts the 1.25 MHz FM voice subcarrier from the PM
demodulator output and recovers 300-3000 Hz audio suitable for playback.
Voice path on the spacecraft (IMPLEMENTATION_SPEC.md section 4.2):
Audio (300-3000 Hz) -> FM VCO @ 113 kHz -> balanced mixer w/ 512 kHz clock
-> BPF -> x2 -> 1.25 MHz FM subcarrier, +/-29 kHz deviation
Receiver side (this block):
PM demod output -> subcarrier_extract(1.25 MHz, BW=58 kHz)
-> quadrature_demod (FM discriminator)
-> audio bandpass 300-3000 Hz
-> rational_resampler to 8000 Hz output
Reference: IMPLEMENTATION_SPEC.md sections 4.2, 4.4
"""
import math
from gnuradio import analog, filter, gr
from gnuradio.fft import window
from gnuradio.filter import firdes
from apollo.constants import (
SAMPLE_RATE_BASEBAND,
VOICE_AUDIO_HIGH_HZ,
VOICE_AUDIO_LOW_HZ,
VOICE_FM_DEVIATION_HZ,
VOICE_SUBCARRIER_HZ,
)
from apollo.subcarrier_extract import subcarrier_extract
class voice_subcarrier_demod(gr.hier_block2):
"""Extract and demodulate the 1.25 MHz FM voice subcarrier to audio.
Inputs:
float -- PM demodulator output (composite subcarrier signal)
Outputs:
float -- demodulated audio at audio_rate (default 8000 Hz)
"""
def __init__(
self,
sample_rate: float = SAMPLE_RATE_BASEBAND,
audio_rate: int = 8000,
):
gr.hier_block2.__init__(
self,
"apollo_voice_subcarrier_demod",
gr.io_signature(1, 1, gr.sizeof_float),
gr.io_signature(1, 1, gr.sizeof_float),
)
self._sample_rate = sample_rate
self._audio_rate = audio_rate
# Voice BPF bandwidth: 2 * deviation = 2 * 29 kHz = 58 kHz
voice_bw = 2 * VOICE_FM_DEVIATION_HZ
# Decimate aggressively to reduce load before FM demod. The voice
# subcarrier bandwidth is 58 kHz, so we need at least ~120 kHz after
# decimation (Nyquist). Pick decimation to land near 128 kHz.
# 5_120_000 / 40 = 128_000 Hz -- satisfies Nyquist for 58 kHz BW.
decimation = max(1, int(sample_rate / (voice_bw * 2.2)))
self._decimation = decimation
extracted_rate = sample_rate / decimation
# Stage 1: Extract the 1.25 MHz subcarrier to complex baseband
self.extract = subcarrier_extract(
center_freq=VOICE_SUBCARRIER_HZ,
bandwidth=voice_bw,
sample_rate=sample_rate,
decimation=decimation,
)
# Stage 2: FM discriminator (quadrature demod)
# Gain formula: sample_rate / (2 * pi * max_deviation)
# This converts instantaneous frequency offset to a proportional voltage.
fm_gain = extracted_rate / (2.0 * math.pi * VOICE_FM_DEVIATION_HZ)
self.fm_demod = analog.quadrature_demod_cf(fm_gain)
# Stage 3: Audio bandpass filter 300-3000 Hz
# Removes DC offset from FM demod and any out-of-band noise.
audio_transition = 200.0 # 200 Hz transition band
audio_taps = firdes.band_pass(
1.0, # gain
extracted_rate, # sample rate
VOICE_AUDIO_LOW_HZ, # low cutoff (300 Hz)
VOICE_AUDIO_HIGH_HZ, # high cutoff (3000 Hz)
audio_transition, # transition width
window.WIN_HAMMING,
)
self.audio_bpf = filter.fir_filter_fff(1, audio_taps)
# Stage 4: Rational resampler to target audio rate
# extracted_rate -> audio_rate
# Find GCD for rational resampling ratio
interp = audio_rate
decim = int(extracted_rate)
common = math.gcd(interp, decim)
interp //= common
decim //= common
self._resample_interp = interp
self._resample_decim = decim
self.resampler = filter.rational_resampler_fff(
interpolation=interp,
decimation=decim,
)
# Connect the chain
self.connect(
self,
self.extract,
self.fm_demod,
self.audio_bpf,
self.resampler,
self,
)
@property
def output_sample_rate(self) -> float:
"""Actual output sample rate after resampling."""
return float(self._audio_rate)
@property
def extracted_rate(self) -> float:
"""Sample rate after subcarrier extraction (before audio resampling)."""
return self._sample_rate / self._decimation