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.
150 lines
5.0 KiB
Python
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"
|