gr-apollo/examples/real_signal_demo.py
Ryan Malloy 77ddec149c Add audio download script and real signal demo
fetch_apollo_audio.py downloads Apollo 11 audio highlights from Archive.org
and extracts clips using ffmpeg (48 kHz mono WAV). Supports --list, --clip,
--all with idempotent downloads and progress reporting.

real_signal_demo.py auto-discovers downloaded clips and runs them through the
full USB downlink TX/RX chain (PCM telemetry + FM voice), saving recovered
audio for comparison. Falls back to the bundled demo clip if no downloads exist.

Also adds .gitignore to keep large audio files out of the repo while preserving
the small apollo11_crew.wav demo clip.
2026-02-24 14:15:23 -07:00

380 lines
12 KiB
Python
Executable File

#!/usr/bin/env python3
"""
Apollo Real Signal Demo -- process downloaded Apollo recordings through USB.
Auto-discovers WAV files in examples/audio/ (from fetch_apollo_audio.py) and
runs them through the full USB downlink chain: transmit (NRZ + BPSK + voice FM
onto PM carrier) then receive (PCM frame recovery + voice demodulation).
This proves the gr-apollo signal chain works on real-world audio, not just
synthetic test tones.
Signal path (same as full_downlink_demo.py):
TX:
pcm_frame_source -> nrz -> bpsk_mod (1.024 MHz) --+
audio_clip -> fm_voice_mod (1.25 MHz, +/-29kHz) ---+-> add -> pm_mod -> [signal]
RX:
[signal] -> usb_downlink_receiver -> PCM frames
[signal] -> pm_demod -> voice_subcarrier_demod -> recovered audio
Usage:
uv run python examples/real_signal_demo.py
uv run python examples/real_signal_demo.py --clip eagle_has_landed
uv run python examples/real_signal_demo.py --snr 25
uv run python examples/real_signal_demo.py --clip liftoff --play
"""
import argparse
import glob
import os
import sys
import time
from math import gcd
import numpy as np
from gnuradio import blocks, gr
from scipy.io import wavfile
from scipy.signal import resample_poly
from apollo.bpsk_subcarrier_mod import bpsk_subcarrier_mod
from apollo.constants import (
PCM_HIGH_BIT_RATE,
PCM_HIGH_WORDS_PER_FRAME,
PCM_SUBCARRIER_HZ,
PCM_WORD_LENGTH,
PM_PEAK_DEVIATION_RAD,
SAMPLE_RATE_BASEBAND,
VOICE_FM_DEVIATION_HZ,
VOICE_SUBCARRIER_HZ,
)
from apollo.fm_voice_subcarrier_mod import fm_voice_subcarrier_mod
from apollo.nrz_encoder import nrz_encoder
from apollo.pcm_frame_source import pcm_frame_source
from apollo.pm_demod import pm_demod
from apollo.pm_mod import pm_mod
from apollo.usb_downlink_receiver import usb_downlink_receiver
from apollo.voice_subcarrier_demod import voice_subcarrier_demod
# Audio directory relative to this script
AUDIO_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "audio")
# Fallback clip if no downloaded audio exists
FALLBACK_CLIP = os.path.join(AUDIO_DIR, "apollo11_crew.wav")
def discover_clips():
"""Find WAV files in the audio directory.
Returns a dict of {name: path} for all apollo11_*.wav files,
excluding *_recovered.wav and *_fullchain.wav (our own output).
"""
clips = {}
pattern = os.path.join(AUDIO_DIR, "apollo11_*.wav")
for path in sorted(glob.glob(pattern)):
basename = os.path.basename(path)
# Skip output files from previous runs
if basename.endswith("_recovered.wav") or basename.endswith("_fullchain.wav"):
continue
# Extract clip name: apollo11_eagle_has_landed.wav -> eagle_has_landed
name = basename.replace("apollo11_", "").replace(".wav", "")
# Skip the small demo clip unless it's the only option
if name == "crew":
continue
clips[name] = path
return clips
def load_and_upsample_audio(audio_path, sample_rate):
"""Load audio file and upsample to baseband rate."""
input_rate, audio_data = wavfile.read(audio_path)
if audio_data.ndim > 1:
audio_data = audio_data[:, 0]
# Normalize to [-1, 1]
if audio_data.dtype == np.int16:
audio_float = audio_data.astype(np.float32) / 32768.0
elif audio_data.dtype == np.int32:
audio_float = audio_data.astype(np.float32) / 2147483648.0
else:
audio_float = audio_data.astype(np.float32)
duration = len(audio_float) / input_rate
# Resample to 8 kHz first (Apollo voice bandwidth)
audio_rate = 8000
if input_rate != audio_rate:
g = gcd(audio_rate, input_rate)
audio_float = resample_poly(
audio_float, audio_rate // g, input_rate // g
).astype(np.float32)
# Upsample to baseband
g = gcd(sample_rate, audio_rate)
upsampled = resample_poly(
audio_float, sample_rate // g, audio_rate // g
).astype(np.float32)
return upsampled, duration, audio_rate
def build_tx_signal(audio_samples, n_samples, sample_rate, snr_db):
"""Build the combined TX signal: PCM + voice -> PM modulation.
Same manual assembly as full_downlink_demo.py so we can inject
external audio into the voice channel.
"""
tb = gr.top_block()
# --- PCM telemetry path ---
frame_src = pcm_frame_source(bit_rate=PCM_HIGH_BIT_RATE)
nrz = nrz_encoder(bit_rate=PCM_HIGH_BIT_RATE, sample_rate=sample_rate)
bpsk = bpsk_subcarrier_mod(
subcarrier_freq=PCM_SUBCARRIER_HZ,
sample_rate=sample_rate,
)
tb.connect(frame_src, nrz, bpsk)
# --- Voice subcarrier path (real audio) ---
voice_src = blocks.vector_source_f(audio_samples[:n_samples].tolist())
voice_mod = fm_voice_subcarrier_mod(
sample_rate=sample_rate,
subcarrier_freq=VOICE_SUBCARRIER_HZ,
fm_deviation=VOICE_FM_DEVIATION_HZ,
audio_input=True,
)
# Scale voice relative to PCM: 1.68/2.2 per IMPL_SPEC
voice_gain = blocks.multiply_const_ff(1.68 / 2.2)
tb.connect(voice_src, voice_mod, voice_gain)
# --- Sum subcarriers ---
adder = blocks.add_ff(1)
tb.connect(bpsk, (adder, 0))
tb.connect(voice_gain, (adder, 1))
# --- PM modulation ---
pm = pm_mod(pm_deviation=PM_PEAK_DEVIATION_RAD, sample_rate=sample_rate)
head = blocks.head(gr.sizeof_gr_complex, n_samples)
tb.connect(adder, pm, head)
# --- Optional AWGN ---
if snr_db is not None:
import math
noise_power = 1.0 / (10.0 ** (snr_db / 10.0))
noise_amp = math.sqrt(noise_power / 2.0)
noise = blocks.vector_source_c(
(np.random.randn(n_samples) + 1j * np.random.randn(n_samples)).astype(
np.complex64
)
* noise_amp
)
summer = blocks.add_cc(1)
snk = blocks.vector_sink_c()
tb.connect(head, (summer, 0))
tb.connect(noise, (summer, 1))
tb.connect(summer, snk)
else:
snk = blocks.vector_sink_c()
tb.connect(head, snk)
tb.run()
return np.array(snk.data())
def receive_pcm(signal_data, sample_rate):
"""Run the PCM receive chain and return the message debug sink."""
tb = gr.top_block()
src = blocks.vector_source_c(signal_data.tolist())
rx = usb_downlink_receiver(
sample_rate=sample_rate,
bit_rate=PCM_HIGH_BIT_RATE,
output_format="raw",
)
snk = blocks.message_debug()
tb.connect(src, rx)
tb.msg_connect(rx, "frames", snk, "store")
tb.run()
return snk
def receive_voice(signal_data, sample_rate, audio_rate=8000):
"""Run the voice receive chain and return recovered audio samples."""
tb = gr.top_block()
src = blocks.vector_source_c(signal_data.tolist())
pm = pm_demod(sample_rate=sample_rate)
voice = voice_subcarrier_demod(sample_rate=sample_rate, audio_rate=audio_rate)
snk = blocks.vector_sink_f()
tb.connect(src, pm, voice, snk)
tb.run()
return np.array(snk.data(), dtype=np.float32)
def process_clip(clip_name, clip_path, sample_rate, audio_rate, snr_db):
"""Process a single audio clip through the full TX/RX chain.
Returns a dict with stats about the processing.
"""
print(f" Loading: {clip_path}")
audio_upsampled, duration, _ = load_and_upsample_audio(clip_path, sample_rate)
print(f" Duration: {duration:.2f}s, {len(audio_upsampled):,} baseband samples")
# Calculate frame timing
bits_per_frame = PCM_HIGH_WORDS_PER_FRAME * PCM_WORD_LENGTH
samples_per_frame = int(bits_per_frame * sample_rate / PCM_HIGH_BIT_RATE)
n_frames = int(duration * 50) + 2 # 50 fps + margin
n_samples = min(len(audio_upsampled), n_frames * samples_per_frame)
snr_desc = f"{snr_db} dB" if snr_db is not None else "clean"
print(f" TX: {n_samples:,} samples, ~{n_frames} PCM frames, SNR={snr_desc}")
# === TRANSMIT ===
t0 = time.time()
signal = build_tx_signal(audio_upsampled, n_samples, sample_rate, snr_db)
t_tx = time.time() - t0
print(f" TX complete: {len(signal):,} complex samples ({t_tx:.1f}s)")
# === RECEIVE: PCM ===
t0 = time.time()
frame_sink = receive_pcm(signal, sample_rate)
t_pcm = time.time() - t0
n_recovered_frames = frame_sink.num_messages()
print(f" RX PCM: {n_recovered_frames} frames recovered ({t_pcm:.1f}s)")
# === RECEIVE: Voice ===
t0 = time.time()
recovered_audio = receive_voice(signal, sample_rate, audio_rate)
t_voice = time.time() - t0
recovered_duration = len(recovered_audio) / audio_rate
print(
f" RX voice: {len(recovered_audio):,} samples,"
f" {recovered_duration:.2f}s ({t_voice:.1f}s)"
)
# Normalize and save recovered audio
output_path = os.path.join(AUDIO_DIR, f"apollo11_{clip_name}_recovered.wav")
peak = np.max(np.abs(recovered_audio))
if peak > 0:
recovered_audio = recovered_audio / peak * 0.9
recovered_int16 = (recovered_audio * 32767).astype(np.int16)
wavfile.write(output_path, audio_rate, recovered_int16)
print(f" Saved: {output_path}")
return {
"clip_name": clip_name,
"input_path": clip_path,
"output_path": output_path,
"input_duration": duration,
"recovered_duration": recovered_duration,
"pcm_frames": n_recovered_frames,
"expected_frames": n_frames,
"snr": snr_desc,
"time_tx": t_tx,
"time_pcm": t_pcm,
"time_voice": t_voice,
}
def main():
parser = argparse.ArgumentParser(
description="Process real Apollo audio through the full USB downlink chain."
)
parser.add_argument(
"--clip",
metavar="NAME",
default=None,
help="Process a specific clip (default: first discovered)",
)
parser.add_argument(
"--snr",
type=float,
default=None,
help="Add AWGN noise at this SNR in dB",
)
parser.add_argument(
"--play",
action="store_true",
help="Play recovered audio with aplay after processing",
)
args = parser.parse_args()
sample_rate = int(SAMPLE_RATE_BASEBAND)
audio_rate = 8000
print("=" * 60)
print("Apollo Real Signal Demo")
print(" Full USB downlink: PCM telemetry + crew voice")
print("=" * 60)
print()
# Discover available clips
clips = discover_clips()
if not clips:
# Fall back to the bundled demo clip
if os.path.exists(FALLBACK_CLIP):
print(" No downloaded clips found. Using bundled demo clip.")
clips = {"crew": FALLBACK_CLIP}
else:
print("No audio files found in examples/audio/.", file=sys.stderr)
print("Run fetch_apollo_audio.py first:", file=sys.stderr)
print(" uv run python examples/fetch_apollo_audio.py --all", file=sys.stderr)
sys.exit(1)
print(f" Found {len(clips)} clip(s): {', '.join(clips.keys())}")
print()
# Select which clip to process
if args.clip:
if args.clip not in clips:
print(f"Clip not found: {args.clip}", file=sys.stderr)
print(f"Available: {', '.join(clips.keys())}", file=sys.stderr)
sys.exit(1)
selected_name = args.clip
else:
selected_name = next(iter(clips))
selected_path = clips[selected_name]
print(f"Processing: {selected_name}")
print("-" * 60)
stats = process_clip(selected_name, selected_path, sample_rate, audio_rate, args.snr)
# === SUMMARY ===
print()
print("=" * 60)
print("Summary")
print("=" * 60)
print(f" Clip: {stats['clip_name']}")
print(f" Input duration: {stats['input_duration']:.2f}s")
print(f" Recovered audio: {stats['recovered_duration']:.2f}s")
pcm_f = stats['pcm_frames']
exp_f = stats['expected_frames']
print(f" PCM frames: {pcm_f} recovered (expected ~{exp_f})")
print(f" SNR: {stats['snr']}")
t_tx = stats['time_tx']
t_pcm = stats['time_pcm']
t_voice = stats['time_voice']
print(
f" Processing time: TX={t_tx:.1f}s"
f" PCM-RX={t_pcm:.1f}s Voice-RX={t_voice:.1f}s"
)
print(f" Output: {stats['output_path']}")
print("=" * 60)
if args.play:
import subprocess
print()
print("Playing recovered audio...")
subprocess.run(["aplay", stats["output_path"]], check=False)
else:
print()
print(f"Play recovered: aplay {stats['output_path']}")
if __name__ == "__main__":
main()