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
158 lines
5.6 KiB
Python
158 lines
5.6 KiB
Python
"""Tests for the voice subcarrier demodulator block."""
|
|
|
|
import numpy as np
|
|
import pytest
|
|
|
|
try:
|
|
from gnuradio import blocks, gr
|
|
|
|
HAS_GNURADIO = True
|
|
except ImportError:
|
|
HAS_GNURADIO = False
|
|
|
|
from apollo.constants import (
|
|
SAMPLE_RATE_BASEBAND,
|
|
VOICE_SUBCARRIER_HZ,
|
|
)
|
|
from apollo.usb_signal_gen import generate_fm_voice_subcarrier
|
|
|
|
pytestmark = pytest.mark.skipif(not HAS_GNURADIO, reason="GNU Radio not installed")
|
|
|
|
|
|
class TestVoiceDemodInstantiation:
|
|
"""Test block creation and parameter handling."""
|
|
|
|
def test_default_parameters(self):
|
|
"""Block should instantiate with default parameters."""
|
|
from apollo.voice_subcarrier_demod import voice_subcarrier_demod
|
|
|
|
demod = voice_subcarrier_demod()
|
|
assert demod is not None
|
|
|
|
def test_custom_sample_rate(self):
|
|
"""Block should accept a custom sample rate."""
|
|
from apollo.voice_subcarrier_demod import voice_subcarrier_demod
|
|
|
|
demod = voice_subcarrier_demod(sample_rate=10_240_000, audio_rate=16000)
|
|
assert demod is not None
|
|
assert demod.output_sample_rate == 16000
|
|
|
|
def test_output_sample_rate_property(self):
|
|
"""Output sample rate should match the requested audio rate."""
|
|
from apollo.voice_subcarrier_demod import voice_subcarrier_demod
|
|
|
|
demod = voice_subcarrier_demod(audio_rate=8000)
|
|
assert demod.output_sample_rate == 8000.0
|
|
|
|
|
|
class TestVoiceDemodFunctional:
|
|
"""Functional tests with synthetic FM voice signals."""
|
|
|
|
def test_fm_voice_produces_output(self):
|
|
"""An FM voice signal at 1.25 MHz should produce non-trivial audio output."""
|
|
from apollo.voice_subcarrier_demod import voice_subcarrier_demod
|
|
|
|
tb = gr.top_block()
|
|
sample_rate = SAMPLE_RATE_BASEBAND
|
|
|
|
# Generate a 1.25 MHz FM subcarrier with 1 kHz tone, enough for
|
|
# several audio cycles to pass through the 300-3000 Hz BPF.
|
|
# At 8 kHz output, we need at least a few ms of signal.
|
|
# 200ms of input gives ~1600 output samples at 8 kHz.
|
|
n_samples = int(sample_rate * 0.2)
|
|
voice_signal = generate_fm_voice_subcarrier(
|
|
n_samples=n_samples,
|
|
sample_rate=sample_rate,
|
|
tone_freq=1000.0,
|
|
)
|
|
|
|
src = blocks.vector_source_f(voice_signal.tolist())
|
|
demod = voice_subcarrier_demod(sample_rate=sample_rate, audio_rate=8000)
|
|
snk = blocks.vector_sink_f()
|
|
|
|
tb.connect(src, demod, snk)
|
|
tb.run()
|
|
|
|
output = np.array(snk.data())
|
|
assert len(output) > 0, "Demodulator produced no output"
|
|
|
|
# After filter settling, there should be energy in the output.
|
|
# Skip the first 25% for filter transients.
|
|
settled = output[len(output) // 4 :]
|
|
if len(settled) > 10:
|
|
rms = np.sqrt(np.mean(settled**2))
|
|
assert rms > 1e-6, f"Output RMS too low: {rms} -- no audio recovered"
|
|
|
|
def test_1khz_tone_spectral_peak(self):
|
|
"""A 1 kHz FM tone should produce audio with energy near 1 kHz."""
|
|
from apollo.voice_subcarrier_demod import voice_subcarrier_demod
|
|
|
|
tb = gr.top_block()
|
|
sample_rate = SAMPLE_RATE_BASEBAND
|
|
audio_rate = 8000
|
|
tone_freq = 1000.0
|
|
|
|
# 500ms of signal for decent frequency resolution
|
|
n_samples = int(sample_rate * 0.5)
|
|
voice_signal = generate_fm_voice_subcarrier(
|
|
n_samples=n_samples,
|
|
sample_rate=sample_rate,
|
|
tone_freq=tone_freq,
|
|
)
|
|
|
|
src = blocks.vector_source_f(voice_signal.tolist())
|
|
demod = voice_subcarrier_demod(sample_rate=sample_rate, audio_rate=audio_rate)
|
|
snk = blocks.vector_sink_f()
|
|
|
|
tb.connect(src, demod, snk)
|
|
tb.run()
|
|
|
|
output = np.array(snk.data())
|
|
assert len(output) > 100, f"Too few output samples: {len(output)}"
|
|
|
|
# Skip transients, use the last 75%
|
|
settled = output[len(output) // 4 :]
|
|
if len(settled) < 64:
|
|
pytest.skip("Not enough settled samples for spectral analysis")
|
|
|
|
# FFT to find the dominant frequency
|
|
fft_vals = np.abs(np.fft.rfft(settled))
|
|
freqs = np.fft.rfftfreq(len(settled), d=1.0 / audio_rate)
|
|
|
|
# Find peak frequency (skip DC bin)
|
|
peak_idx = np.argmax(fft_vals[1:]) + 1
|
|
peak_freq = freqs[peak_idx]
|
|
|
|
# The recovered tone should be within 200 Hz of 1 kHz
|
|
assert abs(peak_freq - tone_freq) < 200, (
|
|
f"Peak frequency {peak_freq:.1f} Hz is not near {tone_freq} Hz"
|
|
)
|
|
|
|
def test_no_output_on_silence(self):
|
|
"""A constant (unmodulated) carrier should produce near-zero audio."""
|
|
from apollo.voice_subcarrier_demod import voice_subcarrier_demod
|
|
|
|
tb = gr.top_block()
|
|
sample_rate = SAMPLE_RATE_BASEBAND
|
|
|
|
# Unmodulated 1.25 MHz carrier (no FM deviation)
|
|
n_samples = int(sample_rate * 0.1)
|
|
t = np.arange(n_samples, dtype=np.float64) / sample_rate
|
|
carrier = np.cos(2.0 * np.pi * VOICE_SUBCARRIER_HZ * t).astype(np.float32)
|
|
|
|
src = blocks.vector_source_f(carrier.tolist())
|
|
demod = voice_subcarrier_demod(sample_rate=sample_rate, audio_rate=8000)
|
|
snk = blocks.vector_sink_f()
|
|
|
|
tb.connect(src, demod, snk)
|
|
tb.run()
|
|
|
|
output = np.array(snk.data())
|
|
if len(output) > 20:
|
|
settled = output[len(output) // 4 :]
|
|
if len(settled) > 0:
|
|
rms = np.sqrt(np.mean(settled**2))
|
|
# Unmodulated carrier -> near-zero audio (just noise floor)
|
|
# Be generous with the threshold since filter transients exist
|
|
assert rms < 1.0, f"Unmodulated carrier produced too much audio: RMS={rms}"
|