Skip to content

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.

Install gr-apollo in development mode:

Terminal window
uv pip install -e .

The signal generator and its dependencies (numpy) are pure Python. No GNU Radio installation is needed.

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).

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.

Add additive white Gaussian noise at a specified SNR:

signal, frame_bits = generate_usb_baseband(
frames=5,
snr_db=20.0, # 20 dB SNR
)
SNRSignal QualityUse Case
NoneNo noise (clean)Verifying decoder logic
40 dBVery cleanBaseline performance test
30 dBModerate noiseRealistic strong signal
20 dBNoisyStress-testing the demodulator
10 dBVery noisyThreshold performance testing

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,
)

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).

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]]]:

Feed the synthetic signal into a GNU Radio flowgraph using vector_source_c:

import numpy as np
from gnuradio import blocks, gr
from apollo.usb_downlink_receiver import usb_downlink_receiver
from apollo.usb_signal_gen import generate_usb_baseband
# Generate test signal with known payload
np.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 flowgraph
tb = 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 results
tb.run()
print(f"Decoded {debug.num_messages()} frames")

The usb_signal_gen module also exposes the individual signal generation stages. These are useful for testing specific blocks in isolation.

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 kHz

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)}") # 1024

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)

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
)

A ready-to-run demo is included in the repository:

Terminal window
uv run python examples/test_signal_gen_demo.py

This 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)

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 indefinitely
tx = usb_signal_source(
voice_enabled=True,
snr_db=25.0,
)
# Limit output to 10 frames worth of samples
head = blocks.head(gr.sizeof_gr_complex, 10 * 102400)
snk = blocks.vector_sink_c()
tb.connect(tx, head, snk)
tb.run()
Approachgenerate_usb_basebandusb_signal_source
RuntimePure Python (numpy)GNU Radio required
Outputnumpy array (finite)Streaming (continuous)
PayloadPer-frame frame_data listframe_data message port
Use caseUnit tests, scriptingFlowgraphs, loopback demos
VoiceInternal test tone onlyInternal tone or external audio

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.