gr-apollo/examples/fetch_apollo_audio.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

290 lines
9.0 KiB
Python
Executable File

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