""" 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