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
134 lines
4.5 KiB
Python
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
|