diff --git a/examples/full_downlink_demo.py b/examples/full_downlink_demo.py new file mode 100644 index 0000000..b685ac3 --- /dev/null +++ b/examples/full_downlink_demo.py @@ -0,0 +1,295 @@ +#!/usr/bin/env python3 +""" +Apollo Full Downlink Demo -- PCM telemetry + crew voice on one carrier. + +Reconstructs the complete Apollo USB downlink signal: PCM telemetry frames +on the 1.024 MHz BPSK subcarrier PLUS crew voice on the 1.25 MHz FM +subcarrier, both phase-modulated onto a single complex carrier. + +Then receives the signal, splitting it into: + - Decoded PCM telemetry frames (digital data) + - Recovered crew voice audio (saved as WAV) + +This is the full spacecraft-to-ground communications path: + + TX (spacecraft): + pcm_frame_source -> nrz -> bpsk_mod (1.024 MHz) ─┐ + crew_audio -> fm_voice_mod (1.25 MHz, +/-29kHz) ──┤ scale 1.68/2.2 + ├─> add -> pm_mod -> [RF] + + RX (ground station): + [RF] -> pm_demod ──> subcarrier_extract -> bpsk_demod -> frame_sync ──> PCM frames + └─> voice_subcarrier_demod ──> crew audio (8 kHz) + +Usage: + uv run python examples/full_downlink_demo.py examples/audio/apollo11_crew.wav + uv run python examples/full_downlink_demo.py input.wav --snr 25 --play +""" + +import argparse +import time +from math import gcd + +import numpy as np +import pmt +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_demux import DemuxEngine +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 + + +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 + else: + audio_float = audio_data.astype(np.float32) + + duration = len(audio_float) / input_rate + + # Resample to 8 kHz first + 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. + + Assembles the individual blocks manually (not using usb_signal_source) + 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 (external 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 decoded frames.""" + 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 main(): + parser = argparse.ArgumentParser(description="Full Apollo downlink: PCM telemetry + crew voice") + parser.add_argument("audio", help="Input audio WAV file (crew voice)") + parser.add_argument( + "--output", "-o", default=None, help="Output WAV path (default: _fullchain.wav)" + ) + 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 voice with aplay") + args = parser.parse_args() + + if args.output is None: + stem = args.audio.rsplit(".", 1)[0] + args.output = f"{stem}_fullchain.wav" + + sample_rate = int(SAMPLE_RATE_BASEBAND) + audio_rate = 8000 + + print("=" * 60) + print("Apollo Full Downlink Demo") + print(" PCM telemetry (1.024 MHz BPSK) + crew voice (1.25 MHz FM)") + print("=" * 60) + print() + + # Load and prepare audio + print("Loading crew voice audio...") + audio_upsampled, duration, _ = load_and_upsample_audio(args.audio, sample_rate) + print(f" Source: {args.audio} ({duration:.2f}s)") + print(f" Upsampled: {len(audio_upsampled):,} samples at {sample_rate / 1e6:.2f} MHz") + print() + + # 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) + + print(f" PCM frames: ~{n_frames} at 50 fps") + print(f" Signal: {n_samples:,} samples ({n_samples / sample_rate:.2f}s)") + snr_desc = f"{args.snr} dB" if args.snr is not None else "clean" + print(f" SNR: {snr_desc}") + print() + + # === TRANSMIT === + print("TX: Building combined PCM + voice signal...") + t0 = time.time() + signal = build_tx_signal(audio_upsampled, n_samples, sample_rate, args.snr) + t_tx = time.time() - t0 + print(f" Generated {len(signal):,} complex samples ({t_tx:.1f}s)") + + # Verify constant envelope (PM property) + envelope = np.abs(signal[:10000]) + if args.snr is None: + env_std = np.std(envelope) + print(f" PM envelope std: {env_std:.6f} (should be ~0 for clean)") + print() + + # === RECEIVE: PCM telemetry === + print("RX: Decoding PCM telemetry frames...") + t0 = time.time() + frame_sink = receive_pcm(signal, sample_rate) + t_pcm = time.time() - t0 + n_recovered = frame_sink.num_messages() + print(f" Recovered {n_recovered} PCM frames ({t_pcm:.1f}s)") + + if n_recovered > 0: + demux = DemuxEngine(output_format="raw") + print() + for i in range(min(n_recovered, 5)): + msg = frame_sink.get_message(i) + payload = pmt.cdr(msg) if pmt.is_pair(msg) else msg + frame_bytes = bytes(pmt.u8vector_elements(payload)) + result = demux.process_frame(frame_bytes) + fid = result.get("sync", {}).get("frame_id", 0) + n_words = len(result.get("words", [])) + parity = "odd" if fid % 2 == 1 else "even" + print(f" Frame {i + 1}: ID={fid:>2} ({parity}), {n_words} data words") + if n_recovered > 5: + print(f" ... ({n_recovered - 5} more frames)") + print() + + # === RECEIVE: crew voice === + print("RX: Demodulating crew voice (1.25 MHz FM)...") + t0 = time.time() + recovered_audio = receive_voice(signal, sample_rate, audio_rate) + t_voice = time.time() - t0 + print(f" Recovered {len(recovered_audio):,} audio samples ({t_voice:.1f}s)") + print(f" Duration: {len(recovered_audio) / audio_rate:.2f}s at {audio_rate} Hz") + + # Normalize and save + 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(args.output, audio_rate, recovered_int16) + print(f" Saved: {args.output}") + print() + + # === SUMMARY === + print("=" * 60) + print(f" TX: {n_samples / sample_rate:.2f}s of combined PCM + voice") + print(f" RX: {n_recovered} PCM frames + {len(recovered_audio) / audio_rate:.2f}s crew voice") + print(f" SNR: {snr_desc}") + print("=" * 60) + + if args.play: + import subprocess + + print() + print("Playing recovered crew voice...") + subprocess.run(["aplay", args.output], check=False) + else: + print() + print(f"Play voice: aplay {args.output}") + + +if __name__ == "__main__": + main()