Merge feature/audio-downloads: Apollo recording download + real signal demo
This commit is contained in:
commit
c117f49fd2
17
.gitignore
vendored
Normal file
17
.gitignore
vendored
Normal file
@ -0,0 +1,17 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
|
||||
# Audio files (large, downloaded on demand)
|
||||
examples/audio/*.wav
|
||||
examples/audio/*.flac
|
||||
# Keep the existing small demo clip
|
||||
!examples/audio/apollo11_crew.wav
|
||||
|
||||
# Environment
|
||||
.env
|
||||
*.env.local
|
||||
node_modules/
|
||||
289
examples/fetch_apollo_audio.py
Executable file
289
examples/fetch_apollo_audio.py
Executable file
@ -0,0 +1,289 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Fetch Apollo audio recordings from Archive.org.
|
||||
|
||||
Downloads the Apollo 11 audio highlights compilation and extracts individual
|
||||
clips using ffmpeg. Source material is from the Internet Archive's Apollo 11
|
||||
collection.
|
||||
|
||||
Clips are saved as 48 kHz mono WAV files in examples/audio/ for use with
|
||||
the gr-apollo signal processing demos.
|
||||
|
||||
Usage:
|
||||
uv run python examples/fetch_apollo_audio.py --list
|
||||
uv run python examples/fetch_apollo_audio.py --all
|
||||
uv run python examples/fetch_apollo_audio.py --clip eagle_has_landed
|
||||
uv run python examples/fetch_apollo_audio.py --clip liftoff --force
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import urllib.request
|
||||
|
||||
# Output directory: examples/audio/ relative to this script
|
||||
AUDIO_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "audio")
|
||||
|
||||
# Source FLAC from the Internet Archive
|
||||
FLAC_URL = "https://archive.org/download/Apollo11AudioHighlights/Apollo11Highlights.flac"
|
||||
FLAC_FILENAME = "Apollo11Highlights.flac"
|
||||
|
||||
# Clip definitions -- timestamps are approximate offsets into the highlights reel.
|
||||
# The important thing is having a working extraction pipeline; timestamps can be
|
||||
# refined once someone listens through the actual source file.
|
||||
CLIPS = {
|
||||
"liftoff": {
|
||||
"start": "00:00:05",
|
||||
"duration": "00:00:30",
|
||||
"description": "Apollo 11 liftoff",
|
||||
},
|
||||
"eagle_has_landed": {
|
||||
"start": "00:06:45",
|
||||
"duration": "00:00:30",
|
||||
"description": "The Eagle has landed",
|
||||
},
|
||||
"one_small_step": {
|
||||
"start": "00:15:30",
|
||||
"duration": "00:00:25",
|
||||
"description": "One small step for man",
|
||||
},
|
||||
"houston_problem": {
|
||||
"start": "00:20:00",
|
||||
"duration": "00:00:15",
|
||||
"description": "Houston, we've had a problem",
|
||||
},
|
||||
"splashdown": {
|
||||
"start": "00:42:00",
|
||||
"duration": "00:00:20",
|
||||
"description": "Splashdown",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def check_ffmpeg():
|
||||
"""Verify ffmpeg is available on PATH."""
|
||||
if shutil.which("ffmpeg") is None:
|
||||
print("ERROR: ffmpeg not found on PATH.", file=sys.stderr)
|
||||
print("Install it with your package manager:", file=sys.stderr)
|
||||
print(" Arch: pacman -S ffmpeg", file=sys.stderr)
|
||||
print(" Debian: apt install ffmpeg", file=sys.stderr)
|
||||
print(" macOS: brew install ffmpeg", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def _progress_hook(block_num, block_size, total_size):
|
||||
"""Report download progress to stderr."""
|
||||
downloaded = block_num * block_size
|
||||
if total_size > 0:
|
||||
pct = min(100.0, downloaded * 100.0 / total_size)
|
||||
mb_down = downloaded / (1024 * 1024)
|
||||
mb_total = total_size / (1024 * 1024)
|
||||
bar_width = 40
|
||||
filled = int(bar_width * pct / 100.0)
|
||||
bar = "#" * filled + "-" * (bar_width - filled)
|
||||
sys.stderr.write(f"\r [{bar}] {pct:5.1f}% {mb_down:.1f}/{mb_total:.1f} MB")
|
||||
sys.stderr.flush()
|
||||
else:
|
||||
mb_down = downloaded / (1024 * 1024)
|
||||
sys.stderr.write(f"\r Downloaded {mb_down:.1f} MB (unknown total)")
|
||||
sys.stderr.flush()
|
||||
|
||||
|
||||
def download_flac(output_dir, force=False):
|
||||
"""Download the FLAC source file with progress reporting.
|
||||
|
||||
Returns the path to the downloaded file, or None on failure.
|
||||
"""
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
flac_path = os.path.join(output_dir, FLAC_FILENAME)
|
||||
|
||||
if os.path.exists(flac_path) and not force:
|
||||
size_mb = os.path.getsize(flac_path) / (1024 * 1024)
|
||||
print(f" FLAC already exists: {flac_path} ({size_mb:.1f} MB)")
|
||||
print(" Use --force to re-download.")
|
||||
return flac_path
|
||||
|
||||
print(f" Downloading: {FLAC_URL}")
|
||||
print(f" Saving to: {flac_path}")
|
||||
print()
|
||||
|
||||
try:
|
||||
urllib.request.urlretrieve(FLAC_URL, flac_path, reporthook=_progress_hook)
|
||||
sys.stderr.write("\n")
|
||||
sys.stderr.flush()
|
||||
except (urllib.error.URLError, OSError) as exc:
|
||||
print(f"\n Download failed: {exc}", file=sys.stderr)
|
||||
# Clean up partial file
|
||||
if os.path.exists(flac_path):
|
||||
os.remove(flac_path)
|
||||
return None
|
||||
|
||||
size_mb = os.path.getsize(flac_path) / (1024 * 1024)
|
||||
print(f" Downloaded {size_mb:.1f} MB")
|
||||
return flac_path
|
||||
|
||||
|
||||
def extract_clip(flac_path, clip_name, clip_info, output_dir, force=False):
|
||||
"""Extract a clip segment from the FLAC source using ffmpeg.
|
||||
|
||||
Outputs a 48 kHz mono WAV file.
|
||||
Returns True on success, False on failure.
|
||||
"""
|
||||
out_path = os.path.join(output_dir, f"apollo11_{clip_name}.wav")
|
||||
|
||||
if os.path.exists(out_path) and not force:
|
||||
print(f" [{clip_name}] Already exists: {out_path}")
|
||||
return True
|
||||
|
||||
cmd = [
|
||||
"ffmpeg",
|
||||
"-y", # overwrite without asking
|
||||
"-ss", clip_info["start"], # seek to start
|
||||
"-t", clip_info["duration"], # extract duration
|
||||
"-i", flac_path, # input
|
||||
"-ac", "1", # mono
|
||||
"-ar", "48000", # 48 kHz
|
||||
"-sample_fmt", "s16", # 16-bit
|
||||
out_path,
|
||||
]
|
||||
|
||||
print(f" [{clip_name}] Extracting: {clip_info['description']}")
|
||||
print(f" start={clip_info['start']} duration={clip_info['duration']}")
|
||||
|
||||
result = subprocess.run(cmd, capture_output=True)
|
||||
|
||||
if result.returncode != 0:
|
||||
print(f" [{clip_name}] ffmpeg failed (exit {result.returncode}):", file=sys.stderr)
|
||||
stderr_text = result.stderr.decode("utf-8", errors="replace")
|
||||
# Print last few lines of ffmpeg output for diagnostics
|
||||
for line in stderr_text.strip().splitlines()[-5:]:
|
||||
print(f" {line}", file=sys.stderr)
|
||||
return False
|
||||
|
||||
size_kb = os.path.getsize(out_path) / 1024
|
||||
print(f" -> {out_path} ({size_kb:.0f} KB)")
|
||||
return True
|
||||
|
||||
|
||||
def list_clips():
|
||||
"""Print available clip names and descriptions."""
|
||||
print("Available clips:")
|
||||
print()
|
||||
max_name = max(len(n) for n in CLIPS)
|
||||
for name, info in CLIPS.items():
|
||||
print(f" {name:<{max_name}} {info['start']} ({info['duration']}) {info['description']}")
|
||||
print()
|
||||
print(f" {len(CLIPS)} clips defined.")
|
||||
print(" Extract with: --clip NAME or --all")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Fetch Apollo 11 audio from Archive.org and extract clips.",
|
||||
epilog="Clips are saved as 48 kHz mono WAV in examples/audio/.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--list",
|
||||
action="store_true",
|
||||
help="List available clip names and timestamps",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--clip",
|
||||
metavar="NAME",
|
||||
help="Extract a specific clip by name",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--all",
|
||||
action="store_true",
|
||||
help="Extract all defined clips",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--keep-flac",
|
||||
action="store_true",
|
||||
help="Keep the downloaded FLAC file after extraction",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--force",
|
||||
action="store_true",
|
||||
help="Re-download and re-extract even if files already exist",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output-dir",
|
||||
default=AUDIO_DIR,
|
||||
help=f"Output directory (default: {AUDIO_DIR})",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
# --list doesn't need ffmpeg
|
||||
if args.list:
|
||||
list_clips()
|
||||
return
|
||||
|
||||
# Everything else requires ffmpeg
|
||||
check_ffmpeg()
|
||||
|
||||
# Validate arguments
|
||||
if not args.clip and not args.all:
|
||||
parser.print_help()
|
||||
print()
|
||||
print("Specify --clip NAME, --all, or --list.")
|
||||
sys.exit(1)
|
||||
|
||||
if args.clip and args.clip not in CLIPS:
|
||||
print(f"Unknown clip: {args.clip}", file=sys.stderr)
|
||||
print(f"Available: {', '.join(CLIPS.keys())}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Determine which clips to extract
|
||||
clip_names = list(CLIPS.keys()) if args.all else [args.clip]
|
||||
|
||||
print("=" * 60)
|
||||
print("Apollo 11 Audio Fetch")
|
||||
print("=" * 60)
|
||||
print()
|
||||
|
||||
# Download the source FLAC
|
||||
print("Step 1: Download source FLAC")
|
||||
flac_path = download_flac(args.output_dir, force=args.force)
|
||||
if flac_path is None:
|
||||
sys.exit(1)
|
||||
print()
|
||||
|
||||
# Extract clips
|
||||
print(f"Step 2: Extract {len(clip_names)} clip(s)")
|
||||
print()
|
||||
|
||||
ok_count = 0
|
||||
fail_count = 0
|
||||
for name in clip_names:
|
||||
success = extract_clip(flac_path, name, CLIPS[name], args.output_dir, force=args.force)
|
||||
if success:
|
||||
ok_count += 1
|
||||
else:
|
||||
fail_count += 1
|
||||
print()
|
||||
|
||||
# Clean up FLAC unless --keep-flac
|
||||
if not args.keep_flac and os.path.exists(flac_path):
|
||||
size_mb = os.path.getsize(flac_path) / (1024 * 1024)
|
||||
os.remove(flac_path)
|
||||
print(f"Removed source FLAC ({size_mb:.1f} MB). Use --keep-flac to retain.")
|
||||
elif args.keep_flac and os.path.exists(flac_path):
|
||||
print(f"Kept source FLAC: {flac_path}")
|
||||
print()
|
||||
|
||||
# Summary
|
||||
print("=" * 60)
|
||||
print(f" Extracted: {ok_count} Failed: {fail_count}")
|
||||
if ok_count > 0:
|
||||
print(f" Output: {args.output_dir}/apollo11_*.wav")
|
||||
print("=" * 60)
|
||||
|
||||
if fail_count > 0:
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
379
examples/real_signal_demo.py
Executable file
379
examples/real_signal_demo.py
Executable file
@ -0,0 +1,379 @@
|
||||
#!/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()
|
||||
Loading…
x
Reference in New Issue
Block a user