Generate Test Signals
The usb_signal_gen module produces synthetic complex baseband signals that exercise the full demodulation chain. The generator creates PM-modulated carriers with BPSK PCM subcarriers, optional FM voice, and configurable noise — all without requiring GNU Radio or an SDR.
Prerequisites
Section titled “Prerequisites”Install gr-apollo in development mode:
uv pip install -e .The signal generator and its dependencies (numpy) are pure Python. No GNU Radio installation is needed.
Basic Signal Generation
Section titled “Basic Signal Generation”The core function is generate_usb_baseband(). It returns a tuple of the complex baseband signal and a list of per-frame bit sequences (useful for verifying decoder output).
Clean Signal
Section titled “Clean Signal”from apollo.usb_signal_gen import generate_usb_baseband
signal, frame_bits = generate_usb_baseband(frames=5)
print(f"Samples: {len(signal)}")print(f"Frames generated: {len(frame_bits)}")print(f"Bits per frame: {len(frame_bits[0])}")This produces 5 PCM frames at 51.2 kbps with random payload data and no added noise. Each frame contains 128 words (1024 bits), and the signal is sampled at 5.12 MHz.
With Noise
Section titled “With Noise”Add additive white Gaussian noise at a specified SNR:
signal, frame_bits = generate_usb_baseband( frames=5, snr_db=20.0, # 20 dB SNR)| SNR | Signal Quality | Use Case |
|---|---|---|
| None | No noise (clean) | Verifying decoder logic |
| 40 dB | Very clean | Baseline performance test |
| 30 dB | Moderate noise | Realistic strong signal |
| 20 dB | Noisy | Stress-testing the demodulator |
| 10 dB | Very noisy | Threshold performance testing |
Custom Payload Data
Section titled “Custom Payload Data”Instead of random data, supply specific byte sequences for each frame:
import numpy as np
# Known payload: 124 data bytes (words 5-128)payload = bytes(range(124))
signal, frame_bits = generate_usb_baseband( frames=5, frame_data=[payload] * 5,)With Voice Subcarrier
Section titled “With Voice Subcarrier”Enable the 1.25 MHz FM voice subcarrier with a test tone:
signal, frame_bits = generate_usb_baseband( frames=5, voice_enabled=True, voice_tone_hz=1000.0, # 1 kHz test tone (default))The voice subcarrier is FM-modulated with the specified tone at the Apollo specification deviation of +/-29 kHz. Its level relative to the PCM subcarrier matches the spec ratio (1.68 Vpp voice / 2.2 Vpp PCM).
All generate_usb_baseband Parameters
Section titled “All generate_usb_baseband Parameters”def generate_usb_baseband( frames: int = 1, # Number of PCM frames bit_rate: float = 51_200, # 51200 (high) or 1600 (low) sample_rate: float = 5_120_000, # Output sample rate in Hz pm_deviation: float = 0.133, # Peak PM deviation in radians voice_enabled: bool = False, # Include FM voice subcarrier voice_tone_hz: float = 1000.0, # Voice test tone frequency snr_db: float | None = None, # Add AWGN at this SNR (None = no noise) frame_data: list[bytes] | None = None, # Custom payload per frame) -> tuple[np.ndarray, list[list[int]]]:Using Generated Signals with GNU Radio
Section titled “Using Generated Signals with GNU Radio”Feed the synthetic signal into a GNU Radio flowgraph using vector_source_c:
import numpy as npfrom gnuradio import blocks, gr
from apollo.usb_downlink_receiver import usb_downlink_receiverfrom apollo.usb_signal_gen import generate_usb_baseband
# Generate test signal with known payloadnp.random.seed(42)payload = bytes(np.random.randint(0, 256, size=124, dtype=np.uint8))
signal, frame_bits = generate_usb_baseband( frames=5, frame_data=[payload] * 5, snr_db=30.0,)
# Build flowgraphtb = gr.top_block()
src = blocks.vector_source_c(signal.tolist())receiver = usb_downlink_receiver(output_format="scaled")debug = blocks.message_debug()
tb.connect(src, receiver)tb.msg_connect(receiver, "frames", debug, "store")
# Run and check resultstb.run()print(f"Decoded {debug.num_messages()} frames")Lower-Level Signal Components
Section titled “Lower-Level Signal Components”The usb_signal_gen module also exposes the individual signal generation stages. These are useful for testing specific blocks in isolation.
NRZ Waveform
Section titled “NRZ Waveform”Convert a bit list to an NRZ baseband waveform (+1/-1 values):
from apollo.usb_signal_gen import generate_nrz_waveform
bits = [1, 0, 1, 1, 0, 0, 1, 0]nrz = generate_nrz_waveform(bits, bit_rate=51200, sample_rate=5_120_000)# Each bit maps to 100 samples at 5.12 MHz / 51.2 kHzPCM Frame Bits
Section titled “PCM Frame Bits”Generate the bit-level content of a single PCM frame (sync word + data):
from apollo.usb_signal_gen import generate_pcm_frame
frame = generate_pcm_frame( frame_id=1, # Frame 1 of 50 odd=False, # Even frame (normal sync core) data=bytes(124), # All-zero payload words_per_frame=128, # High rate)print(f"Frame bits: {len(frame)}") # 1024BPSK Subcarrier
Section titled “BPSK Subcarrier”Modulate NRZ data onto a 1.024 MHz carrier:
from apollo.usb_signal_gen import generate_bpsk_subcarrier, generate_nrz_waveform
bits = [1, 0, 1, 1, 0]nrz = generate_nrz_waveform(bits, 51200, 5_120_000)bpsk = generate_bpsk_subcarrier(nrz, 1_024_000, 5_120_000)FM Voice Subcarrier
Section titled “FM Voice Subcarrier”Generate a standalone FM voice subcarrier (no PCM):
from apollo.usb_signal_gen import generate_fm_voice_subcarrier
voice = generate_fm_voice_subcarrier( n_samples=512_000, # 100 ms at 5.12 MHz sample_rate=5_120_000, tone_freq=1000.0, # 1 kHz test tone subcarrier_freq=1_250_000, # 1.25 MHz center fm_deviation=29_000, # +/-29 kHz)Running the Demo Script
Section titled “Running the Demo Script”A ready-to-run demo is included in the repository:
uv run python examples/test_signal_gen_demo.pyThis script generates clean, voiced, and noisy signals, runs spectral analysis, and prints power measurements for each subcarrier band. Example output:
Apollo USB Signal Generator Demo==================================================
1. Clean PCM-only signal (3 frames): Samples: 307200 (expected 307200) Duration: 60.0 ms Envelope std: 0.0040 (PM = near-constant)
2. Spectral analysis: PCM band (950-1100 kHz): 12.3 dB re total
3. Signal with voice subcarrier: Voice band (1.2-1.3 MHz): 9.1 dB re total
4. Signal with 20 dB SNR noise: Envelope std: 0.0712 (noisy = higher variance)Streaming Signal Generation
Section titled “Streaming Signal Generation”The generate_usb_baseband function creates signals in batch (all samples computed at once, returned as a numpy array). For streaming scenarios — where signal generation runs continuously inside a GNU Radio flowgraph — use usb_signal_source instead:
from gnuradio import blocks, gr
from apollo.usb_signal_source import usb_signal_source
tb = gr.top_block()
# Streaming source: generates frames indefinitelytx = usb_signal_source( voice_enabled=True, snr_db=25.0,)
# Limit output to 10 frames worth of sampleshead = blocks.head(gr.sizeof_gr_complex, 10 * 102400)snk = blocks.vector_sink_c()
tb.connect(tx, head, snk)tb.run()| Approach | generate_usb_baseband | usb_signal_source |
|---|---|---|
| Runtime | Pure Python (numpy) | GNU Radio required |
| Output | numpy array (finite) | Streaming (continuous) |
| Payload | Per-frame frame_data list | frame_data message port |
| Use case | Unit tests, scripting | Flowgraphs, loopback demos |
| Voice | Internal test tone only | Internal tone or external audio |
Signal Structure at a Glance
Section titled “Signal Structure at a Glance”The generated baseband signal has this structure:
flowchart LR
A[PCM Bits<br/>NRZ +1/-1] --> B["BPSK Mod<br/>data * cos(2pi * 1.024M * t)"]
C[Voice Tone<br/>1 kHz sine] --> D["FM Mod<br/>cos(2pi * 1.25M * t + ...)"]
B --> E["PM Mod<br/>exp(j * composite)"]
D --> E
E --> F[Complex<br/>Baseband<br/>5.12 MHz]
G["AWGN<br/>(optional)"] --> F
The PM deviation is 0.133 radians (7.6 degrees), which is small enough that the small-angle approximation holds with less than 0.3% error. This means the composite modulating signal maps nearly linearly into the carrier phase.