gr-apollo/src/apollo/fm_voice_subcarrier_mod.py
Ryan Malloy cd3a8cc6be Add SCO modulator, external audio input, and demo scripts
- sco_mod: 9-channel FM subcarrier oscillator modulator (inverse of sco_demod),
  with round-trip tests proving voltage recovery across all channels
- fm_voice_subcarrier_mod: add audio_input parameter to accept external float
  streams (e.g., Apollo mission voice recordings) instead of internal test tone
- loopback_demo.py: streaming TX->RX round-trip with CLI for noise/voice/frames
- agc_loopback_demo.py: full Virtual AGC integration via TCP bridge
2026-02-22 13:01:48 -07:00

116 lines
3.8 KiB
Python

"""
Apollo FM Voice Subcarrier Modulator -- 1.25 MHz FM with internal tone or external audio.
Transmit-side counterpart to voice_subcarrier_demod. FM-modulates audio onto a
1.25 MHz subcarrier and outputs the real-valued subcarrier signal.
Two modes of operation:
- Internal test tone (default): generates a sine wave for testing.
- External audio input: accepts a float stream (e.g., Apollo mission voice
recordings) and modulates it onto the subcarrier.
On the real spacecraft, voice audio (300-3000 Hz) drives an FM VCO at 113 kHz,
which is mixed with the 512 kHz master clock and doubled to produce the
1.25 MHz FM subcarrier with +/-29 kHz deviation.
Reference: IMPLEMENTATION_SPEC.md section 4.2
"""
import math
from gnuradio import analog, blocks, gr
from apollo.constants import (
SAMPLE_RATE_BASEBAND,
VOICE_FM_DEVIATION_HZ,
VOICE_SUBCARRIER_HZ,
)
class fm_voice_subcarrier_mod(gr.hier_block2):
"""FM-modulated voice subcarrier (1.25 MHz) with internal test tone or external audio.
Outputs:
float -- real-valued FM subcarrier at subcarrier_freq
Inputs (when audio_input=True):
float -- external audio signal (e.g., mission voice recordings)
When audio_input=False (default), an internal sine test tone is used.
When audio_input=True, the block accepts an external float stream -- for
example, actual Apollo mission crew voice recordings.
"""
def __init__(
self,
sample_rate: float = SAMPLE_RATE_BASEBAND,
subcarrier_freq: float = VOICE_SUBCARRIER_HZ,
fm_deviation: float = VOICE_FM_DEVIATION_HZ,
tone_freq: float = 1000.0,
audio_input: bool = False,
):
# Choose input signature based on mode
in_sig = gr.io_signature(1, 1, gr.sizeof_float) if audio_input else gr.io_signature(0, 0, 0)
gr.hier_block2.__init__(
self,
"apollo_fm_voice_subcarrier_mod",
in_sig,
gr.io_signature(1, 1, gr.sizeof_float),
)
self._sample_rate = sample_rate
self._tone_freq = tone_freq
self._subcarrier_freq = subcarrier_freq
self._fm_deviation = fm_deviation
self._audio_input = audio_input
# FM modulator: sensitivity = 2*pi*deviation/sample_rate rad/sample
# With unit-amplitude sine input this gives +/-fm_deviation Hz.
fm_sensitivity = 2.0 * math.pi * fm_deviation / sample_rate
self.fm_mod = analog.frequency_modulator_fc(fm_sensitivity)
# LO at subcarrier frequency for upconversion
self.lo = analog.sig_source_c(
sample_rate, analog.GR_COS_WAVE, subcarrier_freq, 1.0, 0,
)
# Mixer: FM baseband x LO -> subcarrier
self.mixer = blocks.multiply_cc(1)
# Extract real part for float output
self.to_real = blocks.complex_to_real(1)
if audio_input:
# External audio input -> FM mod
self.connect(self, self.fm_mod, (self.mixer, 0))
else:
# Internal test tone -> FM mod
self.tone = analog.sig_source_f(
sample_rate, analog.GR_SIN_WAVE, tone_freq, 1.0, 0,
)
self.connect(self.tone, self.fm_mod, (self.mixer, 0))
self.connect(self.lo, (self.mixer, 1))
self.connect(self.mixer, self.to_real, self)
@property
def tone_freq(self) -> float:
"""Test tone frequency in Hz."""
return self._tone_freq
@property
def subcarrier_freq(self) -> float:
"""Subcarrier center frequency in Hz."""
return self._subcarrier_freq
@property
def fm_deviation(self) -> float:
"""FM deviation in Hz."""
return self._fm_deviation
@property
def audio_input(self) -> bool:
"""Whether the block accepts external audio input."""
return self._audio_input