Skip to content

Decode Voice Audio

The Apollo Unified S-Band downlink carries a voice channel on a 1.25 MHz FM subcarrier alongside the PCM telemetry. The voice_subcarrier_demod block extracts this subcarrier and recovers 300—3000 Hz telephone-quality audio suitable for playback or recording.

ParameterValue
Subcarrier frequency1.25 MHz (FM modulated)
FM deviation+/-29 kHz
Audio bandwidth300—3000 Hz
Default output sample rate8000 Hz
Modulation modePM downlink only (not FM mode)

On the spacecraft, the audio path runs through an FM VCO at 113 kHz, then a balanced mixer with the 512 kHz master clock, a bandpass filter, and a frequency doubler to produce the 1.25 MHz subcarrier. The receiver reverses this process: extract the subcarrier, apply an FM discriminator, and bandpass-filter the resulting audio.

The simplest approach uses pm_demod to recover the composite modulating signal, then voice_subcarrier_demod to extract the audio.

from gnuradio import audio, blocks, gr
from apollo.constants import SAMPLE_RATE_BASEBAND
from apollo.pm_demod import pm_demod
from apollo.voice_subcarrier_demod import voice_subcarrier_demod
tb = gr.top_block()
# Source: complex baseband from SDR or file
src = blocks.file_source(
gr.sizeof_gr_complex,
"/path/to/apollo_baseband.cf32",
repeat=False,
)
# Stage 1: PM demodulator -- carrier PLL + phase extraction
pm = pm_demod(carrier_pll_bw=0.02, sample_rate=SAMPLE_RATE_BASEBAND)
# Stage 2: Voice subcarrier demod -- extract 1.25 MHz FM, output 8 kHz audio
voice = voice_subcarrier_demod(
sample_rate=SAMPLE_RATE_BASEBAND,
audio_rate=8000,
)
# Stage 3: Audio sink for live playback
audio_sink = audio.sink(8000)
tb.connect(src, pm, voice, audio_sink)
tb.run()

To save the decoded audio to a file instead of playing it live:

from gnuradio import blocks, gr
from apollo.constants import SAMPLE_RATE_BASEBAND
from apollo.pm_demod import pm_demod
from apollo.voice_subcarrier_demod import voice_subcarrier_demod
tb = gr.top_block()
src = blocks.file_source(
gr.sizeof_gr_complex,
"/path/to/apollo_baseband.cf32",
repeat=False,
)
pm = pm_demod(carrier_pll_bw=0.02, sample_rate=SAMPLE_RATE_BASEBAND)
voice = voice_subcarrier_demod(
sample_rate=SAMPLE_RATE_BASEBAND,
audio_rate=8000,
)
# WAV file sink -- 1 channel, 8 kHz, 16-bit PCM
wav_sink = blocks.wavfile_sink(
"/path/to/apollo_voice.wav",
1, # channels
8000, # sample rate
blocks.FORMAT_WAV,
blocks.FORMAT_PCM_16,
)
tb.connect(src, pm, voice, wav_sink)
tb.run()

To test the voice demod without a real recording, generate a signal with the voice subcarrier enabled:

from gnuradio import blocks, gr
from apollo.constants import SAMPLE_RATE_BASEBAND
from apollo.pm_demod import pm_demod
from apollo.usb_signal_gen import generate_usb_baseband
from apollo.voice_subcarrier_demod import voice_subcarrier_demod
# Generate 100 frames (~2 seconds) with a 1 kHz voice tone
signal, _ = generate_usb_baseband(
frames=100,
voice_enabled=True,
voice_tone_hz=1000.0,
snr_db=30.0,
)
tb = gr.top_block()
src = blocks.vector_source_c(signal.tolist())
pm = pm_demod(sample_rate=SAMPLE_RATE_BASEBAND)
voice = voice_subcarrier_demod(sample_rate=SAMPLE_RATE_BASEBAND, audio_rate=8000)
wav_sink = blocks.wavfile_sink(
"test_voice_output.wav", 1, 8000,
blocks.FORMAT_WAV, blocks.FORMAT_PCM_16,
)
tb.connect(src, pm, voice, wav_sink)
tb.run()
print("Wrote test_voice_output.wav")

The output WAV file should contain a clean 1 kHz tone.

The voice_subcarrier_demod block is a hierarchical block that chains four stages internally:

flowchart LR
    A["PM Demod<br/>Output (float)"] --> B["Subcarrier Extract<br/>BPF 1.25 MHz<br/>BW = 58 kHz<br/>Decimation ~40x"]
    B --> C["Quadrature Demod<br/>FM discriminator"]
    C --> D["Audio BPF<br/>300-3000 Hz"]
    D --> E["Rational Resampler<br/>to 8000 Hz"]
    E --> F["Audio<br/>Output (float)"]

The aggressive decimation in stage 1 (from 5.12 MHz down to ~128 kHz) reduces the processing load before the FM discriminator runs. The audio bandpass removes DC offset from the discriminator and any noise above 3 kHz.

The voice_subcarrier_demod constructor accepts two parameters:

ParameterDefaultDescription
sample_rate5,120,000Input sample rate from PM demodulator
audio_rate8000Output audio sample rate in Hz

The subcarrier frequency (1.25 MHz), FM deviation (+/-29 kHz), and audio bandwidth (300—3000 Hz) are fixed to the Apollo specification and are not configurable.

You can query the actual output rate and intermediate rate at runtime:

voice = voice_subcarrier_demod(sample_rate=5_120_000, audio_rate=8000)
print(f"Output rate: {voice.output_sample_rate} Hz") # 8000.0
print(f"Extracted rate: {voice.extracted_rate} Hz") # ~128000.0

Since the voice and PCM subcarriers occupy different frequency bands, you can decode both simultaneously by splitting the PM demodulator output:

from gnuradio import blocks, gr
from apollo.constants import SAMPLE_RATE_BASEBAND
from apollo.pm_demod import pm_demod
from apollo.usb_downlink_receiver import usb_downlink_receiver
from apollo.voice_subcarrier_demod import voice_subcarrier_demod
tb = gr.top_block()
src = blocks.file_source(gr.sizeof_gr_complex, "recording.cf32", repeat=False)
# Shared PM demod
pm = pm_demod(sample_rate=SAMPLE_RATE_BASEBAND)
# PCM telemetry path (uses the full receiver for convenience)
# Note: usb_downlink_receiver has its own PM demod internally,
# so for combined use, build the chain with individual blocks.
from apollo.bpsk_subcarrier_demod import bpsk_subcarrier_demod
from apollo.pcm_demux import pcm_demux
from apollo.pcm_frame_sync import pcm_frame_sync
bpsk = bpsk_subcarrier_demod(sample_rate=SAMPLE_RATE_BASEBAND)
fsync = pcm_frame_sync(bit_rate=51200)
demux = pcm_demux(output_format="scaled")
# Voice path
voice = voice_subcarrier_demod(sample_rate=SAMPLE_RATE_BASEBAND)
wav = blocks.wavfile_sink("voice.wav", 1, 8000, blocks.FORMAT_WAV, blocks.FORMAT_PCM_16)
# Frame output
frame_sink = blocks.message_debug()
# Connect shared PM demod
tb.connect(src, pm)
# Split PM output to both paths
tb.connect(pm, bpsk, fsync)
tb.connect(pm, voice, wav)
# Message connections for telemetry
tb.msg_connect(fsync, "frames", demux, "frames")
tb.msg_connect(fsync, "frames", frame_sink, "store")
tb.run()
print(f"Decoded {frame_sink.num_messages()} PCM frames")
print("Wrote voice.wav")

The transmit-side counterpart to voice_subcarrier_demod is fm_voice_subcarrier_mod. It FM-modulates audio onto the 1.25 MHz subcarrier — the exact inverse of the receive chain.

For testing without an audio source, the modulator generates a sine tone internally:

from apollo.fm_voice_subcarrier_mod import fm_voice_subcarrier_mod
# Source block — no input needed
voice_mod = fm_voice_subcarrier_mod(tone_freq=1000.0)

To modulate real audio (like Apollo mission crew recordings), set audio_input=True. This changes the block from a source to a filter that accepts a float input:

from gnuradio import blocks, gr
from apollo.fm_voice_subcarrier_mod import fm_voice_subcarrier_mod
voice_mod = fm_voice_subcarrier_mod(audio_input=True)
# Audio must be upsampled to the baseband rate (5.12 MHz)
# before feeding into the modulator.
# See examples/voice_subcarrier_demo.py for the full pipeline.

The recovered audio has telephone-grade quality (300—3000 Hz, 8 kHz sample rate). This matches the original system design — the Apollo voice link was optimized for intelligibility, not high fidelity. Expect the following characteristics:

  • No low-frequency content below 300 Hz (filtered by design)
  • No high-frequency content above 3 kHz (filtered by design)
  • FM noise floor depends on the input signal SNR
  • The 200 Hz transition bands in the audio BPF may cause slight rolloff near the band edges

For the best audio quality from a noisy recording, use a narrower carrier PLL bandwidth (0.01) in the PM demod to minimize phase noise that propagates into the voice demodulator.