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