gr-apollo/tests/test_fm_voice_subcarrier_mod.py
Ryan Malloy 493c21c511 Add transmit chain: 6 composable GR source blocks mirroring CuriousMarc bench
Implement the transmit/generate side as streaming GNU Radio blocks,
complementing the existing receive chain. Each block maps to a physical
instrument on CuriousMarc's Keysight bench:

  pcm_frame_source  - PCM bit stream generator (sync_block + FrameSourceEngine)
  nrz_encoder       - bits to NRZ waveform (+1/-1) with upsampling
  bpsk_subcarrier_mod - NRZ x cos(1.024 MHz) BPSK modulator
  fm_voice_subcarrier_mod - 1.25 MHz FM test tone source
  pm_mod            - phase modulator: exp(j * deviation * input)
  usb_signal_source - convenience wrapper wiring all blocks together

Includes GRC YAML definitions for all blocks under [Apollo USB] category,
49 new tests (271 total, all passing), and a loopback test that validates
the full TX->RX round trip including frame recovery with 30 dB AWGN.
2026-02-21 18:55:50 -07:00

150 lines
5.0 KiB
Python

"""Tests for the FM voice subcarrier modulator 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,
)
pytestmark = pytest.mark.skipif(not HAS_GNURADIO, reason="GNU Radio not installed")
class TestFmVoiceModInstantiation:
"""Test block creation and parameter handling."""
def test_default_parameters(self):
"""Block should instantiate with default parameters."""
from apollo.fm_voice_subcarrier_mod import fm_voice_subcarrier_mod
mod = fm_voice_subcarrier_mod()
assert mod is not None
def test_custom_tone_freq(self):
"""Block should accept a custom tone frequency and produce output."""
from apollo.fm_voice_subcarrier_mod import fm_voice_subcarrier_mod
sample_rate = SAMPLE_RATE_BASEBAND
n_samples = 10240
tb = gr.top_block()
src = fm_voice_subcarrier_mod(sample_rate=sample_rate, tone_freq=2000.0)
head = blocks.head(gr.sizeof_float, n_samples)
snk = blocks.vector_sink_f()
tb.connect(src, head, snk)
tb.run()
data = np.array(snk.data())
assert len(data) == n_samples
assert np.any(data != 0), "Output is all zeros with tone_freq=2000"
def test_properties(self):
"""Properties should reflect constructor arguments."""
from apollo.fm_voice_subcarrier_mod import fm_voice_subcarrier_mod
mod = fm_voice_subcarrier_mod(
tone_freq=1500.0,
subcarrier_freq=1_000_000,
fm_deviation=20_000,
)
assert mod.tone_freq == 1500.0
assert mod.subcarrier_freq == 1_000_000
assert mod.fm_deviation == 20_000
class TestFmVoiceModFunctional:
"""Functional tests with signal analysis."""
def test_produces_output(self):
"""Source block should produce non-zero float output."""
from apollo.fm_voice_subcarrier_mod import fm_voice_subcarrier_mod
sample_rate = SAMPLE_RATE_BASEBAND
n_samples = 51200
tb = gr.top_block()
src = fm_voice_subcarrier_mod(sample_rate=sample_rate)
head = blocks.head(gr.sizeof_float, n_samples)
snk = blocks.vector_sink_f()
tb.connect(src, head, snk)
tb.run()
data = np.array(snk.data())
assert len(data) == n_samples, f"Expected {n_samples} samples, got {len(data)}"
assert np.any(data != 0), "Output is all zeros"
def test_output_is_float(self):
"""Output samples should be real-valued floats."""
from apollo.fm_voice_subcarrier_mod import fm_voice_subcarrier_mod
sample_rate = SAMPLE_RATE_BASEBAND
n_samples = 1024
tb = gr.top_block()
src = fm_voice_subcarrier_mod(sample_rate=sample_rate)
head = blocks.head(gr.sizeof_float, n_samples)
snk = blocks.vector_sink_f()
tb.connect(src, head, snk)
tb.run()
data = np.array(snk.data())
assert data.dtype in (np.float32, np.float64), (
f"Expected float output, got {data.dtype}"
)
def test_spectral_energy_at_subcarrier(self):
"""Most spectral energy should be near the 1.25 MHz subcarrier."""
from apollo.fm_voice_subcarrier_mod import fm_voice_subcarrier_mod
sample_rate = SAMPLE_RATE_BASEBAND
n_samples = 51200 # ~10 ms
tb = gr.top_block()
src = fm_voice_subcarrier_mod(sample_rate=sample_rate)
head = blocks.head(gr.sizeof_float, n_samples)
snk = blocks.vector_sink_f()
tb.connect(src, head, snk)
tb.run()
data = np.array(snk.data())
fft_vals = np.fft.fft(data)
freqs = np.fft.fftfreq(len(data), 1.0 / sample_rate)
# Energy in the 1.2-1.3 MHz band (subcarrier +/- deviation margin)
voice_mask = (np.abs(freqs) > 1_200_000) & (np.abs(freqs) < 1_300_000)
voice_power = np.mean(np.abs(fft_vals[voice_mask]) ** 2)
total_power = np.mean(np.abs(fft_vals) ** 2)
assert voice_power > total_power * 0.1, (
f"Subcarrier band power ({voice_power:.1f}) is less than 10% of "
f"total power ({total_power:.1f})"
)
def test_output_bounded(self):
"""Output amplitude should stay bounded (not blow up)."""
from apollo.fm_voice_subcarrier_mod import fm_voice_subcarrier_mod
sample_rate = SAMPLE_RATE_BASEBAND
n_samples = 51200
tb = gr.top_block()
src = fm_voice_subcarrier_mod(sample_rate=sample_rate)
head = blocks.head(gr.sizeof_float, n_samples)
snk = blocks.vector_sink_f()
tb.connect(src, head, snk)
tb.run()
data = np.array(snk.data())
peak = np.max(np.abs(data))
# FM on a cosine carrier: peak should be around 1.0, certainly < 2.0
assert peak < 2.0, f"Output peak amplitude {peak:.3f} exceeds expected bound"
assert peak > 0.1, f"Output peak amplitude {peak:.3f} is suspiciously low"