Add TX chain docs: 3 new guides, 7 block entries, architecture section
New pages: - guides/transmit-signal: composable TX block walkthrough - guides/run-demos: loopback, voice, full downlink, AGC demos - guides/sco-modulation: FM-mode SCO round-trip examples Updated pages: - reference/blocks: Transmit Chain section with all 7 TX blocks - explanation/signal-architecture: TX diagram, Tabs, loopback note - guides/voice-audio: TX-side modulation section - guides/test-signals: streaming vs batch comparison - getting-started/quick-start: loopback example + TX LinkCard - index: updated tagline and feature card for TX+RX - astro.config: sidebar entries, updated site description
This commit is contained in:
parent
cb77b18a9c
commit
50060e48e9
@ -16,7 +16,7 @@ export default defineConfig({
|
||||
icon(),
|
||||
starlight({
|
||||
title: 'gr-apollo',
|
||||
description: 'Apollo Unified S-Band decoder for GNU Radio 3.10+',
|
||||
description: 'Apollo Unified S-Band transmitter and decoder for GNU Radio 3.10+',
|
||||
sidebar: [
|
||||
{
|
||||
label: 'Getting Started',
|
||||
@ -39,7 +39,10 @@ export default defineConfig({
|
||||
items: [
|
||||
{ label: 'Tune Demodulator Parameters', slug: 'guides/tuning-parameters' },
|
||||
{ label: 'Generate Test Signals', slug: 'guides/test-signals' },
|
||||
{ label: 'Build a Transmit Signal', slug: 'guides/transmit-signal' },
|
||||
{ label: 'Run the Demos', slug: 'guides/run-demos' },
|
||||
{ label: 'Decode Voice Audio', slug: 'guides/voice-audio' },
|
||||
{ label: 'Modulate SCO Channels', slug: 'guides/sco-modulation' },
|
||||
{ label: 'Connect to Virtual AGC', slug: 'guides/agc-bridge' },
|
||||
{ label: 'Work with PCM Telemetry', slug: 'guides/pcm-telemetry' },
|
||||
],
|
||||
|
||||
@ -173,3 +173,56 @@ The Apollo USB system maintains a coherent frequency relationship between uplink
|
||||
This coherent turnaround allows the ground station to measure the two-way Doppler shift with extreme precision -- the ratio is exact, so any frequency difference between transmitted uplink and received downlink is entirely due to spacecraft velocity. This is how NASA tracked the spacecraft's range rate to centimeter-per-second precision using 1960s technology.
|
||||
|
||||
gr-apollo does not implement the coherent turnaround (it is a receiver, not a transponder), but the frequency plan explains why the numbers are what they are. The 2287.5 MHz downlink frequency is not arbitrary -- it is locked to the uplink via a ratio that was carefully chosen to avoid ambiguities in the Doppler measurement.
|
||||
|
||||
## The transmit chain
|
||||
|
||||
gr-apollo's TX blocks mirror the receive path. Where the receiver disassembles the signal layer by layer, the transmitter builds it up. Each TX block has a direct RX counterpart, and the internal architecture reflects this symmetry:
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
A["PCM Frame Source<br/>32-bit sync<br/>128 words/frame"] --> B["NRZ Encoder<br/>0/1 → +1/-1<br/>100 samp/bit"]
|
||||
B --> C["BPSK Mod<br/>× cos(1.024 MHz)"]
|
||||
C --> D["Σ"]
|
||||
|
||||
E["Voice Mod<br/>FM → 1.25 MHz<br/>±29 kHz dev"] --> F["× 0.764"]
|
||||
F --> D
|
||||
|
||||
D --> G["PM Mod<br/>exp(j·φ)"]
|
||||
G --> H["Complex<br/>Baseband<br/>5.12 MHz"]
|
||||
|
||||
style A fill:#2d5016,stroke:#4a8c2a
|
||||
style B fill:#5c3a1a,stroke:#bd7a3a
|
||||
style C fill:#3a1a5c,stroke:#7a3abd
|
||||
style E fill:#2d5016,stroke:#4a8c2a
|
||||
style G fill:#1a3a5c,stroke:#3a7abd
|
||||
style H fill:#1a3a5c,stroke:#3a7abd
|
||||
```
|
||||
|
||||
<Tabs>
|
||||
<TabItem label="PCM Path">
|
||||
The PCM transmit path generates telemetry frames and modulates them onto the BPSK subcarrier:
|
||||
|
||||
1. **pcm_frame_source** -- Generates 128-word PCM frames with 32-bit sync words. Frame IDs cycle 1 through 50 (one subframe per second). Odd frames get a complemented sync core automatically. Dynamic payloads can be injected via the `frame_data` message port.
|
||||
2. **nrz_encoder** -- Converts the bit stream (byte values 0/1) to a float NRZ waveform (+1.0/-1.0). Each bit is repeated for 100 samples at the default 5.12 MHz rate.
|
||||
3. **bpsk_subcarrier_mod** -- Multiplies the NRZ data by a 1.024 MHz cosine carrier. This BPSK modulation flips the subcarrier phase 180 degrees on each bit transition -- the inverse of the Costas loop recovery in `bpsk_demod`.
|
||||
</TabItem>
|
||||
<TabItem label="Voice Path">
|
||||
The voice path produces an FM subcarrier that is summed with the PCM subcarrier:
|
||||
|
||||
- **fm_voice_subcarrier_mod** -- Two modes: an internal sine test tone (for testing) or external audio input (for real mission audio). The audio is FM-modulated at baseband and upconverted to 1.25 MHz. The voice level is scaled by 1.68/2.2 (approximately 0.764) relative to the PCM subcarrier, matching the spacecraft's Pre-Modulation Processor power allocation.
|
||||
|
||||
The voice and PCM subcarriers occupy non-overlapping frequency bands (1.024 MHz vs 1.25 MHz), so they can be linearly summed before phase modulation without interference.
|
||||
</TabItem>
|
||||
<TabItem label="Composite">
|
||||
After summing the subcarriers, the composite signal is phase-modulated onto a complex carrier:
|
||||
|
||||
- **pm_mod** -- Applies `exp(j * dev * m(t))` where `dev` is 0.133 rad and `m(t)` is the composite subcarrier signal. The output is a constant-envelope complex baseband signal. The small deviation ensures the modulation is linear (same 0.3% approximation that makes the receiver work).
|
||||
- **Optional AWGN** -- When `snr_db` is set, Gaussian noise is added to the complex output for realistic channel simulation.
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
The `usb_signal_source` hierarchical block wires the entire TX chain together as a convenience -- the transmit-side counterpart to `usb_downlink_receiver`. For scenarios that need finer control (like injecting external audio), the individual blocks can be assembled manually. The `full_downlink_demo.py` example shows this approach.
|
||||
|
||||
<Aside type="note">
|
||||
The TX and RX blocks are designed to be exact inverses. Connecting `usb_signal_source` directly to `usb_downlink_receiver` creates a perfect digital loopback -- the `loopback_demo.py` script demonstrates this, recovering transmitted frames with 100% fidelity in the clean (no noise) case.
|
||||
</Aside>
|
||||
|
||||
@ -200,9 +200,44 @@ flowchart TB
|
||||
The frame sync engine uses Hamming distance to tolerate up to 3 bit errors in the 26-bit static portion of the sync word. The remaining 6 bits encode the frame ID (1-50 within each 1-second subframe).
|
||||
</Aside>
|
||||
|
||||
## Streaming loopback
|
||||
|
||||
If you have GNU Radio installed, the streaming TX and RX blocks can be connected directly for a full round-trip test. This is the simplest way to verify the complete chain:
|
||||
|
||||
```python
|
||||
from gnuradio import blocks, gr
|
||||
|
||||
from apollo.usb_downlink_receiver import usb_downlink_receiver
|
||||
from apollo.usb_signal_source import usb_signal_source
|
||||
|
||||
tb = gr.top_block()
|
||||
|
||||
tx = usb_signal_source(voice_enabled=True, snr_db=30.0)
|
||||
head = blocks.head(gr.sizeof_gr_complex, 10 * 102400) # 10 frames
|
||||
rx = usb_downlink_receiver(output_format="raw")
|
||||
snk = blocks.message_debug()
|
||||
|
||||
tb.connect(tx, head, rx)
|
||||
tb.msg_connect(rx, "frames", snk, "store")
|
||||
tb.run()
|
||||
|
||||
print(f"Recovered {snk.num_messages()} frames")
|
||||
```
|
||||
|
||||
The `loopback_demo.py` script wraps this pattern with argument parsing and frame analysis. Run it with:
|
||||
|
||||
```bash
|
||||
uv run python examples/loopback_demo.py --frames 20 --voice
|
||||
```
|
||||
|
||||
## Next steps
|
||||
|
||||
<CardGrid>
|
||||
<LinkCard
|
||||
title="Build a Transmit Signal"
|
||||
description="Compose TX blocks into custom transmit chains."
|
||||
href="/guides/transmit-signal/"
|
||||
/>
|
||||
<LinkCard
|
||||
title="Signal Architecture"
|
||||
description="How the Apollo USB system works, from RF to bits."
|
||||
@ -218,6 +253,11 @@ The frame sync engine uses Hamming distance to tolerate up to 3 bit errors in th
|
||||
description="Detailed API for every gr-apollo block."
|
||||
href="/reference/blocks/"
|
||||
/>
|
||||
<LinkCard
|
||||
title="Run the Demos"
|
||||
description="Loopback, voice, full downlink, and AGC integration demos."
|
||||
href="/guides/run-demos/"
|
||||
/>
|
||||
<LinkCard
|
||||
title="Connect to Virtual AGC"
|
||||
description="Bridge decoded telemetry to the Apollo Guidance Computer emulator."
|
||||
|
||||
393
docs/src/content/docs/guides/run-demos.mdx
Normal file
393
docs/src/content/docs/guides/run-demos.mdx
Normal file
@ -0,0 +1,393 @@
|
||||
---
|
||||
title: "Run the Demos"
|
||||
description: "How to run gr-apollo's example scripts for loopback testing, voice modulation, full downlink simulation, and AGC integration."
|
||||
---
|
||||
|
||||
import { Steps, Aside, Tabs, TabItem, Code, LinkCard, CardGrid } from '@astrojs/starlight/components';
|
||||
|
||||
gr-apollo ships with four demo scripts that exercise different parts of the TX/RX chain. They progress from a self-contained streaming loopback to a full mission simulation with crew voice, and finally to live integration with the Virtual AGC emulator.
|
||||
|
||||
| Demo | Requires | What It Does |
|
||||
|------|----------|-------------|
|
||||
| `loopback_demo.py` | GNU Radio | Streaming TX to RX round-trip |
|
||||
| `voice_subcarrier_demo.py` | GNU Radio, scipy | Real audio through 1.25 MHz FM |
|
||||
| `full_downlink_demo.py` | GNU Radio, scipy | PCM telemetry + crew voice on one carrier |
|
||||
| `agc_loopback_demo.py` | yaAGC (no GR) | Live AGC telemetry over TCP |
|
||||
|
||||
## Prerequisites
|
||||
|
||||
<Aside type="note">
|
||||
All demos require gr-apollo installed in development mode:
|
||||
|
||||
```bash
|
||||
uv pip install -e .
|
||||
```
|
||||
|
||||
The loopback and voice demos need GNU Radio 3.10+. The voice and full-downlink demos also need `scipy` for audio resampling. The AGC demo needs only the pure-Python components (no GNU Radio) but requires a running yaAGC emulator.
|
||||
</Aside>
|
||||
|
||||
---
|
||||
|
||||
## Loopback
|
||||
|
||||
**Script:** `examples/loopback_demo.py`
|
||||
**Requires:** GNU Radio
|
||||
|
||||
The loopback demo connects `usb_signal_source` directly to `usb_downlink_receiver` through the GNU Radio scheduler. It transmits PCM frames, receives them back, and displays sync word analysis for each recovered frame.
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
A["usb_signal_source\n(TX chain)"]:::rf --> B["head\n(sample limiter)"]:::timing --> C["usb_downlink_receiver\n(RX chain)"]:::rf
|
||||
C -->|"frames (PDU)"| D["message_debug\n(store)"]:::data
|
||||
|
||||
classDef data fill:#2d5016,stroke:#4a8c2a,color:#fff
|
||||
classDef rf fill:#1a3a5c,stroke:#3a7abd,color:#fff
|
||||
classDef timing fill:#5c3a1a,stroke:#bd7a3a,color:#fff
|
||||
```
|
||||
|
||||
### Usage
|
||||
|
||||
```bash
|
||||
uv run python examples/loopback_demo.py
|
||||
uv run python examples/loopback_demo.py --voice # include voice subcarrier
|
||||
uv run python examples/loopback_demo.py --snr 20 # add noise at 20 dB SNR
|
||||
uv run python examples/loopback_demo.py --frames 20 # generate 20 frames
|
||||
```
|
||||
|
||||
### Arguments
|
||||
|
||||
| Argument | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `--frames` | 10 | Number of PCM frames to transmit |
|
||||
| `--snr` | None | SNR in dB (None = clean, no noise) |
|
||||
| `--voice` | off | Enable the 1.25 MHz FM voice subcarrier with 1 kHz test tone |
|
||||
|
||||
### Expected Output
|
||||
|
||||
```
|
||||
============================================================
|
||||
Apollo USB Loopback Demo
|
||||
============================================================
|
||||
Frames to transmit: 10
|
||||
Samples per frame: 102,400
|
||||
Total samples: 1,024,000
|
||||
Duration: 0.200 s
|
||||
SNR: clean (no noise)
|
||||
Voice subcarrier: disabled
|
||||
|
||||
Building flowgraph...
|
||||
Running flowgraph (TX -> RX)...
|
||||
|
||||
Recovered 8 frames from 10 transmitted
|
||||
|
||||
------------------------------------------------------------
|
||||
Frame 1: ID= 3 (odd ), sync=0xAB31D403, 124 words [00 00 00 00 00 00 00 00 ...]
|
||||
Frame 2: ID= 4 (even), sync=0xABCED404, 124 words [00 00 00 00 00 00 00 00 ...]
|
||||
...
|
||||
------------------------------------------------------------
|
||||
|
||||
Recovery rate: 8/10 (80%)
|
||||
```
|
||||
|
||||
<Aside type="tip">
|
||||
The first 1--2 frames are typically lost to PLL settling. This is physically correct -- the carrier tracking loop needs signal history to acquire lock. If recovery seems low, increase `--frames` rather than adjusting demod parameters.
|
||||
</Aside>
|
||||
|
||||
---
|
||||
|
||||
## Voice Subcarrier
|
||||
|
||||
**Script:** `examples/voice_subcarrier_demo.py`
|
||||
**Requires:** GNU Radio, scipy
|
||||
|
||||
This demo takes a real audio file (such as actual Apollo 11 crew recordings), modulates it onto the 1.25 MHz FM voice subcarrier with +/-29 kHz deviation, then demodulates it back to audio. The round-trip exercises the same signal path the spacecraft and ground station used.
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
A["WAV file\n(any rate)"]:::data --> B["resample\nto 8 kHz"]:::timing --> C["upsample\nto 5.12 MHz"]:::timing
|
||||
C --> D["fm_voice_subcarrier_mod\n(audio_input=True)"]:::rf
|
||||
D --> E["voice_subcarrier_demod\n(8 kHz output)"]:::rf
|
||||
E --> F["recovered\nWAV file"]:::data
|
||||
|
||||
classDef data fill:#2d5016,stroke:#4a8c2a,color:#fff
|
||||
classDef rf fill:#1a3a5c,stroke:#3a7abd,color:#fff
|
||||
classDef timing fill:#5c3a1a,stroke:#bd7a3a,color:#fff
|
||||
```
|
||||
|
||||
### Usage
|
||||
|
||||
```bash
|
||||
uv run python examples/voice_subcarrier_demo.py examples/audio/apollo11_crew.wav
|
||||
uv run python examples/voice_subcarrier_demo.py input.wav --output recovered.wav
|
||||
uv run python examples/voice_subcarrier_demo.py input.wav --play
|
||||
```
|
||||
|
||||
### Arguments
|
||||
|
||||
| Argument | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `input` (positional) | -- | Input WAV file (any sample rate) |
|
||||
| `--output`, `-o` | `<input>_recovered.wav` | Output WAV file path |
|
||||
| `--play` | off | Play recovered audio with `aplay` after processing |
|
||||
| `--sample-rate` | 5,120,000 | Baseband sample rate in Hz |
|
||||
|
||||
### Expected Output
|
||||
|
||||
```
|
||||
============================================================
|
||||
Apollo Voice Subcarrier Demo
|
||||
============================================================
|
||||
|
||||
Input: examples/audio/apollo11_crew.wav
|
||||
Sample rate: 48000 Hz
|
||||
Duration: 5.23 s
|
||||
Samples: 251,136
|
||||
|
||||
Resampled to 8000 Hz: 41,856 samples
|
||||
Upsampling 8000 Hz -> 5.12 MHz (ratio 640:1)...
|
||||
Upsampled: 26,787,840 samples (2.1s)
|
||||
|
||||
Building flowgraph: FM mod (1.25 MHz) -> FM demod...
|
||||
Running flowgraph...
|
||||
Processed in 3.4s
|
||||
Recovered: 41,790 samples at 8000 Hz
|
||||
Duration: 5.22 s
|
||||
|
||||
Saved: examples/audio/apollo11_crew_recovered.wav
|
||||
Peak amplitude: 0.8234
|
||||
|
||||
Play with: aplay examples/audio/apollo11_crew_recovered.wav
|
||||
|
||||
Done.
|
||||
```
|
||||
|
||||
<Aside type="note">
|
||||
The input audio is first resampled to 8 kHz (the Apollo voice bandwidth standard), then upsampled by 640x to reach the 5.12 MHz baseband rate. This mirrors what the spacecraft hardware did: the Pre-Modulation Processor accepted telephone-bandwidth audio and modulated it onto the RF subcarrier. The recovered audio has 300--3000 Hz bandwidth, matching the original system.
|
||||
</Aside>
|
||||
|
||||
---
|
||||
|
||||
## Full Downlink
|
||||
|
||||
**Script:** `examples/full_downlink_demo.py`
|
||||
**Requires:** GNU Radio, scipy
|
||||
|
||||
The full downlink demo reconstructs the complete Apollo USB downlink: PCM telemetry frames on the 1.024 MHz BPSK subcarrier AND crew voice on the 1.25 MHz FM subcarrier, both phase-modulated onto a single complex carrier. The receiver splits the signal back into decoded frames and audio.
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph TX ["TX (spacecraft)"]
|
||||
direction LR
|
||||
A["pcm_frame_source\n→ nrz → bpsk_mod"]:::data --> D["add_ff"]:::rf
|
||||
B["crew audio\n→ fm_voice_mod"]:::data --> C["× 0.764"]:::rf --> D
|
||||
D --> E["pm_mod"]:::rf
|
||||
end
|
||||
|
||||
subgraph RX ["RX (ground station)"]
|
||||
direction LR
|
||||
F["pm_demod"]:::rf --> G["bpsk_demod\n→ frame_sync"]:::rf
|
||||
F --> H["voice_demod"]:::rf
|
||||
G --> I["PCM frames"]:::data
|
||||
H --> J["crew audio"]:::data
|
||||
end
|
||||
|
||||
E --> F
|
||||
|
||||
classDef data fill:#2d5016,stroke:#4a8c2a,color:#fff
|
||||
classDef rf fill:#1a3a5c,stroke:#3a7abd,color:#fff
|
||||
```
|
||||
|
||||
This demo builds the TX chain manually (not using `usb_signal_source`) so it can inject external audio into the voice channel. It then runs the RX chain twice: once for PCM frame recovery, once for voice demodulation.
|
||||
|
||||
### Usage
|
||||
|
||||
```bash
|
||||
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
|
||||
uv run python examples/full_downlink_demo.py input.wav --play
|
||||
```
|
||||
|
||||
### Arguments
|
||||
|
||||
| Argument | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `audio` (positional) | -- | Input crew voice WAV file |
|
||||
| `--output`, `-o` | `<input>_fullchain.wav` | Output WAV path for recovered voice |
|
||||
| `--snr` | None | Add AWGN noise at this SNR in dB |
|
||||
| `--play` | off | Play recovered voice with `aplay` |
|
||||
|
||||
### Expected Output
|
||||
|
||||
```
|
||||
============================================================
|
||||
Apollo Full Downlink Demo
|
||||
PCM telemetry (1.024 MHz BPSK) + crew voice (1.25 MHz FM)
|
||||
============================================================
|
||||
|
||||
Loading crew voice audio...
|
||||
Source: examples/audio/apollo11_crew.wav (5.23s)
|
||||
Upsampled: 26,787,840 samples at 5.12 MHz
|
||||
|
||||
PCM frames: ~263 at 50 fps
|
||||
Signal: 26,787,840 samples (5.23s)
|
||||
SNR: clean
|
||||
|
||||
TX: Building combined PCM + voice signal...
|
||||
Generated 26,787,840 complex samples (4.2s)
|
||||
PM envelope std: 0.000001 (should be ~0 for clean)
|
||||
|
||||
RX: Decoding PCM telemetry frames...
|
||||
Recovered 260 PCM frames (6.1s)
|
||||
|
||||
Frame 1: ID= 3 (odd), 124 data words
|
||||
Frame 2: ID= 4 (even), 124 data words
|
||||
Frame 3: ID= 5 (odd), 124 data words
|
||||
Frame 4: ID= 6 (even), 124 data words
|
||||
Frame 5: ID= 7 (odd), 124 data words
|
||||
... (255 more frames)
|
||||
|
||||
RX: Demodulating crew voice (1.25 MHz FM)...
|
||||
Recovered 41,790 audio samples (3.8s)
|
||||
Duration: 5.22s at 8000 Hz
|
||||
Saved: examples/audio/apollo11_crew_fullchain.wav
|
||||
|
||||
============================================================
|
||||
TX: 5.23s of combined PCM + voice
|
||||
RX: 260 PCM frames + 5.22s crew voice
|
||||
SNR: clean
|
||||
============================================================
|
||||
|
||||
Play voice: aplay examples/audio/apollo11_crew_fullchain.wav
|
||||
```
|
||||
|
||||
<Aside type="caution">
|
||||
The full downlink demo processes the signal twice (once for PCM, once for voice) because the `usb_downlink_receiver` does not include a voice output path. For a production system, you would split the PM demod output and feed both paths simultaneously, as shown in the [Decode Voice Audio](/guides/voice-audio/#extracting-voice-alongside-pcm-telemetry) guide.
|
||||
</Aside>
|
||||
|
||||
---
|
||||
|
||||
## AGC Integration
|
||||
|
||||
**Script:** `examples/agc_loopback_demo.py`
|
||||
**Requires:** yaAGC emulator (no GNU Radio needed)
|
||||
|
||||
This demo connects directly to a running Virtual AGC emulator over TCP, receives DNTM1/DNTM2 telemetry packets, decodes them into downlink list snapshots, and optionally sends DSKY commands.
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
A["yaAGC\n(Luminary099)"]:::timing -->|"TCP :19697"| B["AGCBridgeClient"]:::rf
|
||||
B --> C["DownlinkEngine\n(reassemble words)"]:::data
|
||||
C --> D["telemetry\nsnapshots"]:::data
|
||||
E["UplinkEncoder\n(V16N36E)"]:::data -->|"INLINK ch 045"| B
|
||||
|
||||
classDef data fill:#2d5016,stroke:#4a8c2a,color:#fff
|
||||
classDef rf fill:#1a3a5c,stroke:#3a7abd,color:#fff
|
||||
classDef timing fill:#5c3a1a,stroke:#bd7a3a,color:#fff
|
||||
```
|
||||
|
||||
### Prerequisites
|
||||
|
||||
<Steps>
|
||||
|
||||
1. **Install Virtual AGC** from [the project website](https://www.ibiblio.org/apollo/). The key binary is `yaAGC`.
|
||||
|
||||
2. **Start the AGC emulator** with a mission flight software image:
|
||||
```bash
|
||||
yaAGC --core=Luminary099.bin --port=19697
|
||||
```
|
||||
|
||||
3. **Optionally start yaDSKY2** for a visual DSKY display:
|
||||
```bash
|
||||
yaDSKY2 --port=19698
|
||||
```
|
||||
|
||||
</Steps>
|
||||
|
||||
### Usage
|
||||
|
||||
```bash
|
||||
uv run python examples/agc_loopback_demo.py
|
||||
uv run python examples/agc_loopback_demo.py --host 192.168.1.100
|
||||
uv run python examples/agc_loopback_demo.py --send-v16n36
|
||||
uv run python examples/agc_loopback_demo.py --duration 30
|
||||
```
|
||||
|
||||
### Arguments
|
||||
|
||||
| Argument | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `--host` | `localhost` | yaAGC hostname or IP |
|
||||
| `--port` | 19697 | yaAGC TCP port |
|
||||
| `--duration` | 10.0 | Collection duration in seconds |
|
||||
| `--send-v16n36` | off | Send V16N36E (display mission elapsed time) to the AGC |
|
||||
|
||||
### Expected Output
|
||||
|
||||
```
|
||||
============================================================
|
||||
Apollo AGC Integration Demo
|
||||
============================================================
|
||||
Target: localhost:19697
|
||||
Duration: 10.0 seconds
|
||||
|
||||
Connecting to yaAGC at localhost:19697...
|
||||
Connection: connecting
|
||||
Connection: connected
|
||||
|
||||
Sending V16N36E (display time)...
|
||||
Sent 7 uplink words
|
||||
|
||||
Collecting telemetry for 10.0 seconds...
|
||||
------------------------------------------------------------
|
||||
Telemetry snapshot: CM Coast/Alignment (type 2), 400 words
|
||||
[000] = 00002 (2)
|
||||
[001] = 00000 (0)
|
||||
[002] = 77777 (32767)
|
||||
[003] = 00000 (0)
|
||||
[004] = 00000 (0)
|
||||
... (395 more words)
|
||||
------------------------------------------------------------
|
||||
|
||||
Summary:
|
||||
Total packets received: 1247
|
||||
Telemetry words: 834
|
||||
Telemetry snapshots: 2
|
||||
Duration: 10.1 seconds
|
||||
|
||||
Done.
|
||||
```
|
||||
|
||||
<Aside type="tip">
|
||||
The V16N36E command requests the AGC to display the current mission elapsed time on the DSKY. If yaDSKY2 is running, you will see the time appear on the display. This is a safe read-only command that does not affect the AGC's running program.
|
||||
</Aside>
|
||||
|
||||
<Aside type="note">
|
||||
The `AGCBridgeClient` auto-reconnects with exponential backoff. You can start the demo before yaAGC is running -- it will retry in the background until the emulator comes online. If no connection is established within 10 seconds, the demo exits with instructions for starting yaAGC.
|
||||
</Aside>
|
||||
|
||||
---
|
||||
|
||||
## Which Demo to Start With
|
||||
|
||||
If you are new to gr-apollo:
|
||||
|
||||
1. **Start with the loopback demo.** It has no external dependencies beyond GNU Radio and exercises the complete TX/RX round-trip in a self-contained flowgraph.
|
||||
|
||||
2. **Try the voice demo** with an Apollo crew recording to hear the signal processing in action. Audio files in `examples/audio/` are ready to use.
|
||||
|
||||
3. **Run the full downlink** to see both PCM and voice working together on one carrier -- the way it worked on the actual spacecraft.
|
||||
|
||||
4. **Connect to yaAGC** when you are ready to interact with a running Apollo Guidance Computer.
|
||||
|
||||
<CardGrid>
|
||||
<LinkCard
|
||||
title="Build a Transmit Signal"
|
||||
description="Detailed walkthrough of the TX block chain"
|
||||
href="/guides/transmit-signal/"
|
||||
/>
|
||||
<LinkCard
|
||||
title="Block Reference"
|
||||
description="Full API docs for all blocks used in the demos"
|
||||
href="/reference/blocks/"
|
||||
/>
|
||||
</CardGrid>
|
||||
320
docs/src/content/docs/guides/sco-modulation.mdx
Normal file
320
docs/src/content/docs/guides/sco-modulation.mdx
Normal file
@ -0,0 +1,320 @@
|
||||
---
|
||||
title: "Modulate SCO Channels"
|
||||
description: "How to generate and demodulate Subcarrier Oscillator (SCO) analog telemetry channels used in FM downlink mode."
|
||||
---
|
||||
|
||||
import { Steps, Aside, Tabs, TabItem, Code, LinkCard, CardGrid } from '@astrojs/starlight/components';
|
||||
|
||||
In FM downlink mode, the Pre-Modulation Processor replaces the PCM/voice subcarrier system with 9 Subcarrier Oscillators (SCOs). Each SCO encodes a 0--5V sensor voltage as FM deviation around a fixed center frequency. The `sco_mod` and `sco_demod` blocks handle the transmit and receive sides of this analog telemetry path.
|
||||
|
||||
<Aside type="caution">
|
||||
SCOs are only used in FM downlink mode. During normal PM downlink mode (which carried the vast majority of Apollo mission communications), the 1.024 MHz BPSK subcarrier handles PCM telemetry and the 1.25 MHz FM subcarrier carries voice. The two modes are mutually exclusive.
|
||||
</Aside>
|
||||
|
||||
## SCO Channel Table
|
||||
|
||||
All 9 channels deviate by +/-7.5% of their center frequency:
|
||||
|
||||
| SCO | Center Freq | Deviation (+/-7.5%) | Low Freq | High Freq |
|
||||
|-----|------------|---------------------|----------|-----------|
|
||||
| 1 | 14,500 Hz | 1,087.5 Hz | 13,412.5 Hz | 15,587.5 Hz |
|
||||
| 2 | 22,000 Hz | 1,650 Hz | 20,350 Hz | 23,650 Hz |
|
||||
| 3 | 30,000 Hz | 2,250 Hz | 27,750 Hz | 32,250 Hz |
|
||||
| 4 | 40,000 Hz | 3,000 Hz | 37,000 Hz | 43,000 Hz |
|
||||
| 5 | 52,500 Hz | 3,937.5 Hz | 48,562.5 Hz | 56,437.5 Hz |
|
||||
| 6 | 70,000 Hz | 5,250 Hz | 64,750 Hz | 75,250 Hz |
|
||||
| 7 | 95,000 Hz | 7,125 Hz | 87,875 Hz | 102,125 Hz |
|
||||
| 8 | 125,000 Hz | 9,375 Hz | 115,625 Hz | 134,375 Hz |
|
||||
| 9 | 165,000 Hz | 12,375 Hz | 152,625 Hz | 177,375 Hz |
|
||||
|
||||
The channels are spaced logarithmically, with each roughly 1.35x the previous. This spacing allows them to be frequency-division multiplexed onto a single composite signal without overlap.
|
||||
|
||||
## Basic SCO Modulation
|
||||
|
||||
<Steps>
|
||||
|
||||
1. **Create an sco_mod for a single channel**
|
||||
|
||||
The `sco_mod` block accepts a 0--5V float input and produces an FM tone at the selected channel's center frequency. Here we modulate a constant 3.3V sensor reading onto SCO channel 5 (52.5 kHz):
|
||||
|
||||
```python
|
||||
from gnuradio import analog, blocks, gr
|
||||
|
||||
from apollo.constants import SAMPLE_RATE_BASEBAND
|
||||
from apollo.sco_mod import sco_mod
|
||||
|
||||
tb = gr.top_block()
|
||||
|
||||
# Simulate a constant 3.3V sensor reading
|
||||
sensor = analog.sig_source_f(
|
||||
SAMPLE_RATE_BASEBAND, analog.GR_CONST_WAVE, 0, 3.3, 0,
|
||||
)
|
||||
|
||||
# SCO channel 5: 52.5 kHz center, +/-3937.5 Hz deviation
|
||||
mod = sco_mod(sco_number=5, sample_rate=SAMPLE_RATE_BASEBAND)
|
||||
|
||||
head = blocks.head(gr.sizeof_float, int(SAMPLE_RATE_BASEBAND * 0.01)) # 10 ms
|
||||
snk = blocks.vector_sink_f()
|
||||
|
||||
tb.connect(sensor, mod, head, snk)
|
||||
tb.run()
|
||||
|
||||
print(f"Generated {len(snk.data())} samples")
|
||||
print(f"SCO 5 center: {mod.center_freq} Hz")
|
||||
print(f"SCO 5 deviation: {mod.deviation_hz} Hz")
|
||||
```
|
||||
|
||||
2. **Verify the output frequency**
|
||||
|
||||
At 3.3V input, the SCO should be above center. The voltage maps linearly: 0V is center minus 7.5%, 2.5V is center, 5V is center plus 7.5%. For 3.3V:
|
||||
|
||||
```
|
||||
offset = (3.3 - 2.5) / 2.5 = 0.32 (normalized)
|
||||
freq = 52500 + 0.32 * 3937.5 = 53760 Hz
|
||||
```
|
||||
|
||||
3. **Inspect properties at runtime**
|
||||
|
||||
The block exposes its configuration as read-only properties:
|
||||
|
||||
```python
|
||||
mod = sco_mod(sco_number=5, sample_rate=SAMPLE_RATE_BASEBAND)
|
||||
|
||||
print(f"Channel: {mod.sco_number}") # 5
|
||||
print(f"Center: {mod.center_freq} Hz") # 52500.0
|
||||
print(f"Deviation: {mod.deviation_hz} Hz") # 3937.5
|
||||
```
|
||||
|
||||
</Steps>
|
||||
|
||||
## Round-Trip: Modulate and Demodulate
|
||||
|
||||
The `sco_demod` block reverses the modulation, recovering the original sensor voltage from the FM subcarrier tone. Connecting `sco_mod` to `sco_demod` creates a complete round-trip:
|
||||
|
||||
```python
|
||||
from gnuradio import analog, blocks, gr
|
||||
|
||||
from apollo.constants import SAMPLE_RATE_BASEBAND
|
||||
from apollo.sco_demod import sco_demod
|
||||
from apollo.sco_mod import sco_mod
|
||||
|
||||
tb = gr.top_block()
|
||||
|
||||
# Slowly varying sensor: 1 Hz sine wave, 0-5V range
|
||||
sensor = analog.sig_source_f(
|
||||
SAMPLE_RATE_BASEBAND, analog.GR_SIN_WAVE, 1.0, 2.5, 2.5,
|
||||
)
|
||||
|
||||
# Modulate onto SCO 3 (30 kHz center)
|
||||
mod = sco_mod(sco_number=3, sample_rate=SAMPLE_RATE_BASEBAND)
|
||||
|
||||
# Demodulate back to voltage
|
||||
demod = sco_demod(sco_number=3, sample_rate=SAMPLE_RATE_BASEBAND)
|
||||
|
||||
head = blocks.head(gr.sizeof_float, int(SAMPLE_RATE_BASEBAND * 2)) # 2 seconds
|
||||
snk = blocks.vector_sink_f()
|
||||
|
||||
tb.connect(sensor, mod, demod, head, snk)
|
||||
tb.run()
|
||||
|
||||
import numpy as np
|
||||
recovered = np.array(snk.data())
|
||||
print(f"Samples: {len(recovered)}")
|
||||
print(f"Mean voltage: {np.mean(recovered):.2f} V (expected ~2.5)")
|
||||
print(f"Voltage range: {np.min(recovered):.2f} - {np.max(recovered):.2f} V")
|
||||
```
|
||||
|
||||
<Aside type="note">
|
||||
The recovered voltage tracks the input with slight latency from the FM discriminator's internal bandpass filter settling. For slowly varying inputs (below ~100 Hz), the tracking is accurate to within a few millivolts. The demodulator's output sample rate depends on the internal decimation -- use the `output_sample_rate` property to check it.
|
||||
</Aside>
|
||||
|
||||
## Multi-Channel Summing
|
||||
|
||||
In a real FM downlink, multiple SCO channels are summed to form a composite signal that drives the PM modulator. Each SCO encodes a different sensor:
|
||||
|
||||
```python
|
||||
from gnuradio import analog, blocks, gr
|
||||
|
||||
from apollo.constants import SAMPLE_RATE_BASEBAND
|
||||
from apollo.pm_mod import pm_mod
|
||||
from apollo.sco_mod import sco_mod
|
||||
|
||||
tb = gr.top_block()
|
||||
|
||||
# Three sensors with different readings
|
||||
sensor1 = analog.sig_source_f(
|
||||
SAMPLE_RATE_BASEBAND, analog.GR_CONST_WAVE, 0, 1.2, 0,
|
||||
) # 1.2V (cabin pressure)
|
||||
sensor5 = analog.sig_source_f(
|
||||
SAMPLE_RATE_BASEBAND, analog.GR_CONST_WAVE, 0, 3.8, 0,
|
||||
) # 3.8V (fuel cell voltage)
|
||||
sensor9 = analog.sig_source_f(
|
||||
SAMPLE_RATE_BASEBAND, analog.GR_CONST_WAVE, 0, 2.5, 0,
|
||||
) # 2.5V (temperature)
|
||||
|
||||
# One sco_mod per channel
|
||||
sco1 = sco_mod(sco_number=1, sample_rate=SAMPLE_RATE_BASEBAND) # 14.5 kHz
|
||||
sco5 = sco_mod(sco_number=5, sample_rate=SAMPLE_RATE_BASEBAND) # 52.5 kHz
|
||||
sco9 = sco_mod(sco_number=9, sample_rate=SAMPLE_RATE_BASEBAND) # 165 kHz
|
||||
|
||||
# Sum all SCO subcarrier tones
|
||||
adder = blocks.add_ff(1)
|
||||
tb.connect(sensor1, sco1, (adder, 0))
|
||||
tb.connect(sensor5, sco5, (adder, 1))
|
||||
tb.connect(sensor9, sco9, (adder, 2))
|
||||
|
||||
# PM modulate the composite SCO signal
|
||||
pm = pm_mod(pm_deviation=0.133, sample_rate=SAMPLE_RATE_BASEBAND)
|
||||
|
||||
head = blocks.head(gr.sizeof_gr_complex, int(SAMPLE_RATE_BASEBAND * 0.1)) # 100 ms
|
||||
snk = blocks.vector_sink_c()
|
||||
|
||||
tb.connect(adder, pm, head, snk)
|
||||
tb.run()
|
||||
|
||||
print(f"Generated {len(snk.data())} complex samples")
|
||||
print(f"SCO channels: {sco1.center_freq}, {sco5.center_freq}, {sco9.center_freq} Hz")
|
||||
```
|
||||
|
||||
The SCO center frequencies (14.5 kHz, 52.5 kHz, 165 kHz) are far enough apart that simple bandpass filtering on the receive side can separate them without interference.
|
||||
|
||||
## Voltage Mapping
|
||||
|
||||
The `sco_mod` block maps input voltage to output frequency linearly:
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
A["0V input"]:::data --> B["center - 7.5%\n(low freq)"]:::rf
|
||||
C["2.5V input"]:::data --> D["center freq\n(nominal)"]:::rf
|
||||
E["5V input"]:::data --> F["center + 7.5%\n(high freq)"]:::rf
|
||||
|
||||
classDef data fill:#2d5016,stroke:#4a8c2a,color:#fff
|
||||
classDef rf fill:#1a3a5c,stroke:#3a7abd,color:#fff
|
||||
```
|
||||
|
||||
Internally, the modulation chain is:
|
||||
|
||||
1. **Subtract 2.5V** -- center the signal at zero
|
||||
2. **Scale to +/-1.0** -- divide by 2.5 (half the input range)
|
||||
3. **FM modulate** -- +/-1.0 input produces +/-deviation Hz output
|
||||
4. **Upconvert** -- mix with a local oscillator at the center frequency
|
||||
5. **Extract real part** -- the output is a real-valued float subcarrier tone
|
||||
|
||||
The `sco_demod` block reverses this:
|
||||
|
||||
1. **Bandpass extract** at the channel center frequency (bandwidth = 15% of center)
|
||||
2. **FM discriminate** -- quadrature demod recovers the frequency deviation
|
||||
3. **Scale by 2.5** -- map discriminator output back to voltage swing
|
||||
4. **Add 2.5V offset** -- restore the 0--5V range
|
||||
|
||||
The conversion is symmetric: a constant voltage in produces that same voltage out (within the FM discriminator's noise floor).
|
||||
|
||||
## Choosing SCO Channels
|
||||
|
||||
The 9 SCO channels span from 14.5 kHz to 165 kHz. When selecting which channels to use, consider:
|
||||
|
||||
| Factor | Guidance |
|
||||
|--------|----------|
|
||||
| Bandwidth | Higher channels have wider deviation bands -- better for fast-changing signals |
|
||||
| Filter settling | Lower channels need longer filter settling time due to narrower bandwidth |
|
||||
| Channel spacing | Adjacent channels can interfere if the composite signal is distorted |
|
||||
| Sample rate | All channels work at the default 5.12 MHz baseband rate |
|
||||
|
||||
<Tabs>
|
||||
<TabItem label="Low-bandwidth sensor">
|
||||
For slow-moving measurements (temperature, pressure), use lower SCO channels (1--4). The narrow bandwidth provides better noise rejection:
|
||||
|
||||
```python
|
||||
# Temperature sensor: changes slowly, needs precision
|
||||
temp_sco = sco_mod(sco_number=2, sample_rate=SAMPLE_RATE_BASEBAND)
|
||||
# SCO 2: 22 kHz center, +/-1650 Hz deviation
|
||||
```
|
||||
</TabItem>
|
||||
<TabItem label="Fast-changing sensor">
|
||||
For vibration monitoring or other fast signals, use higher SCO channels (7--9) where the wider deviation band supports higher-frequency content:
|
||||
|
||||
```python
|
||||
# Vibration sensor: needs higher bandwidth
|
||||
vib_sco = sco_mod(sco_number=8, sample_rate=SAMPLE_RATE_BASEBAND)
|
||||
# SCO 8: 125 kHz center, +/-9375 Hz deviation
|
||||
```
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
## Complete FM Downlink Example
|
||||
|
||||
Here is a full example that modulates three sensor channels, transmits them as FM downlink, and recovers all three voltages:
|
||||
|
||||
```python
|
||||
from gnuradio import analog, blocks, gr
|
||||
|
||||
from apollo.constants import SAMPLE_RATE_BASEBAND
|
||||
from apollo.sco_demod import sco_demod
|
||||
from apollo.sco_mod import sco_mod
|
||||
|
||||
tb = gr.top_block()
|
||||
|
||||
# --- TX side: three sensors ---
|
||||
sensor_readings = {3: 1.8, 5: 4.2, 7: 2.5} # {sco_channel: voltage}
|
||||
|
||||
mods = {}
|
||||
adder = blocks.add_ff(1)
|
||||
for idx, (ch, voltage) in enumerate(sensor_readings.items()):
|
||||
src = analog.sig_source_f(SAMPLE_RATE_BASEBAND, analog.GR_CONST_WAVE, 0, voltage, 0)
|
||||
mod = sco_mod(sco_number=ch, sample_rate=SAMPLE_RATE_BASEBAND)
|
||||
mods[ch] = mod
|
||||
tb.connect(src, mod, (adder, idx))
|
||||
|
||||
# --- RX side: demodulate each channel from composite ---
|
||||
demods = {}
|
||||
sinks = {}
|
||||
duration_samples = int(SAMPLE_RATE_BASEBAND * 0.5) # 500 ms
|
||||
|
||||
for ch in sensor_readings:
|
||||
demod = sco_demod(sco_number=ch, sample_rate=SAMPLE_RATE_BASEBAND)
|
||||
head = blocks.head(gr.sizeof_float, duration_samples)
|
||||
snk = blocks.vector_sink_f()
|
||||
tb.connect(adder, demod, head, snk)
|
||||
demods[ch] = demod
|
||||
sinks[ch] = snk
|
||||
|
||||
tb.run()
|
||||
|
||||
# Compare input and recovered voltages
|
||||
import numpy as np
|
||||
print("SCO | Input | Recovered (mean) | Error")
|
||||
print("-----|--------|------------------|------")
|
||||
for ch, voltage in sensor_readings.items():
|
||||
data = np.array(sinks[ch].data())
|
||||
# Skip first 10% for filter settling
|
||||
settled = data[len(data) // 10:]
|
||||
mean_v = np.mean(settled)
|
||||
print(f" {ch} | {voltage:.1f} V | {mean_v:.3f} V | {abs(voltage - mean_v):.3f} V")
|
||||
```
|
||||
|
||||
Expected output:
|
||||
|
||||
```
|
||||
SCO | Input | Recovered (mean) | Error
|
||||
-----|--------|------------------|------
|
||||
3 | 1.8 V | 1.802 V | 0.002 V
|
||||
5 | 4.2 V | 4.198 V | 0.002 V
|
||||
7 | 2.5 V | 2.500 V | 0.000 V
|
||||
```
|
||||
|
||||
<Aside type="tip">
|
||||
The first few milliseconds of demodulator output may show transient behavior as the internal filters settle. Skip the initial 10% of samples when computing statistics or making comparisons. The settling time is inversely proportional to the channel bandwidth -- higher SCO numbers settle faster.
|
||||
</Aside>
|
||||
|
||||
<CardGrid>
|
||||
<LinkCard
|
||||
title="sco_mod Block Reference"
|
||||
description="Full constructor parameters and properties"
|
||||
href="/reference/blocks/#sco_demod"
|
||||
/>
|
||||
<LinkCard
|
||||
title="Constants Reference"
|
||||
description="SCO frequencies, deviation percentages, and input ranges"
|
||||
href="/reference/constants/#subcarrier-oscillators-fm-mode"
|
||||
/>
|
||||
</CardGrid>
|
||||
@ -232,6 +232,43 @@ Apollo USB Signal Generator Demo
|
||||
Envelope std: 0.0712 (noisy = higher variance)
|
||||
```
|
||||
|
||||
## Streaming Signal Generation
|
||||
|
||||
The `generate_usb_baseband` function creates signals in batch (all samples computed at once, returned as a numpy array). For streaming scenarios — where signal generation runs continuously inside a GNU Radio flowgraph — use `usb_signal_source` instead:
|
||||
|
||||
```python
|
||||
from gnuradio import blocks, gr
|
||||
|
||||
from apollo.usb_signal_source import usb_signal_source
|
||||
|
||||
tb = gr.top_block()
|
||||
|
||||
# Streaming source: generates frames indefinitely
|
||||
tx = usb_signal_source(
|
||||
voice_enabled=True,
|
||||
snr_db=25.0,
|
||||
)
|
||||
|
||||
# Limit output to 10 frames worth of samples
|
||||
head = blocks.head(gr.sizeof_gr_complex, 10 * 102400)
|
||||
snk = blocks.vector_sink_c()
|
||||
|
||||
tb.connect(tx, head, snk)
|
||||
tb.run()
|
||||
```
|
||||
|
||||
| Approach | `generate_usb_baseband` | `usb_signal_source` |
|
||||
|----------|------------------------|---------------------|
|
||||
| **Runtime** | Pure Python (numpy) | GNU Radio required |
|
||||
| **Output** | numpy array (finite) | Streaming (continuous) |
|
||||
| **Payload** | Per-frame `frame_data` list | `frame_data` message port |
|
||||
| **Use case** | Unit tests, scripting | Flowgraphs, loopback demos |
|
||||
| **Voice** | Internal test tone only | Internal tone or external audio |
|
||||
|
||||
<Aside type="note">
|
||||
Both approaches produce identical signals for the same parameters. The batch function is better for offline analysis; the streaming source is better for real-time flowgraphs and integration testing.
|
||||
</Aside>
|
||||
|
||||
## Signal Structure at a Glance
|
||||
|
||||
The generated baseband signal has this structure:
|
||||
|
||||
325
docs/src/content/docs/guides/transmit-signal.mdx
Normal file
325
docs/src/content/docs/guides/transmit-signal.mdx
Normal file
@ -0,0 +1,325 @@
|
||||
---
|
||||
title: "Build a Transmit Signal"
|
||||
description: "How to construct an Apollo USB downlink transmit signal using gr-apollo's composable TX blocks."
|
||||
---
|
||||
|
||||
import { Steps, Aside, Tabs, TabItem, Code, LinkCard, CardGrid } from '@astrojs/starlight/components';
|
||||
|
||||
gr-apollo provides two approaches to constructing Apollo USB downlink signals:
|
||||
|
||||
- **Batch (pure Python):** `generate_usb_baseband()` produces a complete numpy array in one call. No GNU Radio required. Covered in the [Generate Test Signals](/guides/test-signals/) guide.
|
||||
- **Streaming (GNU Radio blocks):** Six composable TX blocks that mirror the RX chain, producing a continuous complex baseband stream through the GNU Radio scheduler.
|
||||
|
||||
The TX blocks map 1:1 to their RX counterparts -- each modulation stage has a direct demodulation match. If you understand the receive chain, you already understand the transmit chain in reverse.
|
||||
|
||||
## TX/RX Block Pairs
|
||||
|
||||
| TX Block | RX Block | Function |
|
||||
|----------|----------|----------|
|
||||
| `pcm_frame_source` | `pcm_frame_sync` | Frame generation / frame sync |
|
||||
| `nrz_encoder` | (slicer in `bpsk_demod`) | NRZ encoding / NRZ slicing |
|
||||
| `bpsk_subcarrier_mod` | `bpsk_subcarrier_demod` | BPSK modulation / demodulation |
|
||||
| `fm_voice_subcarrier_mod` | `voice_subcarrier_demod` | Voice FM modulation / demodulation |
|
||||
| `pm_mod` | `pm_demod` | PM modulation / demodulation |
|
||||
| `sco_mod` | `sco_demod` | SCO modulation / demodulation |
|
||||
| `usb_signal_source` | `usb_downlink_receiver` | Full chain TX / full chain RX |
|
||||
|
||||
## Signal Flow
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
A["pcm_frame_source\n32-bit sync + data"]:::data --> B["nrz_encoder\n0/1 → +1/−1"]:::data
|
||||
B --> C["bpsk_subcarrier_mod\n× cos(1.024 MHz)"]:::rf
|
||||
C --> D["add_ff"]:::rf
|
||||
E["fm_voice_subcarrier_mod\nFM → 1.25 MHz"]:::rf --> F["× 0.764\n(1.68/2.2)"]:::rf
|
||||
F --> D
|
||||
D --> G["pm_mod\nexp(j · 0.133 · m(t))"]:::rf
|
||||
G --> H["Complex\nBaseband"]:::rf
|
||||
|
||||
classDef data fill:#2d5016,stroke:#4a8c2a,color:#fff
|
||||
classDef rf fill:#1a3a5c,stroke:#3a7abd,color:#fff
|
||||
```
|
||||
|
||||
The PCM path generates frame bits, encodes them as NRZ (+1/-1), and modulates onto a 1.024 MHz BPSK subcarrier. The optional voice path FM-modulates audio onto a 1.25 MHz subcarrier, scaled to maintain the spec power ratio (1.68 Vpp voice / 2.2 Vpp PCM). Both subcarriers are summed, then phase-modulated at 0.133 radians peak deviation to produce complex baseband.
|
||||
|
||||
## Building a Transmit Chain
|
||||
|
||||
<Steps>
|
||||
|
||||
1. **Minimal PCM transmitter**
|
||||
|
||||
Connect the four core blocks: frame source, NRZ encoder, BPSK modulator, and PM modulator. This produces a clean complex baseband signal with PCM telemetry only.
|
||||
|
||||
```python
|
||||
from gnuradio import blocks, gr
|
||||
|
||||
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,
|
||||
SAMPLE_RATE_BASEBAND,
|
||||
)
|
||||
from apollo.nrz_encoder import nrz_encoder
|
||||
from apollo.pcm_frame_source import pcm_frame_source
|
||||
from apollo.pm_mod import pm_mod
|
||||
|
||||
tb = gr.top_block()
|
||||
|
||||
# Stage 1: Generate continuous PCM frame bits (0/1 byte stream)
|
||||
frame_src = pcm_frame_source(bit_rate=PCM_HIGH_BIT_RATE)
|
||||
|
||||
# Stage 2: NRZ encode (byte 0/1 -> float +1/-1 at 5.12 MHz)
|
||||
nrz = nrz_encoder(bit_rate=PCM_HIGH_BIT_RATE, sample_rate=SAMPLE_RATE_BASEBAND)
|
||||
|
||||
# Stage 3: BPSK modulate onto 1.024 MHz subcarrier
|
||||
bpsk = bpsk_subcarrier_mod(
|
||||
subcarrier_freq=PCM_SUBCARRIER_HZ,
|
||||
sample_rate=SAMPLE_RATE_BASEBAND,
|
||||
)
|
||||
|
||||
# Stage 4: Phase modulate to complex baseband
|
||||
pm = pm_mod(pm_deviation=0.133, sample_rate=SAMPLE_RATE_BASEBAND)
|
||||
|
||||
# Limit output to 10 frames worth of samples
|
||||
bits_per_frame = PCM_HIGH_WORDS_PER_FRAME * PCM_WORD_LENGTH
|
||||
samples_per_frame = int(bits_per_frame * SAMPLE_RATE_BASEBAND / PCM_HIGH_BIT_RATE)
|
||||
head = blocks.head(gr.sizeof_gr_complex, 10 * samples_per_frame)
|
||||
|
||||
snk = blocks.vector_sink_c()
|
||||
|
||||
tb.connect(frame_src, nrz, bpsk, pm, head, snk)
|
||||
tb.run()
|
||||
|
||||
print(f"Generated {len(snk.data())} complex samples")
|
||||
```
|
||||
|
||||
Each bit at 51.2 kbps occupies 100 samples at 5.12 MHz. Each 128-word frame is 1024 bits, so one frame produces 102,400 samples (~20 ms).
|
||||
|
||||
2. **Add voice**
|
||||
|
||||
Insert an FM voice subcarrier with an internal test tone, scale it by the spec ratio, and sum with the PCM subcarrier before PM modulation.
|
||||
|
||||
```python
|
||||
from gnuradio import blocks, gr
|
||||
|
||||
from apollo.bpsk_subcarrier_mod import bpsk_subcarrier_mod
|
||||
from apollo.constants import (
|
||||
PCM_HIGH_BIT_RATE,
|
||||
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_mod import pm_mod
|
||||
|
||||
tb = gr.top_block()
|
||||
|
||||
# PCM path (same as before)
|
||||
frame_src = pcm_frame_source(bit_rate=PCM_HIGH_BIT_RATE)
|
||||
nrz = nrz_encoder(bit_rate=PCM_HIGH_BIT_RATE, sample_rate=SAMPLE_RATE_BASEBAND)
|
||||
bpsk = bpsk_subcarrier_mod(sample_rate=SAMPLE_RATE_BASEBAND)
|
||||
tb.connect(frame_src, nrz, bpsk)
|
||||
|
||||
# Voice path: 1 kHz test tone on 1.25 MHz FM subcarrier
|
||||
voice = fm_voice_subcarrier_mod(
|
||||
sample_rate=SAMPLE_RATE_BASEBAND,
|
||||
subcarrier_freq=VOICE_SUBCARRIER_HZ,
|
||||
fm_deviation=VOICE_FM_DEVIATION_HZ,
|
||||
tone_freq=1000.0,
|
||||
)
|
||||
# Scale voice to spec ratio: 1.68 Vpp / 2.2 Vpp = 0.764
|
||||
voice_gain = blocks.multiply_const_ff(1.68 / 2.2)
|
||||
tb.connect(voice, voice_gain)
|
||||
|
||||
# Sum subcarriers
|
||||
adder = blocks.add_ff(1)
|
||||
tb.connect(bpsk, (adder, 0))
|
||||
tb.connect(voice_gain, (adder, 1))
|
||||
|
||||
# PM modulate
|
||||
pm = pm_mod(pm_deviation=0.133, sample_rate=SAMPLE_RATE_BASEBAND)
|
||||
head = blocks.head(gr.sizeof_gr_complex, int(SAMPLE_RATE_BASEBAND * 0.2)) # 200 ms
|
||||
snk = blocks.vector_sink_c()
|
||||
|
||||
tb.connect(adder, pm, head, snk)
|
||||
tb.run()
|
||||
|
||||
print(f"Generated {len(snk.data())} samples with PCM + voice")
|
||||
```
|
||||
|
||||
3. **Use external audio**
|
||||
|
||||
Set `audio_input=True` on the voice modulator to accept a float stream instead of the internal tone. This is how you modulate real Apollo crew recordings onto the subcarrier.
|
||||
|
||||
```python
|
||||
import numpy as np
|
||||
from gnuradio import blocks, gr
|
||||
from scipy.io import wavfile
|
||||
from scipy.signal import resample_poly
|
||||
|
||||
from apollo.constants import SAMPLE_RATE_BASEBAND
|
||||
from apollo.fm_voice_subcarrier_mod import fm_voice_subcarrier_mod
|
||||
|
||||
# Load and upsample audio to 5.12 MHz
|
||||
input_rate, audio = wavfile.read("crew_voice.wav")
|
||||
audio_float = audio.astype(np.float32) / 32768.0
|
||||
|
||||
# Resample: input rate -> 8 kHz -> 5.12 MHz (factor 640)
|
||||
from math import gcd
|
||||
g = gcd(8000, input_rate)
|
||||
audio_8k = resample_poly(audio_float, 8000 // g, input_rate // g).astype(np.float32)
|
||||
upsampled = resample_poly(audio_8k, 640, 1).astype(np.float32)
|
||||
|
||||
# Build flowgraph with external audio input
|
||||
tb = gr.top_block()
|
||||
src = blocks.vector_source_f(upsampled.tolist())
|
||||
|
||||
voice_mod = fm_voice_subcarrier_mod(
|
||||
sample_rate=SAMPLE_RATE_BASEBAND,
|
||||
audio_input=True, # accepts float stream input
|
||||
)
|
||||
|
||||
snk = blocks.vector_sink_f()
|
||||
tb.connect(src, voice_mod, snk)
|
||||
tb.run()
|
||||
```
|
||||
|
||||
<Aside type="note">
|
||||
When `audio_input=True`, the block changes its input signature from no-input (source) to one float input. The audio must already be at the baseband sample rate (5.12 MHz). The 640:1 upsampling ratio comes from 5,120,000 / 8,000. Use `scipy.signal.resample_poly` for this -- it handles the anti-aliasing filter automatically.
|
||||
</Aside>
|
||||
|
||||
4. **The convenience wrapper**
|
||||
|
||||
The `usb_signal_source` block wires together the full TX chain internally, matching the topology of `usb_downlink_receiver` on the RX side.
|
||||
|
||||
```python
|
||||
from gnuradio import blocks, gr
|
||||
|
||||
from apollo.constants import SAMPLE_RATE_BASEBAND
|
||||
from apollo.usb_signal_source import usb_signal_source
|
||||
|
||||
tb = gr.top_block()
|
||||
|
||||
# Full TX chain in one block
|
||||
tx = usb_signal_source(
|
||||
sample_rate=SAMPLE_RATE_BASEBAND,
|
||||
bit_rate=51200,
|
||||
pm_deviation=0.133,
|
||||
voice_enabled=True,
|
||||
voice_tone_hz=1000.0,
|
||||
snr_db=30.0, # add noise for realistic testing
|
||||
)
|
||||
|
||||
head = blocks.head(gr.sizeof_gr_complex, int(SAMPLE_RATE_BASEBAND * 0.5))
|
||||
snk = blocks.vector_sink_c()
|
||||
|
||||
tb.connect(tx, head, snk)
|
||||
tb.run()
|
||||
|
||||
print(f"Generated {len(snk.data())} complex samples")
|
||||
```
|
||||
|
||||
The `usb_signal_source` exposes the same `frame_data` message port as `pcm_frame_source`, allowing dynamic payload injection at runtime.
|
||||
|
||||
</Steps>
|
||||
|
||||
## Dynamic Payloads via Message Port
|
||||
|
||||
The `pcm_frame_source` (and by extension `usb_signal_source`) accepts a `frame_data` message input for injecting custom payload bytes into the next generated frame. This is useful for transmitting specific telemetry patterns, test sequences, or data from an external source.
|
||||
|
||||
```python
|
||||
import pmt
|
||||
|
||||
# Prepare a 124-byte payload (words 5-128 of the PCM frame)
|
||||
payload = bytes([0xDE, 0xAD, 0xBE, 0xEF] * 31) # 124 bytes
|
||||
msg = pmt.init_u8vector(len(payload), list(payload))
|
||||
|
||||
# Post to the frame_data port
|
||||
tx.to_basic_block()._post(pmt.intern("frame_data"), msg)
|
||||
```
|
||||
|
||||
<Aside type="tip">
|
||||
Each posted message replaces the payload for the *next* frame only. Subsequent frames revert to zero-fill unless another message arrives. For continuous custom data, post one message per frame period (~20 ms at high rate).
|
||||
</Aside>
|
||||
|
||||
The message port also accepts PDU pairs (`pmt.cons(meta, payload)`) -- the metadata car is ignored, and the payload cdr is used as the frame data bytes.
|
||||
|
||||
## Adding Noise
|
||||
|
||||
Both `usb_signal_source` and the batch `generate_usb_baseband()` support an `snr_db` parameter that adds additive white Gaussian noise to the output:
|
||||
|
||||
<Tabs>
|
||||
<TabItem label="Streaming (GR)">
|
||||
```python
|
||||
tx = usb_signal_source(
|
||||
snr_db=20.0, # 20 dB SNR
|
||||
)
|
||||
```
|
||||
|
||||
Internally this adds a `noise_source_c` with amplitude computed from the target SNR. The PM signal has unit power (constant envelope), so noise amplitude is `sqrt(1 / (2 * 10^(snr_db/10)))`.
|
||||
</TabItem>
|
||||
<TabItem label="Batch (numpy)">
|
||||
```python
|
||||
from apollo.usb_signal_gen import generate_usb_baseband
|
||||
|
||||
signal, bits = generate_usb_baseband(
|
||||
frames=10,
|
||||
snr_db=20.0,
|
||||
)
|
||||
```
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
| SNR | Typical Use |
|
||||
|-----|-------------|
|
||||
| None | Clean signal, verifying block correctness |
|
||||
| 40 dB | Baseline performance measurement |
|
||||
| 30 dB | Realistic strong-signal conditions |
|
||||
| 20 dB | Stress-testing demodulator tracking loops |
|
||||
| 10 dB | Threshold testing, error-rate characterization |
|
||||
|
||||
## Loopback Testing
|
||||
|
||||
The most common use of the TX chain is loopback testing: generate a signal and immediately decode it. This verifies the full modulation/demodulation round-trip:
|
||||
|
||||
```python
|
||||
from gnuradio import blocks, gr
|
||||
|
||||
from apollo.constants import SAMPLE_RATE_BASEBAND
|
||||
from apollo.usb_downlink_receiver import usb_downlink_receiver
|
||||
from apollo.usb_signal_source import usb_signal_source
|
||||
|
||||
tb = gr.top_block()
|
||||
|
||||
tx = usb_signal_source(voice_enabled=True, snr_db=30.0)
|
||||
head = blocks.head(gr.sizeof_gr_complex, int(SAMPLE_RATE_BASEBAND * 0.4))
|
||||
rx = usb_downlink_receiver(output_format="raw")
|
||||
snk = blocks.message_debug()
|
||||
|
||||
tb.connect(tx, head, rx)
|
||||
tb.msg_connect(rx, "frames", snk, "store")
|
||||
tb.run()
|
||||
|
||||
print(f"TX -> RX loopback: recovered {snk.num_messages()} frames")
|
||||
```
|
||||
|
||||
<Aside type="caution">
|
||||
The first 1--2 frames are typically lost to PLL settling in the receiver. This is physically correct behavior -- the carrier tracking loop needs time to acquire lock. Generate at least 5 frames for reliable recovery; the [loopback demo](/guides/run-demos/#loopback) uses 10 by default.
|
||||
</Aside>
|
||||
|
||||
<CardGrid>
|
||||
<LinkCard
|
||||
title="Block Reference"
|
||||
description="Full API documentation for all TX and RX blocks"
|
||||
href="/reference/blocks/"
|
||||
/>
|
||||
<LinkCard
|
||||
title="Run the Demos"
|
||||
description="Ready-to-run scripts for loopback, voice, and full downlink testing"
|
||||
href="/guides/run-demos/"
|
||||
/>
|
||||
</CardGrid>
|
||||
@ -227,6 +227,41 @@ print("Wrote voice.wav")
|
||||
The voice subcarrier is only present during PM downlink mode. In FM downlink mode, the 1.25 MHz band is used differently, and the voice path is replaced by Subcarrier Oscillator (SCO) channels. The `sco_demod` block handles FM-mode analog telemetry.
|
||||
</Aside>
|
||||
|
||||
## Modulating Voice (TX Side)
|
||||
|
||||
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.
|
||||
|
||||
### Internal Test Tone
|
||||
|
||||
For testing without an audio source, the modulator generates a sine tone internally:
|
||||
|
||||
```python
|
||||
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)
|
||||
```
|
||||
|
||||
### External Audio Input
|
||||
|
||||
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:
|
||||
|
||||
```python
|
||||
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.
|
||||
```
|
||||
|
||||
<Aside type="tip">
|
||||
The `voice_subcarrier_demo.py` script handles the full pipeline: load WAV, resample to 8 kHz, upsample to 5.12 MHz, modulate, demodulate, and save. Run it with `uv run python examples/voice_subcarrier_demo.py input.wav`.
|
||||
</Aside>
|
||||
|
||||
## Audio Quality Notes
|
||||
|
||||
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:
|
||||
|
||||
@ -3,7 +3,7 @@ title: gr-apollo
|
||||
description: Apollo Unified S-Band decoder for GNU Radio 3.10+
|
||||
template: splash
|
||||
hero:
|
||||
tagline: Decode Apollo-era spacecraft telemetry with modern software-defined radio
|
||||
tagline: Generate and decode Apollo-era spacecraft telemetry with modern software-defined radio
|
||||
actions:
|
||||
- text: Get Started
|
||||
link: /getting-started/introduction/
|
||||
@ -29,8 +29,8 @@ import { Card, CardGrid } from '@astrojs/starlight/components';
|
||||
Bridge decoded telemetry directly to the Virtual AGC emulator
|
||||
via TCP socket — send DSKY commands, receive downlink data.
|
||||
</Card>
|
||||
<Card title="Signal Generator" icon="puzzle">
|
||||
Generate synthetic USB baseband signals with configurable SNR,
|
||||
known payloads, and voice — no hardware needed for testing.
|
||||
<Card title="Full TX Chain" icon="puzzle">
|
||||
Build complete transmit signals with streaming GNU Radio blocks —
|
||||
PCM telemetry, FM voice, SCO channels, and composite USB carriers.
|
||||
</Card>
|
||||
</CardGrid>
|
||||
|
||||
@ -923,3 +923,362 @@ The internal blocks are exposed as instance attributes for runtime inspection or
|
||||
| `self.bpsk` | `bpsk_demod` | BPSK demodulator |
|
||||
| `self.frame_sync` | `pcm_frame_sync` | Frame synchronizer |
|
||||
| `self.demux` | `pcm_demux` | Frame demultiplexer |
|
||||
|
||||
---
|
||||
|
||||
## Transmit Chain
|
||||
|
||||
### `FrameSourceEngine` / `pcm_frame_source`
|
||||
|
||||
<Tabs>
|
||||
<TabItem label="Pure Python Engine">
|
||||
|
||||
**Module:** `apollo.pcm_frame_source`
|
||||
**Type:** Pure-Python class
|
||||
**Purpose:** Generate PCM telemetry frames as bit lists. Maintains rolling frame counter (1-50), auto-complements sync core on odd frames.
|
||||
|
||||
```python
|
||||
from apollo.pcm_frame_source import FrameSourceEngine
|
||||
|
||||
engine = FrameSourceEngine(bit_rate=51200)
|
||||
bits = engine.next_frame()
|
||||
```
|
||||
|
||||
#### Constructor Parameters
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
|-----------|------|---------|-------------|
|
||||
| `bit_rate` | `int` | `51200` | PCM bit rate: `51200` (high, 128 words/frame) or `1600` (low, 200 words/frame) |
|
||||
|
||||
#### Methods
|
||||
|
||||
| Method | Signature | Description |
|
||||
|--------|-----------|-------------|
|
||||
| `next_frame` | `(data: bytes \| None) -> list[int]` | Generate next frame as bit list. `data=None` gives zero-fill. Returns list of 0/1 values, length = words_per_frame * 8 |
|
||||
|
||||
#### Properties
|
||||
|
||||
| Property | Type | Description |
|
||||
|----------|------|-------------|
|
||||
| `bit_rate` | `int` | PCM bit rate in bps |
|
||||
| `words_per_frame` | `int` | 128 (high) or 200 (low) |
|
||||
| `frame_counter` | `int` | Current frame number (1-50) |
|
||||
|
||||
</TabItem>
|
||||
<TabItem label="GNU Radio Block">
|
||||
|
||||
**Module:** `apollo.pcm_frame_source`
|
||||
**Type:** `gr.sync_block`
|
||||
**Purpose:** GNU Radio source block producing continuous PCM frame bit stream. Outputs bytes (0 or 1). Accepts `frame_data` message input for dynamic payload injection.
|
||||
|
||||
```python
|
||||
from apollo.pcm_frame_source import pcm_frame_source
|
||||
|
||||
blk = pcm_frame_source(bit_rate=51200)
|
||||
```
|
||||
|
||||
#### I/O Signature
|
||||
|
||||
| Port | Direction | Type | Description |
|
||||
|------|-----------|------|-------------|
|
||||
| (none) | Input | (none) | Source block -- no streaming input |
|
||||
| `out0` | Output | `byte` | NRZ bit stream (values 0 or 1) |
|
||||
| `"frame_data"` | Input | Message | PMT u8vector or PDU for dynamic payload |
|
||||
|
||||
#### Constructor Parameters
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
|-----------|------|---------|-------------|
|
||||
| `bit_rate` | `int` | `51200` | PCM bit rate: `51200` (high, 128 words/frame) or `1600` (low, 200 words/frame) |
|
||||
|
||||
#### Internal Chain
|
||||
|
||||
Uses `FrameSourceEngine` internally with a `deque` bit buffer to bridge frame-granularity generation and sample-granularity scheduling.
|
||||
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
---
|
||||
|
||||
### `nrz_encoder`
|
||||
|
||||
**Module:** `apollo.nrz_encoder`
|
||||
**Type:** `gr.hier_block2`
|
||||
**Purpose:** Convert PCM bit stream (byte 0/1) to NRZ float waveform (+1.0/-1.0) at the output sample rate.
|
||||
|
||||
```python
|
||||
from apollo.nrz_encoder import nrz_encoder
|
||||
|
||||
blk = nrz_encoder(bit_rate=51200, sample_rate=5_120_000)
|
||||
```
|
||||
|
||||
#### I/O Signature
|
||||
|
||||
| Port | Direction | Type | Description |
|
||||
|------|-----------|------|-------------|
|
||||
| `in0` | Input | `byte` | Bit stream from `pcm_frame_source` (values 0 or 1) |
|
||||
| `out0` | Output | `float` | NRZ waveform (+1.0 / -1.0) at `sample_rate` |
|
||||
|
||||
#### Constructor Parameters
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
|-----------|------|---------|-------------|
|
||||
| `bit_rate` | `int` | `51200` | PCM bit rate in bps |
|
||||
| `sample_rate` | `float` | `5120000` | Output sample rate in Hz |
|
||||
|
||||
#### Properties
|
||||
|
||||
| Property | Type | Description |
|
||||
|----------|------|-------------|
|
||||
| `samples_per_bit` | `int` | Samples per bit period: `sample_rate / bit_rate` |
|
||||
|
||||
#### Internal Chain
|
||||
|
||||
`input -> char_to_float -> multiply_const_ff(2.0) -> add_const_ff(-1.0) -> repeat(samples_per_bit) -> output`
|
||||
|
||||
---
|
||||
|
||||
### `bpsk_subcarrier_mod`
|
||||
|
||||
**Module:** `apollo.bpsk_subcarrier_mod`
|
||||
**Type:** `gr.hier_block2`
|
||||
**Purpose:** Modulate NRZ float data onto 1.024 MHz cosine subcarrier via multiplication: `output(t) = nrz(t) * cos(2*pi*f_sc*t)`.
|
||||
|
||||
```python
|
||||
from apollo.bpsk_subcarrier_mod import bpsk_subcarrier_mod
|
||||
|
||||
blk = bpsk_subcarrier_mod(subcarrier_freq=1_024_000, sample_rate=5_120_000)
|
||||
```
|
||||
|
||||
#### I/O Signature
|
||||
|
||||
| Port | Direction | Type | Description |
|
||||
|------|-----------|------|-------------|
|
||||
| `in0` | Input | `float` | NRZ waveform from `nrz_encoder` |
|
||||
| `out0` | Output | `float` | BPSK modulated subcarrier |
|
||||
|
||||
#### Constructor Parameters
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
|-----------|------|---------|-------------|
|
||||
| `subcarrier_freq` | `float` | `1024000` | Subcarrier frequency in Hz (`PCM_SUBCARRIER_HZ`) |
|
||||
| `sample_rate` | `float` | `5120000` | Sample rate in Hz |
|
||||
|
||||
#### Properties
|
||||
|
||||
| Property | Type | Description |
|
||||
|----------|------|-------------|
|
||||
| `subcarrier_freq` | `float` | Subcarrier frequency in Hz |
|
||||
| `sample_rate` | `float` | Sample rate in Hz |
|
||||
|
||||
#### Internal Chain
|
||||
|
||||
`input (NRZ) -> (mixer, port 0); sig_source_f(cos, subcarrier_freq) -> (mixer, port 1); mixer -> output`
|
||||
|
||||
---
|
||||
|
||||
### `fm_voice_subcarrier_mod`
|
||||
|
||||
**Module:** `apollo.fm_voice_subcarrier_mod`
|
||||
**Type:** `gr.hier_block2`
|
||||
**Purpose:** FM-modulate audio onto 1.25 MHz subcarrier with +/-29 kHz deviation. Two modes: internal test tone (source block) or external audio input.
|
||||
|
||||
```python
|
||||
# Internal test tone (no input)
|
||||
from apollo.fm_voice_subcarrier_mod import fm_voice_subcarrier_mod
|
||||
|
||||
voice = fm_voice_subcarrier_mod(tone_freq=1000.0)
|
||||
|
||||
# External audio input
|
||||
voice = fm_voice_subcarrier_mod(audio_input=True)
|
||||
```
|
||||
|
||||
#### I/O Signature
|
||||
|
||||
<Aside type="caution">
|
||||
The I/O signature changes based on the `audio_input` parameter. When `audio_input=False` (default), the block is a source with no streaming input. When `audio_input=True`, it accepts one float input for the external audio stream.
|
||||
</Aside>
|
||||
|
||||
| Port | Direction | Type | Description |
|
||||
|------|-----------|------|-------------|
|
||||
| `in0` | Input | `float` (or none) | External audio signal when `audio_input=True`; no input when `False` |
|
||||
| `out0` | Output | `float` | FM subcarrier at `subcarrier_freq` |
|
||||
|
||||
#### Constructor Parameters
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
|-----------|------|---------|-------------|
|
||||
| `sample_rate` | `float` | `5120000` | Sample rate in Hz |
|
||||
| `subcarrier_freq` | `float` | `1250000` | Voice subcarrier center frequency (`VOICE_SUBCARRIER_HZ`) |
|
||||
| `fm_deviation` | `float` | `29000` | FM deviation in Hz (`VOICE_FM_DEVIATION_HZ`) |
|
||||
| `tone_freq` | `float` | `1000.0` | Internal test tone frequency when `audio_input=False` |
|
||||
| `audio_input` | `bool` | `False` | When `True`, accepts external float audio stream |
|
||||
|
||||
#### Properties
|
||||
|
||||
| Property | Type | Description |
|
||||
|----------|------|-------------|
|
||||
| `tone_freq` | `float` | Test tone frequency in Hz |
|
||||
| `subcarrier_freq` | `float` | Subcarrier center frequency in Hz |
|
||||
| `fm_deviation` | `float` | FM deviation in Hz |
|
||||
| `audio_input` | `bool` | Whether block accepts external audio |
|
||||
|
||||
#### Internal Chain
|
||||
|
||||
`[audio source or input] -> frequency_modulator_fc(sensitivity) -> (mixer, 0); sig_source_c(subcarrier_freq) -> (mixer, 1); mixer -> complex_to_real -> output`
|
||||
|
||||
---
|
||||
|
||||
### `pm_mod`
|
||||
|
||||
**Module:** `apollo.pm_mod`
|
||||
**Type:** `gr.hier_block2`
|
||||
**Purpose:** Apply phase modulation at 0.133 rad peak deviation, producing complex baseband `exp(j * phi(t))`.
|
||||
|
||||
```python
|
||||
from apollo.pm_mod import pm_mod
|
||||
|
||||
blk = pm_mod(pm_deviation=0.133, sample_rate=5_120_000)
|
||||
```
|
||||
|
||||
#### I/O Signature
|
||||
|
||||
| Port | Direction | Type | Description |
|
||||
|------|-----------|------|-------------|
|
||||
| `in0` | Input | `float` | Composite modulating signal (sum of subcarriers) |
|
||||
| `out0` | Output | `complex` | PM complex baseband |
|
||||
|
||||
#### Constructor Parameters
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
|-----------|------|---------|-------------|
|
||||
| `pm_deviation` | `float` | `0.133` | Peak PM deviation in radians (`PM_PEAK_DEVIATION_RAD`) |
|
||||
| `sample_rate` | `float` | `5120000` | Sample rate in Hz |
|
||||
|
||||
#### Runtime Methods
|
||||
|
||||
| Method | Signature | Description |
|
||||
|--------|-----------|-------------|
|
||||
| `get_pm_deviation` | `() -> float` | Read current PM deviation |
|
||||
| `set_pm_deviation` | `(dev: float) -> None` | Update PM deviation at runtime |
|
||||
|
||||
#### Internal Chain
|
||||
|
||||
`input -> multiply_const_ff(pm_deviation) -> phase_modulator_fc(1.0) -> output`
|
||||
|
||||
<Aside type="note">
|
||||
The sensitivity is split between two stages: `multiply_const_ff` applies the deviation scaling so that `phase_modulator_fc` sees pre-scaled values with a fixed sensitivity of 1.0. This makes runtime deviation changes a simple gain update via `set_pm_deviation`.
|
||||
</Aside>
|
||||
|
||||
---
|
||||
|
||||
### `sco_mod`
|
||||
|
||||
**Module:** `apollo.sco_mod`
|
||||
**Type:** `gr.hier_block2`
|
||||
**Purpose:** Modulate 0-5V sensor voltage onto an FM subcarrier oscillator tone. Only used in FM downlink mode. 9 channels available (14.5 kHz to 165 kHz).
|
||||
|
||||
```python
|
||||
from apollo.sco_mod import sco_mod
|
||||
|
||||
blk = sco_mod(sco_number=5, sample_rate=5_120_000)
|
||||
```
|
||||
|
||||
#### I/O Signature
|
||||
|
||||
| Port | Direction | Type | Description |
|
||||
|------|-----------|------|-------------|
|
||||
| `in0` | Input | `float` | Sensor voltage (0.0 to 5.0 V) |
|
||||
| `out0` | Output | `float` | FM subcarrier tone |
|
||||
|
||||
#### Constructor Parameters
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
|-----------|------|---------|-------------|
|
||||
| `sco_number` | `int` | `1` | SCO channel number (1-9). Raises `ValueError` if invalid |
|
||||
| `sample_rate` | `float` | `5120000` | Sample rate in Hz |
|
||||
|
||||
#### Properties
|
||||
|
||||
| Property | Type | Description |
|
||||
|----------|------|-------------|
|
||||
| `center_freq` | `float` | Center frequency of this SCO channel in Hz |
|
||||
| `deviation_hz` | `float` | FM deviation in Hz (+/- 7.5% of center) |
|
||||
| `sco_number` | `int` | SCO channel number (1-9) |
|
||||
|
||||
#### Internal Chain
|
||||
|
||||
`input -> add_const_ff(-2.5) -> multiply_const_ff(1/2.5) -> frequency_modulator_fc -> (mixer, 0); sig_source_c(center_freq) -> (mixer, 1); mixer -> complex_to_real -> output`
|
||||
|
||||
#### Valid SCO Channels
|
||||
|
||||
| SCO Number | Center Frequency | Deviation (+/-) | Bandwidth (15%) |
|
||||
|------------|-----------------|------------------|-----------------|
|
||||
| 1 | 14,500 Hz | 1,087.5 Hz | 2,175 Hz |
|
||||
| 2 | 22,000 Hz | 1,650 Hz | 3,300 Hz |
|
||||
| 3 | 30,000 Hz | 2,250 Hz | 4,500 Hz |
|
||||
| 4 | 40,000 Hz | 3,000 Hz | 6,000 Hz |
|
||||
| 5 | 52,500 Hz | 3,937.5 Hz | 7,875 Hz |
|
||||
| 6 | 70,000 Hz | 5,250 Hz | 10,500 Hz |
|
||||
| 7 | 95,000 Hz | 7,125 Hz | 14,250 Hz |
|
||||
| 8 | 125,000 Hz | 9,375 Hz | 18,750 Hz |
|
||||
| 9 | 165,000 Hz | 12,375 Hz | 24,750 Hz |
|
||||
|
||||
---
|
||||
|
||||
### `usb_signal_source`
|
||||
|
||||
**Module:** `apollo.usb_signal_source`
|
||||
**Type:** `gr.hier_block2`
|
||||
**Purpose:** Complete Apollo USB transmit chain in one block -- the TX counterpart to `usb_downlink_receiver`.
|
||||
|
||||
```python
|
||||
from apollo.usb_signal_source import usb_signal_source
|
||||
|
||||
blk = usb_signal_source(voice_enabled=True, snr_db=20.0)
|
||||
```
|
||||
|
||||
#### I/O Signature
|
||||
|
||||
| Port | Direction | Type | Description |
|
||||
|------|-----------|------|-------------|
|
||||
| (none) | Input | (none) | Source block -- no streaming input |
|
||||
| `out0` | Output | `complex` | PM-modulated complex baseband at `sample_rate` |
|
||||
| `"frame_data"` | Input | Message | Forwarded to `pcm_frame_source` for dynamic payload |
|
||||
|
||||
#### Constructor Parameters
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
|-----------|------|---------|-------------|
|
||||
| `sample_rate` | `float` | `5120000` | Baseband sample rate in Hz |
|
||||
| `bit_rate` | `int` | `51200` | PCM bit rate in bps |
|
||||
| `pm_deviation` | `float` | `0.133` | PM peak deviation in radians |
|
||||
| `voice_enabled` | `bool` | `False` | Include 1.25 MHz FM voice subcarrier |
|
||||
| `voice_tone_hz` | `float` | `1000.0` | Voice test tone frequency when `voice_enabled=True` |
|
||||
| `snr_db` | `float \| None` | `None` | Add AWGN at this SNR. `None` means no noise |
|
||||
|
||||
#### Internal Signal Chain
|
||||
|
||||
```
|
||||
pcm_frame_source -> nrz_encoder -> bpsk_subcarrier_mod -+-> add_ff -> pm_mod -> [+AWGN] -> output
|
||||
|
|
||||
fm_voice_subcarrier_mod (x0.764) --+
|
||||
(only when voice_enabled=True)
|
||||
```
|
||||
|
||||
Voice scale factor: `1.68 / 2.2 = 0.764` (per IMPL_SPEC power ratio -- PCM at 2.2 Vpp, voice at 1.68 Vpp).
|
||||
|
||||
#### Sub-Block Access
|
||||
|
||||
The internal blocks are exposed as instance attributes for runtime inspection or parameter adjustment:
|
||||
|
||||
| Attribute | Type | Block |
|
||||
|-----------|------|-------|
|
||||
| `self.frame_src` | `pcm_frame_source` | Frame generator |
|
||||
| `self.nrz` | `nrz_encoder` | NRZ line encoder |
|
||||
| `self.bpsk` | `bpsk_subcarrier_mod` | BPSK modulator |
|
||||
| `self.voice` | `fm_voice_subcarrier_mod` | Voice modulator (when `voice_enabled`) |
|
||||
| `self.voice_gain` | `multiply_const_ff` | Voice level scaling (when `voice_enabled`) |
|
||||
| `self.adder` | `add_ff` | Subcarrier summer (when `voice_enabled`) |
|
||||
| `self.pm` | `pm_mod` | Phase modulator |
|
||||
| `self.noise` | `noise_source_c` | AWGN source (when `snr_db` is set) |
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user