#!/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()