Add full downlink demo: PCM telemetry + crew voice on one carrier
Assembles the complete Apollo USB downlink signal from individual blocks: PCM frames on 1.024 MHz BPSK + crew voice on 1.25 MHz FM, both PM-modulated onto a single complex carrier. Receives and splits into decoded PCM frames and recovered voice audio. Clean: 399/402 frames, 8s voice. At 25 dB SNR: 395/402 frames.
This commit is contained in:
parent
8728d36a90
commit
cb77b18a9c
295
examples/full_downlink_demo.py
Normal file
295
examples/full_downlink_demo.py
Normal file
@ -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: <input>_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()
|
||||||
Loading…
x
Reference in New Issue
Block a user