Compare commits

..

10 Commits

Author SHA1 Message Date
50060e48e9 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
2026-02-23 14:13:46 -07:00
cb77b18a9c Add full downlink demo: PCM telemetry + crew voice on one carrier
Assembles the complete Apollo USB downlink signal from individual blocks:
PCM frames on 1.024 MHz BPSK + crew voice on 1.25 MHz FM, both PM-modulated
onto a single complex carrier. Receives and splits into decoded PCM frames
and recovered voice audio.

Clean: 399/402 frames, 8s voice. At 25 dB SNR: 395/402 frames.
2026-02-22 18:06:22 -07:00
8728d36a90 Add voice subcarrier demo with Apollo 11 crew audio
FM-modulates real Apollo 11 onboard audio onto the 1.25 MHz voice
subcarrier (+/-29 kHz deviation) and demodulates it back, achieving
94.1% correlation with the original. Audio source: NASA/Internet
Archive public domain (Collins bidding farewell to Eagle crew).
2026-02-22 18:00:45 -07:00
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
493c21c511 Add transmit chain: 6 composable GR source blocks mirroring CuriousMarc bench
Implement the transmit/generate side as streaming GNU Radio blocks,
complementing the existing receive chain. Each block maps to a physical
instrument on CuriousMarc's Keysight bench:

  pcm_frame_source  - PCM bit stream generator (sync_block + FrameSourceEngine)
  nrz_encoder       - bits to NRZ waveform (+1/-1) with upsampling
  bpsk_subcarrier_mod - NRZ x cos(1.024 MHz) BPSK modulator
  fm_voice_subcarrier_mod - 1.25 MHz FM test tone source
  pm_mod            - phase modulator: exp(j * deviation * input)
  usb_signal_source - convenience wrapper wiring all blocks together

Includes GRC YAML definitions for all blocks under [Apollo USB] category,
49 new tests (271 total, all passing), and a loopback test that validates
the full TX->RX round trip including frame recovery with 30 dB AWGN.
2026-02-21 18:55:50 -07:00
0dffcdbb54 Add CuriousMarc Keysight and lab tour video links 2026-02-21 10:48:34 -07:00
200fa4e6dc Add reference links and credit CuriousMarc restoration team 2026-02-21 10:41:25 -07:00
a06f5e8dc1 Add Open Graph image generation for all docs pages
Custom renderer with NASA-blue theme, Inter font, signal arc decoration.
Generates 1200x630 PNG per page at build time via astro-opengraph-images.
Head component injects og:image meta tag using getImagePath().
2026-02-21 09:28:45 -07:00
db14b85633 Add Docker deployment for docs site at gr-apollo.l.warehack.ing
Multi-stage Dockerfile: node:22-slim build → caddy:2-alpine serve.
caddy-docker-proxy labels for automatic TLS + reverse proxy.
Profiles: prod (Caddy static) and dev (Astro HMR with volume mounts).
2026-02-21 09:10:16 -07:00
9dc52d1a5b Docs: replace default favicon, add git icon, fix Mermaid direction
- Favicon: MSFN antenna dish with signal arcs (NASA blue/red, dark mode)
- Social icon: generic git-branch replacing GitHub-specific icon
- Mermaid signal chain diagram: flowchart TB for vertical readability
- Add astro-icon (with Lucide icons) and astro-opengraph-images packages
2026-02-21 09:10:07 -07:00
55 changed files with 7509 additions and 24 deletions

8
docs/.dockerignore Normal file
View File

@ -0,0 +1,8 @@
node_modules/
dist/
.astro/
.git/
.env
.env.*
Makefile
docker-compose.yml

2
docs/.env.example Normal file
View File

@ -0,0 +1,2 @@
COMPOSE_PROJECT=gr-apollo-docs
DOMAIN=gr-apollo.l.warehack.ing

7
docs/Caddyfile Normal file
View File

@ -0,0 +1,7 @@
:80 {
root * /srv
file_server
encode gzip zstd
header Cache-Control "public, max-age=3600"
try_files {path} {path}/ /404.html
}

17
docs/Dockerfile Normal file
View File

@ -0,0 +1,17 @@
# Stage 1: Install dependencies
FROM node:22-slim AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
# Stage 2: Build static site
FROM deps AS build
COPY . .
ENV ASTRO_TELEMETRY_DISABLED=1
RUN npm run build
# Stage 3: Serve with Caddy
FROM caddy:2-alpine
COPY --from=build /app/dist /srv
COPY Caddyfile /etc/caddy/Caddyfile
EXPOSE 80

18
docs/Makefile Normal file
View File

@ -0,0 +1,18 @@
.PHONY: up down logs restart dev
up:
docker compose --profile prod up -d --build
down:
docker compose --profile prod down
docker compose --profile dev down
logs:
docker compose logs -f
restart:
docker compose --profile prod down
docker compose --profile prod up -d --build
dev:
docker compose --profile dev up --build

View File

@ -1,16 +1,22 @@
// @ts-check
import { defineConfig } from 'astro/config';
import starlight from '@astrojs/starlight';
import icon from 'astro-icon';
import rehypeMermaid from 'rehype-mermaid';
import opengraphImages from 'astro-opengraph-images';
import { render as ogRender } from './src/og-image.tsx';
import * as fs from 'node:fs';
const interRegular = fs.readFileSync('node_modules/@fontsource/inter/files/inter-latin-400-normal.woff');
const interBold = fs.readFileSync('node_modules/@fontsource/inter/files/inter-latin-700-normal.woff');
export default defineConfig({
site: 'https://gr-apollo.l.warehack.ing',
integrations: [
icon(),
starlight({
title: 'gr-apollo',
description: 'Apollo Unified S-Band decoder for GNU Radio 3.10+',
social: [
{ icon: 'github', label: 'GitHub', href: 'https://github.com/rpm/gr-apollo' },
],
description: 'Apollo Unified S-Band transmitter and decoder for GNU Radio 3.10+',
sidebar: [
{
label: 'Getting Started',
@ -33,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' },
],
@ -52,8 +61,28 @@ export default defineConfig({
},
components: {
Head: './src/components/Head.astro',
SocialIcons: './src/components/SocialIcons.astro',
},
}),
opengraphImages({
options: {
fonts: [
{
name: 'Inter',
weight: 400,
style: 'normal',
data: interRegular,
},
{
name: 'Inter',
weight: 700,
style: 'normal',
data: interBold,
},
],
},
render: ogRender,
}),
],
markdown: {
rehypePlugins: [

38
docs/docker-compose.yml Normal file
View File

@ -0,0 +1,38 @@
services:
docs:
build: .
restart: unless-stopped
networks:
- caddy
labels:
caddy: ${DOMAIN}
caddy.reverse_proxy: "{{upstreams 80}}"
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:80/"]
interval: 30s
timeout: 5s
retries: 3
profiles:
- prod
docs-dev:
build:
context: .
target: deps
command: npx astro dev --host 0.0.0.0 --port 4321
ports:
- "4321:4321"
volumes:
- ./src:/app/src
- ./public:/app/public
- ./astro.config.mjs:/app/astro.config.mjs
- ./tsconfig.json:/app/tsconfig.json
environment:
- ASTRO_TELEMETRY_DISABLED=1
- VITE_HMR_HOST=${VITE_HMR_HOST:-}
profiles:
- dev
networks:
caddy:
external: true

2340
docs/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -11,9 +11,16 @@
},
"dependencies": {
"@astrojs/starlight": "^0.37.6",
"@iconify-json/lucide": "^1.2.92",
"astro": "^5.6.1",
"astro-icon": "^1.1.5",
"astro-opengraph-images": "^1.14.3",
"mermaid": "^11.12.3",
"rehype-mermaid": "^3.0.0",
"sharp": "^0.34.2"
},
"devDependencies": {
"@fontsource/inter": "^5.2.8",
"react": "^19.2.4"
}
}

View File

@ -1 +1,28 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128"><path fill-rule="evenodd" d="M81 36 64 0 47 36l-1 2-9-10a6 6 0 0 0-9 9l10 10h-2L0 64l36 17h2L28 91a6 6 0 1 0 9 9l9-10 1 2 17 36 17-36v-2l9 10a6 6 0 1 0 9-9l-9-9 2-1 36-17-36-17-2-1 9-9a6 6 0 1 0-9-9l-9 10v-2Zm-17 2-2 5c-4 8-11 15-19 19l-5 2 5 2c8 4 15 11 19 19l2 5 2-5c4-8 11-15 19-19l5-2-5-2c-8-4-15-11-19-19l-2-5Z" clip-rule="evenodd"/><path d="M118 19a6 6 0 0 0-9-9l-3 3a6 6 0 1 0 9 9l3-3Zm-96 4c-2 2-6 2-9 0l-3-3a6 6 0 1 1 9-9l3 3c3 2 3 6 0 9Zm0 82c-2-2-6-2-9 0l-3 3a6 6 0 1 0 9 9l3-3c3-2 3-6 0-9Zm96 4a6 6 0 0 1-9 9l-3-3a6 6 0 1 1 9-9l3 3Z"/><style>path{fill:#000}@media (prefers-color-scheme:dark){path{fill:#fff}}</style></svg>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
<defs>
<style>
.s{fill:none;stroke:#0B3D91;stroke-linecap:round;stroke-linejoin:round}
.f{fill:#0B3D91}
.r{fill:none;stroke:#FC3D21;stroke-linecap:round}
@media(prefers-color-scheme:dark){
.s{stroke:#7EB8FF}
.f{fill:#7EB8FF}
}
</style>
</defs>
<!-- Dish reflector -->
<path class="s" d="M18 78Q64 108 110 78" stroke-width="10"/>
<!-- Struts to feed horn -->
<line class="s" x1="34" y1="82" x2="64" y2="42" stroke-width="5"/>
<line class="s" x1="94" y1="82" x2="64" y2="42" stroke-width="5"/>
<!-- Feed horn -->
<circle class="f" cx="64" cy="40" r="6"/>
<!-- Pedestal -->
<line class="s" x1="64" y1="88" x2="64" y2="116" stroke-width="8"/>
<!-- Base plate -->
<line class="s" x1="44" y1="116" x2="84" y2="116" stroke-width="8"/>
<!-- Signal arcs -->
<path class="r" d="M56 30Q64 22 72 30" stroke-width="4"/>
<path class="r" d="M48 22Q64 10 80 22" stroke-width="4" opacity=".65"/>
<path class="r" d="M40 14Q64-2 88 14" stroke-width="4" opacity=".35"/>
</svg>

Before

Width:  |  Height:  |  Size: 696 B

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -2,7 +2,11 @@
import type { Props } from '@astrojs/starlight/props';
import Default from '@astrojs/starlight/components/Head.astro';
import MermaidInit from './MermaidInit.astro';
import { getImagePath } from 'astro-opengraph-images';
const ogImageUrl = getImagePath({ url: Astro.url, site: Astro.site });
---
<Default {...Astro.props}><slot /></Default>
<meta property="og:image" content={ogImageUrl} />
<MermaidInit />

View File

@ -0,0 +1,33 @@
---
// Generic Git icon replacing Starlight's GitHub-specific social icon
---
<a href="https://github.com/rpm/gr-apollo" rel="me" class="sl-flex git-link" aria-label="Source code">
<svg
aria-hidden="true"
width="1em"
height="1em"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<line x1="6" y1="3" x2="6" y2="15" />
<circle cx="18" cy="6" r="3" />
<circle cx="6" cy="18" r="3" />
<path d="M18 9a9 9 0 0 1-9 9" />
</svg>
</a>
<style>
.git-link {
color: var(--sl-color-text-accent);
text-decoration: none;
padding: 0.5em;
}
.git-link:hover {
opacity: 0.66;
}
</style>

View File

@ -7,6 +7,8 @@ import { Aside, Tabs, TabItem } from '@astrojs/starlight/components';
The PCM (Pulse Code Modulation) telemetry system converts analog sensor readings and digital status words from the spacecraft into a serial bit stream. This stream is organized into fixed-length frames, each beginning with a sync word that lets the ground station find the frame boundaries in the raw data. Understanding the frame structure is essential for interpreting anything the spacecraft sends.
Frame formats, sync word structure, and A/D converter specifications are from the [NAA Telecommunication Systems Study Guide (Course A-624)](https://archive.org/details/apollo-telecommunications). AGC channel assignments are from the [Virtual AGC project](https://www.ibiblio.org/apollo/).
## Frame layout
Each high-rate frame contains exactly 128 eight-bit words, for a total of 1024 bits. The first four words (32 bits) are the sync word. The remaining 124 words carry telemetry data.

View File

@ -7,6 +7,8 @@ import { Aside, Tabs, TabItem } from '@astrojs/starlight/components';
The Apollo Unified S-Band (USB) system multiplexes voice, telemetry, and ranging onto a single 2287.5 MHz carrier using nested modulation layers. Understanding these layers -- and the reasons behind each design choice -- is the key to understanding what gr-apollo does and why its blocks are structured the way they are.
All parameters in this section are from the [NAA Telecommunication Systems Study Guide (Course A-624)](https://archive.org/details/apollo-telecommunications).
## The downlink signal, inside out
The spacecraft transmitter begins with a stable carrier at 2287.5 MHz. Multiple information streams are combined onto this carrier through a two-level modulation scheme: subcarriers are first individually modulated with their data, then the composite subcarrier signal phase-modulates the RF carrier.
@ -48,7 +50,7 @@ At small modulation indices, the relationship between the modulating signal and
This linearity means the PM demodulator output is a faithful reproduction of the composite subcarrier signal. There is no need for pre-distortion or nonlinear correction -- the receiver just extracts the phase and gets the subcarriers back, ready for filtering and demodulation.
The tradeoff is signal power. Most of the transmitted power remains in the carrier rather than the sidebands. The spacecraft burns roughly 20 watts of RF power (via the traveling-wave tube amplifier), but only a fraction of a watt ends up in each subcarrier. The Deep Space Network's 26-meter dishes made up the difference with raw antenna gain.
The tradeoff is signal power. Most of the transmitted power remains in the carrier rather than the sidebands. The spacecraft burns roughly 20 watts of RF power (via the traveling-wave tube amplifier), but only a fraction of a watt ends up in each subcarrier. The [Deep Space Network's](https://www.nasa.gov/communicating-with-missions/dsn/) 26-meter dishes made up the difference with raw antenna gain.
<Aside type="note">
The 0.133 rad deviation is for the combined PCM mode (voice + telemetry). During FM-only mode (used for pre-launch checkout), the spacecraft switches to wideband FM with much higher deviation, and the subcarrier oscillator system is used instead of PCM.
@ -171,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>

View File

@ -283,3 +283,9 @@ The default TCP port is 19697 (`AGC_PORT_BASE`). If you are running multiple yaA
</Aside>
The GNU Radio wrapper (`agc_bridge` block) exposes three message ports: `uplink_data` (input), `downlink_data` (output), and `status` (output). The status port emits the connection state string whenever it changes, which can be connected to a QT GUI label or logged for monitoring.
## Hardware restoration
While gr-apollo works with the Virtual AGC *emulator*, others have gotten the real hardware running. [CuriousMarc (Marc Verdiell)](https://www.curiousmarc.com/space), along with Mike Stewart, [Ken Shirriff](https://www.righto.com/search/label/Apollo), and a team of volunteers, have restored actual Apollo Guidance Computers and S-Band telecommunications equipment to operational status. Their work provides invaluable validation that the specifications gr-apollo implements match the behavior of real flight hardware.
Of particular interest: [Building an Apollo transmit station with Keysight instruments](https://www.youtube.com/watch?v=ctNmYFfxI7w) shows modern Keysight signal generators and analyzers driving the same S-Band uplink chain that gr-apollo models in software. [Inside the WILD Lab of CuriousMarc](https://www.youtube.com/watch?v=qwocVH3_1Eo) is a Keysight-produced tour of the lab where the restoration work happens.

View File

@ -31,7 +31,7 @@ gr-apollo provides a set of GNU Radio blocks and pure-Python engines that implem
The downlink receiver processes a phase-modulated carrier through five stages. Each stage has a standalone GNU Radio block, or you can use `usb_downlink_receiver` which chains them together.
```mermaid
flowchart LR
flowchart TB
A["Complex<br/>Baseband<br/>5.12 MHz"] --> B["PM Demod<br/><em>Carrier PLL +<br/>phase extraction</em>"]
B --> C["Subcarrier<br/>Extract<br/><em>BPF + translate<br/>1.024 MHz → DC</em>"]
C --> D["BPSK Demod<br/><em>Costas loop +<br/>symbol sync</em>"]
@ -85,6 +85,19 @@ from apollo import pm_demod, bpsk_demod, pcm_frame_sync, usb_downlink_receiver
You can use the signal generator and frame processing engines without GNU Radio installed. This is useful for offline analysis, unit testing, or environments where GR is hard to install.
</Aside>
## References
gr-apollo is based on these primary sources:
- [NAA Telecommunication Systems Study Guide (Course A-624, 1965)](https://archive.org/details/apollo-telecommunications) -- the original North American Aviation training manual for the Apollo USB system. Defines all frequencies, modulation parameters, frame formats, and SCO specifications used in gr-apollo.
- [Virtual AGC Project](https://www.ibiblio.org/apollo/) -- Ron Burkey's Apollo Guidance Computer emulator and document archive. The AGC bridge protocol and downlink list decoding are based on the [yaAGC source code](https://github.com/virtualagc/virtualagc).
- [NASA Technical Reports Server](https://ntrs.nasa.gov/) -- primary source for Apollo spacecraft telecommunications system descriptions and MSFN ground station specifications.
### Community
- [CuriousMarc (Marc Verdiell)](https://www.curiousmarc.com/space) — Marc and his team (Mike Stewart, Ken Shirriff, and others) have been restoring and operating original Apollo S-Band flight hardware. Their [YouTube channel](https://www.youtube.com/@CuriousMarc) documents the restoration process in detail — see [Building an Apollo transmit station with Keysight instruments](https://www.youtube.com/watch?v=ctNmYFfxI7w) for a look at how modern test equipment talks to 1960s flight hardware.
- [Ken Shirriff's Blog](https://www.righto.com/search/label/Apollo) — detailed reverse-engineering posts on Apollo-era hardware, from AGC core rope memory to the USB signal processing chains.
## Next step
<LinkCard

View File

@ -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."

View 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>

View 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>

View File

@ -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:

View 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>

View File

@ -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:

View File

@ -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>

View File

@ -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) |

View File

@ -5,7 +5,7 @@ description: "Complete reference for all Apollo Unified S-Band system constants
import { Tabs, TabItem, Aside } from '@astrojs/starlight/components';
All constants are defined in `apollo.constants` and trace directly to the 1965 NAA Telecommunication Systems Study Guide (Course A-624) via the IMPLEMENTATION_SPEC.md section references noted in each table.
All constants are defined in `apollo.constants` and trace directly to the [1965 NAA Telecommunication Systems Study Guide (Course A-624)](https://archive.org/details/apollo-telecommunications) via the IMPLEMENTATION_SPEC.md section references noted in each table.
```python
from apollo.constants import DOWNLINK_FREQ_HZ, PCM_HIGH_BIT_RATE, SCO_FREQUENCIES
@ -253,7 +253,7 @@ AGC_TELECOM_CHANNELS = frozenset({
### Downlink List Type IDs
From `DecodeDigitalDownlink.c` in the Virtual AGC project:
From `DecodeDigitalDownlink.c` in the [Virtual AGC project](https://github.com/virtualagc/virtualagc):
| Constant | Value | Description |
|----------|-------|-------------|

View File

@ -214,7 +214,7 @@ def bits_to_sync_word(bits: list[int]) -> int
The Apollo Guidance Computer emulator (yaAGC) communicates over TCP using a 4-byte packet format. Each packet carries one I/O channel update: a 9-bit channel number and a 15-bit data value.
These functions are direct ports of `FormIoPacket()` and `ParseIoPacket()` from `yaAGC/SocketAPI.c`.
These functions are direct ports of `FormIoPacket()` and `ParseIoPacket()` from [`yaAGC/SocketAPI.c`](https://github.com/virtualagc/virtualagc) in the Virtual AGC project.
### Packet Bit Layout

114
docs/src/og-image.tsx Normal file
View File

@ -0,0 +1,114 @@
import React from "react";
import type { RenderFunctionInput } from "astro-opengraph-images";
export async function render({
title,
description,
}: RenderFunctionInput): Promise<React.ReactNode> {
return (
<div
style={{
height: "100%",
width: "100%",
display: "flex",
flexDirection: "column",
justifyContent: "space-between",
backgroundColor: "#0B1426",
padding: "60px 70px",
fontFamily: "Inter",
}}
>
{/* Signal arc decorations — top right */}
<svg
viewBox="0 0 200 200"
width="200"
height="200"
style={{ position: "absolute", top: "30", right: "50", opacity: 0.15 }}
>
<path
d="M40 180 Q100 60 160 180"
fill="none"
stroke="#FC3D21"
stroke-width="8"
/>
<path
d="M70 180 Q100 90 130 180"
fill="none"
stroke="#FC3D21"
stroke-width="6"
/>
<path
d="M90 180 Q100 120 110 180"
fill="none"
stroke="#FC3D21"
stroke-width="4"
/>
</svg>
{/* Top: site name */}
<div
style={{
display: "flex",
alignItems: "center",
gap: "12px",
color: "#7EB8FF",
fontSize: 28,
fontWeight: 400,
letterSpacing: "0.05em",
}}
>
gr-apollo
</div>
{/* Middle: title + description */}
<div
style={{
display: "flex",
flexDirection: "column",
gap: "16px",
flexGrow: "1",
justifyContent: "center",
}}
>
<div
style={{
color: "#FFFFFF",
fontSize: 56,
fontWeight: 700,
lineHeight: 1.15,
}}
>
{title}
</div>
{description && (
<div
style={{
color: "#8BA4C4",
fontSize: 26,
fontWeight: 400,
lineHeight: 1.4,
}}
>
{description}
</div>
)}
</div>
{/* Bottom: tagline */}
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
borderTop: "1px solid #1E3A5F",
paddingTop: "20px",
color: "#4A6A8A",
fontSize: 20,
}}
>
<span>Apollo Unified S-Band Decoder</span>
<span>GNU Radio 3.10+</span>
</div>
</div>
);
}

View File

@ -0,0 +1,186 @@
#!/usr/bin/env python3
"""
Apollo AGC Integration Demo -- full communications loop with Virtual AGC.
Demonstrates the complete Apollo unified S-band communications path:
yaAGC (emulator)
| DNTM1/DNTM2 telemetry via TCP
v
agc_bridge
| PDU message
v
downlink_decoder --> print decoded telemetry
^
| PDU frames
usb_downlink_receiver (RX chain)
^
| complex baseband
usb_signal_source (TX chain)
And the uplink path:
DSKY commands
|
uplink_encoder
| (channel, value) pairs
v
agc_bridge --> yaAGC (INLINK channel 045)
Prerequisites:
1. Install Virtual AGC: https://www.ibiblio.org/apollo/
2. Start yaAGC with a mission (e.g., Luminary099 for Apollo 11 LM):
$ yaAGC --core=Luminary099.bin --port=19697
3. Optionally start yaDSKY2 for visual display:
$ yaDSKY2 --port=19698
Usage:
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 --port 19697
uv run python examples/agc_loopback_demo.py --send-v16n36 # request time display
"""
import argparse
import sys
import time
from apollo.agc_bridge import AGCBridgeClient
from apollo.constants import (
AGC_CH_DNTM1,
AGC_CH_DNTM2,
AGC_CH_OUTLINK,
AGC_PORT_BASE,
)
from apollo.downlink_decoder import DownlinkEngine
from apollo.uplink_encoder import UplinkEncoder
def main():
parser = argparse.ArgumentParser(
description="Apollo AGC integration demo -- connect to yaAGC emulator"
)
parser.add_argument("--host", default="localhost", help="yaAGC host (default: localhost)")
parser.add_argument("--port", type=int, default=AGC_PORT_BASE,
help="yaAGC port (default: 19697)")
parser.add_argument("--duration", type=float, default=10.0, help="Run duration in seconds")
parser.add_argument("--send-v16n36", action="store_true",
help="Send V16N36E (display time) to AGC")
args = parser.parse_args()
print("=" * 60)
print("Apollo AGC Integration Demo")
print("=" * 60)
print(f" Target: {args.host}:{args.port}")
print(f" Duration: {args.duration} seconds")
print()
# Downlink decoder accumulates telemetry words
decoder = DownlinkEngine()
packet_count = 0
telemetry_words = 0
def on_packet(channel: int, value: int):
nonlocal packet_count, telemetry_words
packet_count += 1
if channel in (AGC_CH_DNTM1, AGC_CH_DNTM2):
telemetry_words += 1
decoder.feed_agc_word(channel, value)
elif channel == AGC_CH_OUTLINK:
print(f" OUTLINK: ch={channel:03o} val={value:05o} ({value})")
def on_status(state: str):
print(f" Connection: {state}")
# Connect to yaAGC
client = AGCBridgeClient(
host=args.host,
port=args.port,
channel_filter=None, # accept all channels for this demo
on_packet=on_packet,
on_status=on_status,
)
print(f"Connecting to yaAGC at {args.host}:{args.port}...")
client.start()
# Wait for connection
for _ in range(20): # 10 seconds max
if client.connected:
break
time.sleep(0.5)
if not client.connected:
print()
print("Could not connect to yaAGC.")
print()
print("Make sure yaAGC is running:")
print(f" yaAGC --core=Luminary099.bin --port={args.port}")
print()
print("Or try a different host/port:")
print(" python examples/agc_loopback_demo.py --host <ip> --port <port>")
client.stop()
sys.exit(1)
print()
# Optionally send a DSKY command
if args.send_v16n36:
print("Sending V16N36E (display time)...")
encoder = UplinkEncoder()
pairs = encoder.encode_verb_noun(verb=16, noun=36)
for channel, value in pairs:
client.send(channel, value)
time.sleep(0.1) # pace for UPRUPT processing
print(f" Sent {len(pairs)} uplink words")
print()
# Collect telemetry for the specified duration
print(f"Collecting telemetry for {args.duration} seconds...")
print("-" * 60)
start_time = time.time()
last_snapshot_count = 0
try:
while time.time() - start_time < args.duration:
time.sleep(0.5)
# Check for new telemetry snapshots
snapshots = decoder._completed_snapshots
if len(snapshots) > last_snapshot_count:
for snap in snapshots[last_snapshot_count:]:
list_type = snap.get("list_type_id", "?")
list_name = snap.get("list_name", "Unknown")
n_words = snap.get("word_count", 0)
print(f" Telemetry snapshot: {list_name} "
f"(type {list_type}), {n_words} words")
# Show first few words
words = snap.get("words", [])
for i, val in enumerate(words[:5]):
print(f" [{i:03d}] = {val:05o} ({val})")
if len(words) > 5:
print(f" ... ({len(words) - 5} more words)")
last_snapshot_count = len(snapshots)
except KeyboardInterrupt:
print()
print("Interrupted.")
print("-" * 60)
print()
print("Summary:")
print(f" Total packets received: {packet_count}")
print(f" Telemetry words: {telemetry_words}")
print(f" Telemetry snapshots: {len(decoder._completed_snapshots)}")
print(f" Duration: {time.time() - start_time:.1f} seconds")
client.stop()
print()
print("Done.")
if __name__ == "__main__":
main()

Binary file not shown.

View File

@ -0,0 +1,295 @@
#!/usr/bin/env python3
"""
Apollo Full Downlink Demo -- PCM telemetry + crew voice on one carrier.
Reconstructs the complete Apollo USB downlink signal: PCM telemetry frames
on the 1.024 MHz BPSK subcarrier PLUS crew voice on the 1.25 MHz FM
subcarrier, both phase-modulated onto a single complex carrier.
Then receives the signal, splitting it into:
- Decoded PCM telemetry frames (digital data)
- Recovered crew voice audio (saved as WAV)
This is the full spacecraft-to-ground communications path:
TX (spacecraft):
pcm_frame_source -> nrz -> bpsk_mod (1.024 MHz)
crew_audio -> fm_voice_mod (1.25 MHz, +/-29kHz) scale 1.68/2.2
> add -> pm_mod -> [RF]
RX (ground station):
[RF] -> pm_demod > subcarrier_extract -> bpsk_demod -> frame_sync > PCM frames
> voice_subcarrier_demod > crew audio (8 kHz)
Usage:
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 --play
"""
import argparse
import time
from math import gcd
import numpy as np
import pmt
from gnuradio import blocks, gr
from scipy.io import wavfile
from scipy.signal import resample_poly
from apollo.bpsk_subcarrier_mod import bpsk_subcarrier_mod
from apollo.constants import (
PCM_HIGH_BIT_RATE,
PCM_HIGH_WORDS_PER_FRAME,
PCM_SUBCARRIER_HZ,
PCM_WORD_LENGTH,
PM_PEAK_DEVIATION_RAD,
SAMPLE_RATE_BASEBAND,
VOICE_FM_DEVIATION_HZ,
VOICE_SUBCARRIER_HZ,
)
from apollo.fm_voice_subcarrier_mod import fm_voice_subcarrier_mod
from apollo.nrz_encoder import nrz_encoder
from apollo.pcm_demux import DemuxEngine
from apollo.pcm_frame_source import pcm_frame_source
from apollo.pm_demod import pm_demod
from apollo.pm_mod import pm_mod
from apollo.usb_downlink_receiver import usb_downlink_receiver
from apollo.voice_subcarrier_demod import voice_subcarrier_demod
def load_and_upsample_audio(audio_path, sample_rate):
"""Load audio file and upsample to baseband rate."""
input_rate, audio_data = wavfile.read(audio_path)
if audio_data.ndim > 1:
audio_data = audio_data[:, 0]
# Normalize to [-1, 1]
if audio_data.dtype == np.int16:
audio_float = audio_data.astype(np.float32) / 32768.0
else:
audio_float = audio_data.astype(np.float32)
duration = len(audio_float) / input_rate
# Resample to 8 kHz first
audio_rate = 8000
if input_rate != audio_rate:
g = gcd(audio_rate, input_rate)
audio_float = resample_poly(audio_float, audio_rate // g, input_rate // g).astype(
np.float32
)
# Upsample to baseband
g = gcd(sample_rate, audio_rate)
upsampled = resample_poly(audio_float, sample_rate // g, audio_rate // g).astype(np.float32)
return upsampled, duration, audio_rate
def build_tx_signal(audio_samples, n_samples, sample_rate, snr_db):
"""Build the combined TX signal: PCM + voice -> PM modulation.
Assembles the individual blocks manually (not using usb_signal_source)
so we can inject external audio into the voice channel.
"""
tb = gr.top_block()
# --- PCM telemetry path ---
frame_src = pcm_frame_source(bit_rate=PCM_HIGH_BIT_RATE)
nrz = nrz_encoder(bit_rate=PCM_HIGH_BIT_RATE, sample_rate=sample_rate)
bpsk = bpsk_subcarrier_mod(
subcarrier_freq=PCM_SUBCARRIER_HZ,
sample_rate=sample_rate,
)
tb.connect(frame_src, nrz, bpsk)
# --- Voice subcarrier path (external audio) ---
voice_src = blocks.vector_source_f(audio_samples[:n_samples].tolist())
voice_mod = fm_voice_subcarrier_mod(
sample_rate=sample_rate,
subcarrier_freq=VOICE_SUBCARRIER_HZ,
fm_deviation=VOICE_FM_DEVIATION_HZ,
audio_input=True,
)
# Scale voice relative to PCM: 1.68/2.2 per IMPL_SPEC
voice_gain = blocks.multiply_const_ff(1.68 / 2.2)
tb.connect(voice_src, voice_mod, voice_gain)
# --- Sum subcarriers ---
adder = blocks.add_ff(1)
tb.connect(bpsk, (adder, 0))
tb.connect(voice_gain, (adder, 1))
# --- PM modulation ---
pm = pm_mod(pm_deviation=PM_PEAK_DEVIATION_RAD, sample_rate=sample_rate)
head = blocks.head(gr.sizeof_gr_complex, n_samples)
tb.connect(adder, pm, head)
# --- Optional AWGN ---
if snr_db is not None:
import math
noise_power = 1.0 / (10.0 ** (snr_db / 10.0))
noise_amp = math.sqrt(noise_power / 2.0)
noise = blocks.vector_source_c(
(np.random.randn(n_samples) + 1j * np.random.randn(n_samples)).astype(np.complex64)
* noise_amp
)
summer = blocks.add_cc(1)
snk = blocks.vector_sink_c()
tb.connect(head, (summer, 0))
tb.connect(noise, (summer, 1))
tb.connect(summer, snk)
else:
snk = blocks.vector_sink_c()
tb.connect(head, snk)
tb.run()
return np.array(snk.data())
def receive_pcm(signal_data, sample_rate):
"""Run the PCM receive chain and return decoded frames."""
tb = gr.top_block()
src = blocks.vector_source_c(signal_data.tolist())
rx = usb_downlink_receiver(
sample_rate=sample_rate,
bit_rate=PCM_HIGH_BIT_RATE,
output_format="raw",
)
snk = blocks.message_debug()
tb.connect(src, rx)
tb.msg_connect(rx, "frames", snk, "store")
tb.run()
return snk
def receive_voice(signal_data, sample_rate, audio_rate=8000):
"""Run the voice receive chain and return recovered audio samples."""
tb = gr.top_block()
src = blocks.vector_source_c(signal_data.tolist())
pm = pm_demod(sample_rate=sample_rate)
voice = voice_subcarrier_demod(sample_rate=sample_rate, audio_rate=audio_rate)
snk = blocks.vector_sink_f()
tb.connect(src, pm, voice, snk)
tb.run()
return np.array(snk.data(), dtype=np.float32)
def main():
parser = argparse.ArgumentParser(description="Full Apollo downlink: PCM telemetry + crew voice")
parser.add_argument("audio", help="Input audio WAV file (crew voice)")
parser.add_argument(
"--output", "-o", default=None, help="Output WAV path (default: <input>_fullchain.wav)"
)
parser.add_argument("--snr", type=float, default=None, help="Add AWGN noise at this SNR in dB")
parser.add_argument("--play", action="store_true", help="Play recovered voice with aplay")
args = parser.parse_args()
if args.output is None:
stem = args.audio.rsplit(".", 1)[0]
args.output = f"{stem}_fullchain.wav"
sample_rate = int(SAMPLE_RATE_BASEBAND)
audio_rate = 8000
print("=" * 60)
print("Apollo Full Downlink Demo")
print(" PCM telemetry (1.024 MHz BPSK) + crew voice (1.25 MHz FM)")
print("=" * 60)
print()
# Load and prepare audio
print("Loading crew voice audio...")
audio_upsampled, duration, _ = load_and_upsample_audio(args.audio, sample_rate)
print(f" Source: {args.audio} ({duration:.2f}s)")
print(f" Upsampled: {len(audio_upsampled):,} samples at {sample_rate / 1e6:.2f} MHz")
print()
# Calculate frame timing
bits_per_frame = PCM_HIGH_WORDS_PER_FRAME * PCM_WORD_LENGTH
samples_per_frame = int(bits_per_frame * sample_rate / PCM_HIGH_BIT_RATE)
n_frames = int(duration * 50) + 2 # 50 fps + margin
n_samples = min(len(audio_upsampled), n_frames * samples_per_frame)
print(f" PCM frames: ~{n_frames} at 50 fps")
print(f" Signal: {n_samples:,} samples ({n_samples / sample_rate:.2f}s)")
snr_desc = f"{args.snr} dB" if args.snr is not None else "clean"
print(f" SNR: {snr_desc}")
print()
# === TRANSMIT ===
print("TX: Building combined PCM + voice signal...")
t0 = time.time()
signal = build_tx_signal(audio_upsampled, n_samples, sample_rate, args.snr)
t_tx = time.time() - t0
print(f" Generated {len(signal):,} complex samples ({t_tx:.1f}s)")
# Verify constant envelope (PM property)
envelope = np.abs(signal[:10000])
if args.snr is None:
env_std = np.std(envelope)
print(f" PM envelope std: {env_std:.6f} (should be ~0 for clean)")
print()
# === RECEIVE: PCM telemetry ===
print("RX: Decoding PCM telemetry frames...")
t0 = time.time()
frame_sink = receive_pcm(signal, sample_rate)
t_pcm = time.time() - t0
n_recovered = frame_sink.num_messages()
print(f" Recovered {n_recovered} PCM frames ({t_pcm:.1f}s)")
if n_recovered > 0:
demux = DemuxEngine(output_format="raw")
print()
for i in range(min(n_recovered, 5)):
msg = frame_sink.get_message(i)
payload = pmt.cdr(msg) if pmt.is_pair(msg) else msg
frame_bytes = bytes(pmt.u8vector_elements(payload))
result = demux.process_frame(frame_bytes)
fid = result.get("sync", {}).get("frame_id", 0)
n_words = len(result.get("words", []))
parity = "odd" if fid % 2 == 1 else "even"
print(f" Frame {i + 1}: ID={fid:>2} ({parity}), {n_words} data words")
if n_recovered > 5:
print(f" ... ({n_recovered - 5} more frames)")
print()
# === RECEIVE: crew voice ===
print("RX: Demodulating crew voice (1.25 MHz FM)...")
t0 = time.time()
recovered_audio = receive_voice(signal, sample_rate, audio_rate)
t_voice = time.time() - t0
print(f" Recovered {len(recovered_audio):,} audio samples ({t_voice:.1f}s)")
print(f" Duration: {len(recovered_audio) / audio_rate:.2f}s at {audio_rate} Hz")
# Normalize and save
peak = np.max(np.abs(recovered_audio))
if peak > 0:
recovered_audio = recovered_audio / peak * 0.9
recovered_int16 = (recovered_audio * 32767).astype(np.int16)
wavfile.write(args.output, audio_rate, recovered_int16)
print(f" Saved: {args.output}")
print()
# === SUMMARY ===
print("=" * 60)
print(f" TX: {n_samples / sample_rate:.2f}s of combined PCM + voice")
print(f" RX: {n_recovered} PCM frames + {len(recovered_audio) / audio_rate:.2f}s crew voice")
print(f" SNR: {snr_desc}")
print("=" * 60)
if args.play:
import subprocess
print()
print("Playing recovered crew voice...")
subprocess.run(["aplay", args.output], check=False)
else:
print()
print(f"Play voice: aplay {args.output}")
if __name__ == "__main__":
main()

133
examples/loopback_demo.py Normal file
View File

@ -0,0 +1,133 @@
#!/usr/bin/env python3
"""
Apollo USB Loopback Demo -- streaming TX -> RX round-trip.
Demonstrates the full gr-apollo block chain using GNU Radio streaming blocks:
TX: pcm_frame_source -> nrz_encoder -> bpsk_subcarrier_mod -> pm_mod
RX: pm_demod -> subcarrier_extract -> bpsk_demod -> pcm_frame_sync -> pcm_demux
All wrapped in the convenience blocks:
usb_signal_source -> usb_downlink_receiver
Prints decoded frames as they arrive, including sync word analysis.
Usage:
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
"""
import argparse
import sys
import pmt
from gnuradio import blocks, gr
from apollo.constants import (
PCM_HIGH_BIT_RATE,
PCM_HIGH_WORDS_PER_FRAME,
PCM_WORD_LENGTH,
SAMPLE_RATE_BASEBAND,
)
from apollo.pcm_demux import DemuxEngine
from apollo.usb_downlink_receiver import usb_downlink_receiver
from apollo.usb_signal_source import usb_signal_source
def main():
parser = argparse.ArgumentParser(description="Apollo USB loopback demo")
parser.add_argument("--frames", type=int, default=10, help="Number of frames to generate")
parser.add_argument("--snr", type=float, default=None, help="SNR in dB (None = no noise)")
parser.add_argument("--voice", action="store_true", help="Enable voice subcarrier")
args = parser.parse_args()
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)
n_samples = args.frames * samples_per_frame
print("=" * 60)
print("Apollo USB Loopback Demo")
print("=" * 60)
print(f" Frames to transmit: {args.frames}")
print(f" Samples per frame: {samples_per_frame:,}")
print(f" Total samples: {n_samples:,}")
print(f" Duration: {n_samples / SAMPLE_RATE_BASEBAND:.3f} s")
print(f" SNR: {'clean (no noise)' if args.snr is None else f'{args.snr} dB'}")
print(f" Voice subcarrier: {'enabled' if args.voice else 'disabled'}")
print()
# Build the flowgraph
print("Building flowgraph...")
tb = gr.top_block()
tx = usb_signal_source(
sample_rate=SAMPLE_RATE_BASEBAND,
bit_rate=PCM_HIGH_BIT_RATE,
snr_db=args.snr,
voice_enabled=args.voice,
)
head = blocks.head(gr.sizeof_gr_complex, n_samples)
rx = usb_downlink_receiver(
sample_rate=SAMPLE_RATE_BASEBAND,
bit_rate=PCM_HIGH_BIT_RATE,
output_format="raw",
)
snk = blocks.message_debug()
tb.connect(tx, head, rx)
tb.msg_connect(rx, "frames", snk, "store")
print("Running flowgraph (TX -> RX)...")
print()
tb.run()
n_recovered = snk.num_messages()
print(f"Recovered {n_recovered} frames from {args.frames} transmitted")
print()
if n_recovered == 0:
print("No frames recovered. PLL may need more settling time.")
print("Try increasing --frames to give the receiver more data.")
sys.exit(1)
# Decode and display each recovered frame
demux = DemuxEngine(output_format="raw")
print("-" * 60)
for i in range(n_recovered):
msg = snk.get_message(i)
payload = pmt.cdr(msg) if pmt.is_pair(msg) else msg
frame_bytes = bytes(pmt.u8vector_elements(payload))
result = demux.process_frame(frame_bytes)
sync = result.get("sync", {})
frame_id = sync.get("frame_id", 0)
parity = "odd" if (frame_id % 2 == 1) else "even"
words = result.get("words", [])
n_words = len(words)
# Show first few data words as hex
word_preview = " ".join(
f"{w['raw_value']:02X}" for w in words[:8]
)
print(
f" Frame {i + 1:3d}: "
f"ID={frame_id:>2} ({parity:4s}), "
f"sync=0x{sync.get('word', 0):08X}, "
f"{n_words} words "
f"[{word_preview} ...]"
)
print("-" * 60)
print()
print(f"Recovery rate: {n_recovered}/{args.frames} "
f"({100 * n_recovered / args.frames:.0f}%)")
if __name__ == "__main__":
main()

View File

@ -0,0 +1,160 @@
#!/usr/bin/env python3
"""
Apollo Voice Subcarrier Demo -- modulate real audio onto 1.25 MHz FM subcarrier.
Takes an audio file (e.g., actual Apollo mission crew recordings), modulates
it onto the 1.25 MHz FM voice subcarrier with +/-29 kHz deviation, then
demodulates it back to audio. This is exactly what the spacecraft's
Pre-Modulation Processor and the ground station receiver did.
Signal path:
audio file (8 kHz)
-> upsample to 5.12 MHz
-> fm_voice_subcarrier_mod (audio_input=True)
[audio -> FM mod -> upconvert to 1.25 MHz -> float subcarrier]
-> voice_subcarrier_demod
[BPF 1.25 MHz -> FM discriminator -> BPF 300-3000 Hz -> decimate to 8 kHz]
-> recovered audio (8 kHz WAV)
Usage:
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
"""
import argparse
import time
from math import gcd
import numpy as np
from gnuradio import blocks, gr
from scipy.io import wavfile
from scipy.signal import resample_poly
from apollo.constants import SAMPLE_RATE_BASEBAND
from apollo.fm_voice_subcarrier_mod import fm_voice_subcarrier_mod
from apollo.voice_subcarrier_demod import voice_subcarrier_demod
def main():
parser = argparse.ArgumentParser(
description="Modulate audio onto Apollo 1.25 MHz FM voice subcarrier"
)
parser.add_argument("input", help="Input WAV file (any sample rate)")
parser.add_argument("--output", "-o", default=None,
help="Output WAV file (default: <input>_recovered.wav)")
parser.add_argument("--play", action="store_true",
help="Play recovered audio with aplay")
parser.add_argument("--sample-rate", type=float, default=SAMPLE_RATE_BASEBAND,
help="Baseband sample rate (default: 5.12 MHz)")
args = parser.parse_args()
if args.output is None:
stem = args.input.rsplit(".", 1)[0]
args.output = f"{stem}_recovered.wav"
# Load input audio
print("=" * 60)
print("Apollo Voice Subcarrier Demo")
print("=" * 60)
print()
input_rate, audio_data = wavfile.read(args.input)
if audio_data.ndim > 1:
audio_data = audio_data[:, 0] # mono
# Normalize to [-1, 1] float
if audio_data.dtype == np.int16:
audio_float = audio_data.astype(np.float32) / 32768.0
elif audio_data.dtype == np.int32:
audio_float = audio_data.astype(np.float32) / 2147483648.0
else:
audio_float = audio_data.astype(np.float32)
duration = len(audio_float) / input_rate
print(f" Input: {args.input}")
print(f" Sample rate: {input_rate} Hz")
print(f" Duration: {duration:.2f} s")
print(f" Samples: {len(audio_float):,}")
print()
# Upsample to baseband rate
sample_rate = int(args.sample_rate)
audio_rate = 8000 # output rate from voice demod
# Resample input to 8 kHz first (the standard Apollo voice bandwidth)
if input_rate != audio_rate:
g = gcd(audio_rate, input_rate)
audio_float = resample_poly(audio_float, audio_rate // g, input_rate // g)
audio_float = audio_float.astype(np.float32)
print(f" Resampled to {audio_rate} Hz: {len(audio_float):,} samples")
# Upsample from 8 kHz to baseband (5.12 MHz)
# Factor: 5120000 / 8000 = 640
g = gcd(sample_rate, audio_rate)
up = sample_rate // g
down = audio_rate // g
print(f" Upsampling {audio_rate} Hz -> {sample_rate / 1e6:.2f} MHz "
f"(ratio {up}:{down})...")
t0 = time.time()
upsampled = resample_poly(audio_float, up, down)
upsampled = upsampled.astype(np.float32)
t_resample = time.time() - t0
print(f" Upsampled: {len(upsampled):,} samples ({t_resample:.1f}s)")
print()
# Build GNU Radio flowgraph: voice mod -> voice demod
print("Building flowgraph: FM mod (1.25 MHz) -> FM demod...")
tb = gr.top_block()
src = blocks.vector_source_f(upsampled.tolist())
voice_mod = fm_voice_subcarrier_mod(
sample_rate=sample_rate,
audio_input=True,
)
voice_demod = voice_subcarrier_demod(
sample_rate=sample_rate,
audio_rate=audio_rate,
)
snk = blocks.vector_sink_f()
tb.connect(src, voice_mod, voice_demod, snk)
print("Running flowgraph...")
t0 = time.time()
tb.run()
t_run = time.time() - t0
recovered = np.array(snk.data(), dtype=np.float32)
print(f" Processed in {t_run:.1f}s")
print(f" Recovered: {len(recovered):,} samples at {audio_rate} Hz")
print(f" Duration: {len(recovered) / audio_rate:.2f} s")
print()
# Normalize recovered audio for WAV output
peak = np.max(np.abs(recovered))
if peak > 0:
recovered = recovered / peak * 0.9 # normalize to 90% to avoid clipping
# Save as 16-bit WAV
recovered_int16 = (recovered * 32767).astype(np.int16)
wavfile.write(args.output, audio_rate, recovered_int16)
print(f" Saved: {args.output}")
print(f" Peak amplitude: {peak:.4f}")
print()
# Play if requested
if args.play:
import subprocess
print("Playing recovered audio...")
subprocess.run(["aplay", args.output], check=False)
else:
print(f"Play with: aplay {args.output}")
print()
print("Done.")
if __name__ == "__main__":
main()

View File

@ -0,0 +1,47 @@
id: apollo_bpsk_subcarrier_mod
label: Apollo BPSK Subcarrier Mod
category: '[Apollo USB]'
flags: [python]
parameters:
- id: subcarrier_freq
label: Subcarrier Frequency (Hz)
dtype: real
default: '1024000'
- id: sample_rate
label: Sample Rate (Hz)
dtype: real
default: '5120000'
inputs:
- label: in
domain: stream
dtype: float
outputs:
- label: out
domain: stream
dtype: float
templates:
imports: from apollo.bpsk_subcarrier_mod import bpsk_subcarrier_mod
make: apollo.bpsk_subcarrier_mod.bpsk_subcarrier_mod(subcarrier_freq=${subcarrier_freq}, sample_rate=${sample_rate})
documentation: |-
Apollo BPSK Subcarrier Modulator
Multiplies an NRZ baseband waveform (+1/-1) by a 1.024 MHz cosine to produce
a BPSK-modulated subcarrier: output(t) = nrz(t) * cos(2*pi*f_sc*t).
The cosine phase flips 180 degrees at each NRZ sign change, implementing
bi-phase shift keying. This is the transmit-side counterpart to the
Apollo BPSK Subcarrier Demod block.
On the real spacecraft, the PCM encoder drives the BPSK subcarrier modulator
before summing with the voice subcarrier for PM transmission.
Parameters:
subcarrier_freq: BPSK subcarrier frequency in Hz (default 1.024 MHz)
sample_rate: Sample rate in Hz (default 5.12 MHz)
file_format: 1

View File

@ -0,0 +1,76 @@
id: apollo_fm_voice_subcarrier_mod
label: Apollo FM Voice Subcarrier Mod
category: '[Apollo USB]'
flags: [python]
parameters:
- id: sample_rate
label: Sample Rate (Hz)
dtype: real
default: '5120000'
- id: subcarrier_freq
label: Subcarrier Frequency (Hz)
dtype: real
default: '1250000'
- id: fm_deviation
label: FM Deviation (Hz)
dtype: real
default: '29000'
- id: tone_freq
label: Test Tone Frequency (Hz)
dtype: real
default: '1000'
- id: audio_input
label: Audio Source
dtype: bool
default: 'False'
options: ['True', 'False']
option_labels: ['External Input', 'Internal Test Tone']
inputs:
- label: audio
domain: stream
dtype: float
optional: true
hide: ${ 'all' if not audio_input else 'none' }
outputs:
- label: out
domain: stream
dtype: float
templates:
imports: from apollo.fm_voice_subcarrier_mod import fm_voice_subcarrier_mod
make: >-
apollo.fm_voice_subcarrier_mod.fm_voice_subcarrier_mod(
sample_rate=${sample_rate},
subcarrier_freq=${subcarrier_freq},
fm_deviation=${fm_deviation},
tone_freq=${tone_freq},
audio_input=${audio_input})
documentation: |-
Apollo FM Voice Subcarrier Modulator
Generates a 1.25 MHz FM subcarrier for the voice channel. Can operate in
two modes:
Internal mode (default): Uses a built-in sine test tone as the audio source.
This is useful for testing and signal validation.
External mode (audio_input=True): Accepts an external float audio stream as
input. Use this to modulate actual Apollo mission voice recordings or live
audio onto the 1.25 MHz FM subcarrier with +/-29 kHz deviation.
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.
Parameters:
sample_rate: Output sample rate in Hz (default 5.12 MHz)
subcarrier_freq: FM subcarrier center frequency in Hz (default 1.25 MHz)
fm_deviation: FM deviation in Hz (default +/-29 kHz)
tone_freq: Internal test tone frequency in Hz (default 1 kHz, ignored when audio_input=True)
audio_input: When True, accept external audio via float input port
file_format: 1

View File

@ -0,0 +1,52 @@
id: apollo_nrz_encoder
label: Apollo NRZ Encoder
category: '[Apollo USB]'
flags: [python]
parameters:
- id: bit_rate
label: Bit Rate (bps)
dtype: int
default: '51200'
options: ['51200', '1600']
option_labels: ['High (51.2 kbps)', 'Low (1.6 kbps)']
- id: sample_rate
label: Sample Rate
dtype: real
default: '5120000'
inputs:
- label: in
domain: stream
dtype: byte
outputs:
- label: out
domain: stream
dtype: float
templates:
imports: from apollo.nrz_encoder import nrz_encoder
make: apollo.nrz_encoder.nrz_encoder(bit_rate=${bit_rate}, sample_rate=${sample_rate})
documentation: |-
Apollo NRZ Encoder
Converts a stream of byte values (0 or 1) to a Non-Return-to-Zero
baseband waveform at the specified sample rate.
Mapping:
bit 1 -> +1.0 (held for bit period)
bit 0 -> -1.0 (held for bit period)
Each bit value is upsampled by samples_per_bit = sample_rate / bit_rate.
At the default high rate (51.2 kbps, 5.12 MHz), this is 100 samples
per bit. At low rate (1.6 kbps), it is 3200 samples per bit.
This is the transmit-side counterpart to the slicer in bpsk_demod.
Parameters:
bit_rate: PCM bit rate in bps (51200 high, 1600 low)
sample_rate: Output sample rate in Hz (default 5.12 MHz)
file_format: 1

View File

@ -0,0 +1,47 @@
id: apollo_pcm_frame_source
label: Apollo PCM Frame Source
category: '[Apollo USB]'
flags: [python]
parameters:
- id: bit_rate
label: Bit Rate (bps)
dtype: int
default: '51200'
options: ['51200', '1600']
option_labels: ['High (51.2 kbps)', 'Low (1.6 kbps)']
inputs:
- label: frame_data
domain: message
optional: true
outputs:
- label: out
domain: stream
dtype: byte
templates:
imports: from apollo.pcm_frame_source import pcm_frame_source
make: apollo.pcm_frame_source.pcm_frame_source(bit_rate=${bit_rate})
documentation: |-
Apollo PCM Frame Source
Generates a continuous stream of NRZ-encoded PCM telemetry frame bits
(byte values 0 or 1). Frame IDs cycle 1 through 50 automatically,
with the 15-bit sync core complemented on odd-numbered frames.
This is the transmit-side counterpart to the PCM Frame Sync block.
The sync word format is:
[5-bit A][15-bit core][6-bit B][6-bit frame ID]
An optional message input (frame_data) accepts u8vector payloads that
will be used as data words for the next generated frame. Without
injected data, frames carry zero-fill.
Parameters:
bit_rate: 51200 (128 words/frame, 50 fps) or 1600 (200 words/frame, 1 fps)
file_format: 1

View File

@ -0,0 +1,44 @@
id: apollo_pm_mod
label: Apollo PM Mod
category: '[Apollo USB]'
flags: [python]
parameters:
- id: pm_deviation
label: PM Deviation (rad)
dtype: real
default: '0.133'
- id: sample_rate
label: Sample Rate
dtype: real
default: '5120000'
inputs:
- label: in
domain: stream
dtype: float
outputs:
- label: out
domain: stream
dtype: complex
templates:
imports: from apollo.pm_mod import pm_mod
make: apollo.pm_mod.pm_mod(pm_deviation=${pm_deviation}, sample_rate=${sample_rate})
documentation: |-
Apollo PM Modulator
Applies phase modulation to produce complex baseband signal.
Takes a composite modulating signal (sum of subcarriers) and outputs
s(t) = exp(j * pm_deviation * input(t)).
The spacecraft PM deviation is 0.133 rad (7.6 degrees) peak.
This is the transmit-side counterpart to Apollo PM Demod.
Parameters:
pm_deviation: Peak phase deviation in radians (default 0.133)
sample_rate: Sample rate in Hz (default 5.12 MHz)
file_format: 1

View File

@ -0,0 +1,58 @@
id: apollo_sco_mod
label: Apollo SCO Mod
category: '[Apollo USB]'
flags: [python]
parameters:
- id: sco_number
label: SCO Channel (1-9)
dtype: int
default: '1'
- id: sample_rate
label: Sample Rate (Hz)
dtype: real
default: '5120000'
inputs:
- label: in
domain: stream
dtype: float
outputs:
- label: out
domain: stream
dtype: float
templates:
imports: from apollo.sco_mod import sco_mod
make: >-
apollo.sco_mod.sco_mod(
sco_number=${sco_number},
sample_rate=${sample_rate})
documentation: |-
Apollo Subcarrier Oscillator (SCO) Modulator
Generates FM subcarrier oscillator signals for analog telemetry. Takes a
0-5V sensor voltage input and produces an FM subcarrier tone at the
selected channel frequency with +/-7.5% deviation.
Transmit-side counterpart to the SCO Demodulator.
SCO Channels:
1: 14,500 Hz 4: 40,000 Hz 7: 95,000 Hz
2: 22,000 Hz 5: 52,500 Hz 8: 125,000 Hz
3: 30,000 Hz 6: 70,000 Hz 9: 165,000 Hz
Voltage mapping (linear):
0V -> center - 7.5% (low frequency)
2.5V -> center (nominal)
5V -> center + 7.5% (high frequency)
Only used in FM downlink mode.
Parameters:
sco_number: SCO channel number (1-9)
sample_rate: Sample rate in Hz (default 5.12 MHz)
file_format: 1

View File

@ -0,0 +1,80 @@
id: apollo_usb_signal_source
label: Apollo USB Signal Source
category: '[Apollo USB]'
flags: [python]
parameters:
- id: sample_rate
label: Sample Rate (Hz)
dtype: float
default: '5120000'
- id: bit_rate
label: PCM Bit Rate
dtype: int
default: '51200'
options: ['51200', '1600']
option_labels: ['51.2 kbps (high rate)', '1.6 kbps (low rate)']
- id: pm_deviation
label: PM Deviation (rad)
dtype: float
default: '0.133'
- id: voice_enabled
label: Voice Subcarrier
dtype: bool
default: 'False'
options: ['True', 'False']
option_labels: ['Enabled', 'Disabled']
- id: voice_tone_hz
label: Voice Test Tone (Hz)
dtype: float
default: '1000'
- id: snr_db
label: SNR (dB)
dtype: raw
default: 'None'
inputs:
- label: frame_data
domain: message
optional: true
outputs:
- label: out
domain: stream
dtype: complex
templates:
imports: from apollo.usb_signal_source import usb_signal_source
make: >-
apollo.usb_signal_source.usb_signal_source(
sample_rate=${sample_rate},
bit_rate=${bit_rate},
pm_deviation=${pm_deviation},
voice_enabled=${voice_enabled},
voice_tone_hz=${voice_tone_hz},
snr_db=${snr_db})
documentation: |-
Apollo USB Signal Source -- complete transmit chain in one block.
Generates a PM-modulated complex baseband signal containing:
- 1.024 MHz BPSK subcarrier with PCM telemetry frames
- Optional 1.25 MHz FM voice subcarrier (test tone)
- Optional AWGN noise
This is the transmit-side counterpart to the USB Downlink Receiver.
It mirrors CuriousMarc's bench: individual composable blocks wired
together as one convenience wrapper.
Message input:
frame_data -- inject custom payload bytes for the next PCM frame
Parameters:
sample_rate: Output sample rate (default 5.12 MHz)
bit_rate: PCM bit rate -- 51200 (high) or 1600 (low)
pm_deviation: Peak PM deviation in radians (default 0.133)
voice_enabled: Include 1.25 MHz FM voice subcarrier
voice_tone_hz: Voice test tone frequency in Hz
snr_db: Add AWGN noise at this SNR (None = no noise)
file_format: 1

View File

@ -1,7 +1,7 @@
"""
gr-apollo: Apollo Unified S-Band decoder for GNU Radio 3.10+
gr-apollo: Apollo Unified S-Band for GNU Radio 3.10+
Decodes Apollo-era Unified S-Band (USB) telecommunications:
Receive and transmit Apollo-era Unified S-Band (USB) telecommunications:
- 2287.5 MHz downlink with PM/FM modulation
- 1.024 MHz BPSK subcarrier (PCM telemetry @ 51.2 kbps)
- 1.25 MHz FM subcarrier (voice)
@ -10,21 +10,19 @@ Decodes Apollo-era Unified S-Band (USB) telecommunications:
__version__ = "0.1.0"
# Pure-python modules (always available)
# Pure-python modules and engines (always available, no GR dependency)
from apollo import constants as constants
from apollo import protocol as protocol
# Pure-python engines (always available, no GR dependency)
from apollo.agc_bridge import AGCBridgeClient as AGCBridgeClient
from apollo.downlink_decoder import DownlinkEngine as DownlinkEngine
from apollo.pcm_demux import DemuxEngine as DemuxEngine
from apollo.pcm_frame_source import FrameSourceEngine as FrameSourceEngine
from apollo.pcm_frame_sync import FrameSyncEngine as FrameSyncEngine
from apollo.uplink_encoder import UplinkEncoder as UplinkEncoder
from apollo.usb_signal_gen import generate_usb_baseband as generate_usb_baseband
# GNU Radio blocks (require gnuradio runtime)
# These are imported lazily to allow the package to be used
# for its pure-python utilities without GNU Radio installed.
# GNU Radio receive-side blocks (require gnuradio runtime)
# Imported lazily so the package works without GNU Radio installed.
try:
from apollo.bpsk_demod import bpsk_demod as bpsk_demod
from apollo.bpsk_subcarrier_demod import bpsk_subcarrier_demod as bpsk_subcarrier_demod
@ -33,8 +31,20 @@ try:
from apollo.subcarrier_extract import subcarrier_extract as subcarrier_extract
from apollo.voice_subcarrier_demod import voice_subcarrier_demod as voice_subcarrier_demod
except ImportError:
pass # GNU Radio not available — Phase 1/3 GR blocks won't be importable
pass # GNU Radio not available — receive-side GR blocks won't be importable
# GNU Radio transmit-side blocks
try:
from apollo.bpsk_subcarrier_mod import bpsk_subcarrier_mod as bpsk_subcarrier_mod
from apollo.fm_voice_subcarrier_mod import fm_voice_subcarrier_mod as fm_voice_subcarrier_mod
from apollo.nrz_encoder import nrz_encoder as nrz_encoder
from apollo.pcm_frame_source import pcm_frame_source as pcm_frame_source
from apollo.pm_mod import pm_mod as pm_mod
from apollo.sco_mod import sco_mod as sco_mod
except ImportError:
pass # GNU Radio not available — transmit-side GR blocks won't be importable
# GNU Radio composite blocks (depend on individual blocks above)
try:
from apollo.agc_bridge import agc_bridge as agc_bridge
from apollo.downlink_decoder import downlink_decoder as downlink_decoder
@ -42,5 +52,6 @@ try:
from apollo.pcm_frame_sync import pcm_frame_sync as pcm_frame_sync
from apollo.uplink_encoder import uplink_encoder as uplink_encoder
from apollo.usb_downlink_receiver import usb_downlink_receiver as usb_downlink_receiver
from apollo.usb_signal_source import usb_signal_source as usb_signal_source
except (ImportError, NameError):
pass # GNU Radio not available — Phase 2/4/5 GR blocks won't be importable
pass

View File

@ -0,0 +1,59 @@
"""
Apollo BPSK Subcarrier Modulator -- NRZ data onto 1.024 MHz subcarrier.
The transmit-side counterpart to bpsk_subcarrier_demod. Takes an NRZ baseband
waveform (+1/-1) and modulates it onto a 1.024 MHz cosine subcarrier via
simple multiplication: output(t) = nrz(t) * cos(2*pi*f_sc*t).
This is Bi-Phase Shift Keying (BPSK): the cosine phase flips 180 degrees
when the NRZ data changes sign.
On the real spacecraft, a 33522B AWG (or equivalent) generates this
BPSK-modulated subcarrier before summing with the voice subcarrier.
Reference: IMPLEMENTATION_SPEC.md section 4.2
"""
from gnuradio import analog, blocks, gr
from apollo.constants import PCM_SUBCARRIER_HZ, SAMPLE_RATE_BASEBAND
class bpsk_subcarrier_mod(gr.hier_block2):
"""BPSK modulator: NRZ float input -> BPSK subcarrier float output."""
def __init__(
self,
subcarrier_freq: float = PCM_SUBCARRIER_HZ,
sample_rate: float = SAMPLE_RATE_BASEBAND,
):
gr.hier_block2.__init__(
self,
"apollo_bpsk_subcarrier_mod",
gr.io_signature(1, 1, gr.sizeof_float),
gr.io_signature(1, 1, gr.sizeof_float),
)
self._subcarrier_freq = subcarrier_freq
self._sample_rate = sample_rate
# 1.024 MHz cosine subcarrier (continuous phase, maintained by sig_source)
self.carrier = analog.sig_source_f(
sample_rate, analog.GR_COS_WAVE, subcarrier_freq, 1.0, 0,
)
# Multiply NRZ data by subcarrier
self.mixer = blocks.multiply_ff(1)
# Connect: input (NRZ) -> mixer port 0, carrier -> mixer port 1 -> output
self.connect(self, (self.mixer, 0))
self.connect(self.carrier, (self.mixer, 1))
self.connect(self.mixer, self)
@property
def subcarrier_freq(self) -> float:
return self._subcarrier_freq
@property
def sample_rate(self) -> float:
return self._sample_rate

View File

@ -0,0 +1,115 @@
"""
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

61
src/apollo/nrz_encoder.py Normal file
View File

@ -0,0 +1,61 @@
"""
Apollo NRZ Encoder -- converts PCM bit stream to NRZ baseband waveform.
Takes a stream of byte values (0 or 1) from pcm_frame_source and produces
a float waveform at the output sample rate where bit 1 -> +1.0 and
bit 0 -> -1.0, with each bit repeated for samples_per_bit samples.
This is the transmit-side counterpart to the slicer in bpsk_demod.
NRZ (Non-Return-to-Zero) encoding maps:
bit 1 -> +1.0 (held for bit period)
bit 0 -> -1.0 (held for bit period)
Reference: IMPLEMENTATION_SPEC.md section 5.1
"""
from gnuradio import blocks, gr
from apollo.constants import PCM_HIGH_BIT_RATE, SAMPLE_RATE_BASEBAND
class nrz_encoder(gr.hier_block2):
"""NRZ line encoder: byte (0/1) stream -> float (+1/-1) waveform.
Input: byte stream (values 0 or 1)
Output: float NRZ waveform at sample_rate
"""
def __init__(
self,
bit_rate: int = PCM_HIGH_BIT_RATE,
sample_rate: float = SAMPLE_RATE_BASEBAND,
):
gr.hier_block2.__init__(
self,
"apollo_nrz_encoder",
gr.io_signature(1, 1, gr.sizeof_char),
gr.io_signature(1, 1, gr.sizeof_float),
)
self._bit_rate = bit_rate
self._sample_rate = sample_rate
samples_per_bit = int(sample_rate / bit_rate)
# byte (0/1) -> float (0.0/1.0)
self.to_float = blocks.char_to_float(1, 1)
# float (0.0/1.0) -> float (0.0/2.0)
self.scale = blocks.multiply_const_ff(2.0)
# float (0.0/2.0) -> float (-1.0/+1.0)
self.offset = blocks.add_const_ff(-1.0)
# Upsample: repeat each value samples_per_bit times
self.upsample = blocks.repeat(gr.sizeof_float, samples_per_bit)
# Connect chain
self.connect(self, self.to_float, self.scale, self.offset, self.upsample, self)
@property
def samples_per_bit(self) -> int:
return int(self._sample_rate / self._bit_rate)

View File

@ -0,0 +1,153 @@
"""
Apollo PCM Frame Source -- generates a continuous NRZ bit stream of PCM frames.
The transmit-side counterpart to pcm_frame_sync. Produces a steady stream of
128-word (high rate, 51.2 kbps) or 200-word (low rate, 1.6 kbps) PCM frames,
each beginning with the standard 32-bit sync word:
[5-bit A][15-bit core][6-bit B][6-bit frame ID]
Frame IDs cycle 1 through 50 (one subframe), with the 15-bit core complemented
on odd-numbered frames. An optional message input allows dynamic payload
injection; otherwise frames carry zero-fill data.
The core logic lives in FrameSourceEngine (pure Python, testable without GNU
Radio). The GR sync_block wrapper bridges frame-granularity generation with
GR's sample-granularity scheduler via an internal bit buffer.
Reference: IMPLEMENTATION_SPEC.md sections 5.1, 5.2
"""
from collections import deque
import numpy as np
from apollo.constants import (
PCM_HIGH_BIT_RATE,
PCM_HIGH_WORDS_PER_FRAME,
PCM_LOW_WORDS_PER_FRAME,
)
from apollo.usb_signal_gen import generate_pcm_frame
class FrameSourceEngine:
"""PCM frame generation engine (pure Python, no GR dependency).
Maintains a rolling frame counter (1-50) and generates complete frames
on demand via next_frame(). Odd-numbered frames get a complemented
sync core automatically.
Args:
bit_rate: PCM bit rate in bps (51200 or 1600).
"""
def __init__(self, bit_rate: int = PCM_HIGH_BIT_RATE):
self.bit_rate = bit_rate
if bit_rate == PCM_HIGH_BIT_RATE:
self.words_per_frame = PCM_HIGH_WORDS_PER_FRAME
else:
self.words_per_frame = PCM_LOW_WORDS_PER_FRAME
self.frame_counter = 1
def next_frame(self, data: bytes | None = None) -> list[int]:
"""Generate the next PCM frame as a list of bits (0/1 values, MSB first).
Args:
data: Optional payload bytes for data words. If None, the frame
carries zero-fill (deterministic, unlike the random fill in
generate_pcm_frame when data=None for signal-gen use).
Returns:
List of bit values, length = words_per_frame * 8.
"""
frame_id = self.frame_counter
odd = (frame_id % 2) == 1
# Default to zero-fill rather than random for a transmit source --
# downstream blocks and tests need deterministic output.
if data is None:
data = bytes(self.words_per_frame)
bits = generate_pcm_frame(
frame_id=frame_id,
odd=odd,
data=data,
words_per_frame=self.words_per_frame,
)
# Advance counter: 1 -> 2 -> ... -> 50 -> 1
self.frame_counter = (self.frame_counter % 50) + 1
return bits
# ---------------------------------------------------------------------------
# GNU Radio block wrapper (optional -- only if gnuradio is available)
# ---------------------------------------------------------------------------
try:
import pmt
from gnuradio import gr
class pcm_frame_source(gr.sync_block):
"""GNU Radio source block: continuous PCM frame bit stream.
Outputs a stream of bytes (values 0 or 1) representing NRZ-encoded
PCM telemetry frames. Frame IDs cycle 1-50 automatically.
An optional ``frame_data`` message input accepts PMT u8vector payloads
that will be used as the data words for the next generated frame.
Parameters:
bit_rate: 51200 (128 words/frame) or 1600 (200 words/frame).
"""
def __init__(self, bit_rate: int = PCM_HIGH_BIT_RATE):
gr.sync_block.__init__(
self,
name="apollo_pcm_frame_source",
in_sig=None,
out_sig=[np.byte],
)
self._engine = FrameSourceEngine(bit_rate=bit_rate)
self._bit_buffer: deque[int] = deque()
self._pending_data: bytes | None = None
# Message input for dynamic payload injection
self.message_port_register_in(pmt.intern("frame_data"))
self.set_msg_handler(
pmt.intern("frame_data"), self._handle_frame_data
)
def _handle_frame_data(self, msg):
"""Store incoming PMT payload bytes for the next frame."""
if pmt.is_u8vector(msg):
self._pending_data = bytes(pmt.u8vector_elements(msg))
elif pmt.is_pair(msg):
# Accept PDU (car=meta, cdr=payload)
payload = pmt.cdr(msg)
if pmt.is_u8vector(payload):
self._pending_data = bytes(pmt.u8vector_elements(payload))
def work(self, input_items, output_items):
out = output_items[0]
n_out = len(out)
produced = 0
while produced < n_out:
if not self._bit_buffer:
frame_bits = self._engine.next_frame(data=self._pending_data)
self._pending_data = None
self._bit_buffer.extend(frame_bits)
chunk = min(n_out - produced, len(self._bit_buffer))
for i in range(chunk):
out[produced + i] = self._bit_buffer.popleft()
produced += chunk
return produced
except ImportError:
pass

53
src/apollo/pm_mod.py Normal file
View File

@ -0,0 +1,53 @@
"""
Apollo PM Modulator applies phase modulation to produce complex baseband.
The transmit-side counterpart to pm_demod. Takes a composite modulating signal
(sum of subcarriers) and produces PM complex baseband: s(t) = exp(j * phi(t))
where phi(t) = pm_deviation * modulating(t).
The spacecraft transmitter phase-modulates at 0.133 rad peak deviation (7.6 deg).
At this small deviation, the modulation is essentially linear.
Reference: IMPLEMENTATION_SPEC.md section 2.3
"""
from gnuradio import analog, blocks, gr
from apollo.constants import PM_PEAK_DEVIATION_RAD, SAMPLE_RATE_BASEBAND
class pm_mod(gr.hier_block2):
"""Phase modulator: float input -> PM complex baseband output."""
def __init__(
self,
pm_deviation: float = PM_PEAK_DEVIATION_RAD,
sample_rate: float = SAMPLE_RATE_BASEBAND,
):
gr.hier_block2.__init__(
self,
"apollo_pm_mod",
gr.io_signature(1, 1, gr.sizeof_float),
gr.io_signature(1, 1, gr.sizeof_gr_complex),
)
self._pm_deviation = pm_deviation
# Scale input by PM deviation so phase_modulator sees deviation-scaled values
self.gain = blocks.multiply_const_ff(pm_deviation)
# Phase modulate: output = exp(j * 1.0 * input)
# Sensitivity is 1.0 because we pre-scale by pm_deviation above
self.modulator = analog.phase_modulator_fc(1.0)
# Connect: input -> gain -> phase_mod -> output
self.connect(self, self.gain, self.modulator, self)
def get_pm_deviation(self) -> float:
"""Return current PM deviation in radians."""
return self._pm_deviation
def set_pm_deviation(self, dev: float):
"""Update PM deviation at runtime."""
self._pm_deviation = dev
self.gain.set_k(dev)

124
src/apollo/sco_mod.py Normal file
View File

@ -0,0 +1,124 @@
"""
Apollo Subcarrier Oscillator (SCO) Modulator FM analog telemetry.
In FM downlink mode, the Pre-Modulation Processor generates 9 subcarrier
oscillators (SCOs) that encode analog sensor voltages (0-5V DC) as frequency
deviations of +/-7.5% around each channel's center frequency.
This is the transmit-side counterpart to sco_demod.py. It takes a 0-5V sensor
voltage input and produces an FM subcarrier tone at the configured SCO channel
frequency.
Transmitter side (this block):
0-5V input -> subtract 2.5V -> scale to +/-1.0
-> FM modulator -> upconvert to center_freq
-> extract real part -> float output
The mapping is linear:
0V input -> center_freq - 7.5% = low frequency
2.5V input -> center_freq (nominal)
5V input -> center_freq + 7.5% = high frequency
Reference: IMPLEMENTATION_SPEC.md section 4.3
"""
import math
from gnuradio import analog, blocks, gr
from apollo.constants import (
SAMPLE_RATE_BASEBAND,
SCO_DEVIATION_PERCENT,
SCO_FREQUENCIES,
SCO_INPUT_RANGE_V,
)
class sco_mod(gr.hier_block2):
"""Modulate a 0-5V sensor voltage onto an FM subcarrier oscillator tone.
Only used in FM downlink mode.
Inputs:
float -- sensor voltage (0.0 to 5.0 V)
Outputs:
float -- FM subcarrier tone at the selected SCO channel frequency
"""
def __init__(
self,
sco_number: int = 1,
sample_rate: float = SAMPLE_RATE_BASEBAND,
):
gr.hier_block2.__init__(
self,
"apollo_sco_mod",
gr.io_signature(1, 1, gr.sizeof_float),
gr.io_signature(1, 1, gr.sizeof_float),
)
if sco_number not in SCO_FREQUENCIES:
raise ValueError(
f"SCO number must be 1-9, got {sco_number}. "
f"Valid channels: {sorted(SCO_FREQUENCIES.keys())}"
)
self._sco_number = sco_number
self._sample_rate = sample_rate
center_freq = SCO_FREQUENCIES[sco_number]
self._center_freq = center_freq
# Frequency deviation in Hz: +/-7.5% of center
deviation_hz = center_freq * (SCO_DEVIATION_PERCENT / 100.0)
self._deviation_hz = deviation_hz
# Voltage range parameters
v_min, v_max = SCO_INPUT_RANGE_V
v_range = v_max - v_min # 5.0
v_mid = (v_max + v_min) / 2.0 # 2.5
# Stage 1: Offset — subtract midpoint so 2.5V becomes 0
self.offset = blocks.add_const_ff(-v_mid)
# Stage 2: Scale — map +/-2.5V to +/-1.0
self.scale = blocks.multiply_const_ff(1.0 / (v_range / 2.0))
# Stage 3: FM modulator — +/-1.0 input produces +/-deviation_hz
fm_sensitivity = 2.0 * math.pi * deviation_hz / sample_rate
self.fm_mod = analog.frequency_modulator_fc(fm_sensitivity)
# Stage 4: Local oscillator at center_freq for upconversion
self.lo = analog.sig_source_c(
sample_rate, analog.GR_COS_WAVE, center_freq, 1.0, 0,
)
# Stage 5: Mixer — shift baseband FM signal up to center_freq
self.mixer = blocks.multiply_cc(1)
# Stage 6: Extract real part for float output
self.to_real = blocks.complex_to_real(1)
# Connect the chain:
# input -> offset -> scale -> fm_mod -> (mixer, 0)
# lo -> (mixer, 1)
# mixer -> to_real -> output
self.connect(self, self.offset, self.scale, self.fm_mod, (self.mixer, 0))
self.connect(self.lo, (self.mixer, 1))
self.connect(self.mixer, self.to_real, self)
@property
def center_freq(self) -> float:
"""Center frequency of this SCO channel in Hz."""
return self._center_freq
@property
def deviation_hz(self) -> float:
"""FM deviation in Hz (+/- from center)."""
return self._deviation_hz
@property
def sco_number(self) -> int:
"""SCO channel number (1-9)."""
return self._sco_number

View File

@ -0,0 +1,146 @@
"""
Apollo USB Signal Source -- complete transmit chain in one block.
The transmit-side counterpart to usb_downlink_receiver. Wires together the
full modulation chain:
pcm_frame_source -> nrz_encoder -> bpsk_subcarrier_mod -+-> add_ff -> pm_mod -> [complex out]
|
fm_voice_subcarrier_mod --------+
(optional, scaled by 1.68/2.2)
This mirrors CuriousMarc's physical bench topology: the individual composable
blocks map 1:1 to Keysight instruments (EXG signal generator for PM, two
33522B AWGs for subcarrier modulation).
For finer control, use the individual blocks directly.
Reference: IMPLEMENTATION_SPEC.md -- full downlink transmit path
"""
import math
from gnuradio import analog, blocks, gr
from apollo.bpsk_subcarrier_mod import bpsk_subcarrier_mod
from apollo.constants import (
PCM_HIGH_BIT_RATE,
PCM_SUBCARRIER_HZ,
PM_PEAK_DEVIATION_RAD,
SAMPLE_RATE_BASEBAND,
VOICE_FM_DEVIATION_HZ,
VOICE_SUBCARRIER_HZ,
)
from apollo.fm_voice_subcarrier_mod import fm_voice_subcarrier_mod
from apollo.nrz_encoder import nrz_encoder
from apollo.pcm_frame_source import pcm_frame_source
from apollo.pm_mod import pm_mod
class usb_signal_source(gr.hier_block2):
"""Apollo USB downlink signal source -- complex baseband output.
Outputs:
complex -- PM-modulated baseband at sample_rate (default 5.12 MHz)
Message inputs:
frame_data -- forwarded to pcm_frame_source for dynamic payload injection
The block generates PCM telemetry frames, NRZ-encodes them, BPSK-modulates
onto a 1.024 MHz subcarrier, optionally adds a 1.25 MHz FM voice subcarrier,
and applies PM modulation to produce complex baseband.
Optional AWGN noise can be added by setting snr_db to a finite value.
"""
def __init__(
self,
sample_rate: float = SAMPLE_RATE_BASEBAND,
bit_rate: int = PCM_HIGH_BIT_RATE,
pm_deviation: float = PM_PEAK_DEVIATION_RAD,
voice_enabled: bool = False,
voice_tone_hz: float = 1000.0,
snr_db: float | None = None,
):
gr.hier_block2.__init__(
self,
"apollo_usb_signal_source",
gr.io_signature(0, 0, 0), # source -- no input
gr.io_signature(1, 1, gr.sizeof_gr_complex),
)
self._sample_rate = sample_rate
self._voice_enabled = voice_enabled
# Forward the frame_data message port from pcm_frame_source
self.message_port_register_hier_in("frame_data")
# --- PCM telemetry path ---
# Stage 1: Generate PCM frame bits (0/1 byte stream)
self.frame_src = pcm_frame_source(bit_rate=bit_rate)
# Forward message port: hier input -> pcm_frame_source
self.msg_connect(self, "frame_data", self.frame_src, "frame_data")
# Stage 2: NRZ encode (byte 0/1 -> float +1/-1 at sample_rate)
self.nrz = nrz_encoder(bit_rate=bit_rate, sample_rate=sample_rate)
# Stage 3: BPSK modulate onto 1.024 MHz subcarrier
self.bpsk = bpsk_subcarrier_mod(
subcarrier_freq=PCM_SUBCARRIER_HZ,
sample_rate=sample_rate,
)
# Connect PCM chain: frame_src -> nrz -> bpsk
self.connect(self.frame_src, self.nrz, self.bpsk)
# --- Subcarrier summing ---
if voice_enabled:
# Voice subcarrier level relative to PCM:
# Per IMPL_SPEC: PCM = 2.2 Vpp, Voice = 1.68 Vpp
# The BPSK subcarrier has unity amplitude, so voice is scaled
# by 1.68/2.2 to maintain the correct power ratio.
voice_scale = 1.68 / 2.2
self.voice = fm_voice_subcarrier_mod(
sample_rate=sample_rate,
subcarrier_freq=VOICE_SUBCARRIER_HZ,
fm_deviation=VOICE_FM_DEVIATION_HZ,
tone_freq=voice_tone_hz,
)
self.voice_gain = blocks.multiply_const_ff(voice_scale)
self.adder = blocks.add_ff(1)
# PCM subcarrier -> adder port 0
self.connect(self.bpsk, (self.adder, 0))
# Voice subcarrier (scaled) -> adder port 1
self.connect(self.voice, self.voice_gain, (self.adder, 1))
composite = self.adder
else:
composite = self.bpsk
# --- PM modulation ---
self.pm = pm_mod(pm_deviation=pm_deviation, sample_rate=sample_rate)
self.connect(composite, self.pm)
# --- Optional AWGN ---
if snr_db is not None:
# Signal power is 1.0 (PM constant envelope)
noise_power = 1.0 / (10.0 ** (snr_db / 10.0))
noise_amplitude = math.sqrt(noise_power / 2.0)
self.noise = analog.noise_source_c(
analog.GR_GAUSSIAN, noise_amplitude, 0,
)
self.sum_noise = blocks.add_cc(1)
self.connect(self.pm, (self.sum_noise, 0))
self.connect(self.noise, (self.sum_noise, 1))
self.connect(self.sum_noise, self)
else:
self.connect(self.pm, self)

View File

@ -0,0 +1,197 @@
"""Tests for the BPSK subcarrier modulator block."""
import numpy as np
import pytest
try:
from gnuradio import blocks, gr
HAS_GNURADIO = True
except ImportError:
HAS_GNURADIO = False
from apollo.constants import PCM_SUBCARRIER_HZ, SAMPLE_RATE_BASEBAND
pytestmark = pytest.mark.skipif(not HAS_GNURADIO, reason="GNU Radio not installed")
class TestBPSKSubcarrierMod:
"""Test BPSK subcarrier modulation with synthetic NRZ inputs."""
def test_block_instantiation(self):
"""Block should instantiate with default parameters."""
from apollo.bpsk_subcarrier_mod import bpsk_subcarrier_mod
mod = bpsk_subcarrier_mod()
assert mod is not None
assert mod.subcarrier_freq == PCM_SUBCARRIER_HZ
assert mod.sample_rate == SAMPLE_RATE_BASEBAND
def test_constant_positive_input(self):
"""All +1.0 input should produce a pure cosine at 1.024 MHz."""
from apollo.bpsk_subcarrier_mod import bpsk_subcarrier_mod
sample_rate = SAMPLE_RATE_BASEBAND
n_samples = 51200
tb = gr.top_block()
src = blocks.vector_source_f([1.0] * n_samples)
mod = bpsk_subcarrier_mod(
subcarrier_freq=PCM_SUBCARRIER_HZ, sample_rate=sample_rate,
)
snk = blocks.vector_sink_f()
tb.connect(src, mod, snk)
tb.run()
data = np.array(snk.data())
assert len(data) == n_samples
# FFT: spectral energy should concentrate at 1.024 MHz
fft_vals = np.fft.fft(data)
freqs = np.fft.fftfreq(len(data), 1.0 / sample_rate)
pcm_mask = (np.abs(freqs) > 950_000) & (np.abs(freqs) < 1_100_000)
pcm_power = np.mean(np.abs(fft_vals[pcm_mask]) ** 2)
total_power = np.mean(np.abs(fft_vals) ** 2)
assert pcm_power > total_power * 0.1, (
f"PCM band power ({pcm_power:.1f}) is less than 10% of "
f"total power ({total_power:.1f})"
)
def test_constant_negative_input(self):
"""All -1.0 input should produce -cos (inverted cosine) at 1.024 MHz."""
from apollo.bpsk_subcarrier_mod import bpsk_subcarrier_mod
sample_rate = SAMPLE_RATE_BASEBAND
n_samples = 51200
tb = gr.top_block()
src = blocks.vector_source_f([-1.0] * n_samples)
mod = bpsk_subcarrier_mod(
subcarrier_freq=PCM_SUBCARRIER_HZ, sample_rate=sample_rate,
)
snk = blocks.vector_sink_f()
tb.connect(src, mod, snk)
tb.run()
data = np.array(snk.data())
assert len(data) == n_samples
# Inverted cosine still has energy at 1.024 MHz
fft_vals = np.fft.fft(data)
freqs = np.fft.fftfreq(len(data), 1.0 / sample_rate)
pcm_mask = (np.abs(freqs) > 950_000) & (np.abs(freqs) < 1_100_000)
pcm_power = np.mean(np.abs(fft_vals[pcm_mask]) ** 2)
total_power = np.mean(np.abs(fft_vals) ** 2)
assert pcm_power > total_power * 0.1, (
f"PCM band power ({pcm_power:.1f}) is less than 10% of "
f"total power ({total_power:.1f})"
)
def test_alternating_input_spectrum(self):
"""Alternating +1/-1 NRZ should still have spectral peak near subcarrier."""
from apollo.bpsk_subcarrier_mod import bpsk_subcarrier_mod
sample_rate = SAMPLE_RATE_BASEBAND
# Samples per bit at high rate: 5_120_000 / 51_200 = 100
samples_per_bit = int(sample_rate / 51_200)
n_bits = 512
n_samples = n_bits * samples_per_bit
# Build alternating NRZ: +1 for 100 samples, -1 for 100, ...
nrz = []
for i in range(n_bits):
val = 1.0 if i % 2 == 0 else -1.0
nrz.extend([val] * samples_per_bit)
tb = gr.top_block()
src = blocks.vector_source_f(nrz)
mod = bpsk_subcarrier_mod(
subcarrier_freq=PCM_SUBCARRIER_HZ, sample_rate=sample_rate,
)
snk = blocks.vector_sink_f()
tb.connect(src, mod, snk)
tb.run()
data = np.array(snk.data())
assert len(data) == n_samples
# BPSK with alternating data spreads energy around subcarrier +/- bit_rate,
# but the band near 1.024 MHz should still carry significant power
fft_vals = np.fft.fft(data)
freqs = np.fft.fftfreq(len(data), 1.0 / sample_rate)
pcm_mask = (np.abs(freqs) > 950_000) & (np.abs(freqs) < 1_100_000)
pcm_power = np.mean(np.abs(fft_vals[pcm_mask]) ** 2)
total_power = np.mean(np.abs(fft_vals) ** 2)
assert pcm_power > total_power * 0.1, (
f"PCM band power ({pcm_power:.1f}) is less than 10% of "
f"total power ({total_power:.1f})"
)
def test_amplitude_bounded(self):
"""Output amplitude should be <= 1.0 (product of +/-1 and cos)."""
from apollo.bpsk_subcarrier_mod import bpsk_subcarrier_mod
sample_rate = SAMPLE_RATE_BASEBAND
n_samples = 51200
tb = gr.top_block()
src = blocks.vector_source_f([1.0] * n_samples)
mod = bpsk_subcarrier_mod(
subcarrier_freq=PCM_SUBCARRIER_HZ, sample_rate=sample_rate,
)
snk = blocks.vector_sink_f()
tb.connect(src, mod, snk)
tb.run()
data = np.array(snk.data())
peak = np.max(np.abs(data))
# cos has peak 1.0, NRZ is +/-1.0, product peak should be ~1.0
assert peak <= 1.0 + 1e-6, (
f"Output peak amplitude {peak:.6f} exceeds 1.0"
)
assert peak > 0.9, (
f"Output peak amplitude {peak:.6f} is suspiciously low"
)
def test_custom_subcarrier_freq(self):
"""Custom subcarrier frequency should shift spectral peak."""
from apollo.bpsk_subcarrier_mod import bpsk_subcarrier_mod
sample_rate = SAMPLE_RATE_BASEBAND
n_samples = 51200
custom_freq = 500_000 # 500 kHz
tb = gr.top_block()
src = blocks.vector_source_f([1.0] * n_samples)
mod = bpsk_subcarrier_mod(
subcarrier_freq=custom_freq, sample_rate=sample_rate,
)
snk = blocks.vector_sink_f()
tb.connect(src, mod, snk)
tb.run()
data = np.array(snk.data())
fft_vals = np.fft.fft(data)
freqs = np.fft.fftfreq(len(data), 1.0 / sample_rate)
# Energy should be near 500 kHz, not 1.024 MHz
custom_mask = (np.abs(freqs) > 450_000) & (np.abs(freqs) < 550_000)
custom_power = np.mean(np.abs(fft_vals[custom_mask]) ** 2)
total_power = np.mean(np.abs(fft_vals) ** 2)
assert custom_power > total_power * 0.1, (
f"Custom freq band power ({custom_power:.1f}) is less than 10% of "
f"total power ({total_power:.1f})"
)

View File

@ -0,0 +1,247 @@
"""Tests for the FM voice subcarrier modulator block."""
import numpy as np
import pytest
try:
from gnuradio import blocks, gr
HAS_GNURADIO = True
except ImportError:
HAS_GNURADIO = False
from apollo.constants import SAMPLE_RATE_BASEBAND
pytestmark = pytest.mark.skipif(not HAS_GNURADIO, reason="GNU Radio not installed")
class TestFmVoiceModInstantiation:
"""Test block creation and parameter handling."""
def test_default_parameters(self):
"""Block should instantiate with default parameters."""
from apollo.fm_voice_subcarrier_mod import fm_voice_subcarrier_mod
mod = fm_voice_subcarrier_mod()
assert mod is not None
def test_custom_tone_freq(self):
"""Block should accept a custom tone frequency and produce output."""
from apollo.fm_voice_subcarrier_mod import fm_voice_subcarrier_mod
sample_rate = SAMPLE_RATE_BASEBAND
n_samples = 10240
tb = gr.top_block()
src = fm_voice_subcarrier_mod(sample_rate=sample_rate, tone_freq=2000.0)
head = blocks.head(gr.sizeof_float, n_samples)
snk = blocks.vector_sink_f()
tb.connect(src, head, snk)
tb.run()
data = np.array(snk.data())
assert len(data) == n_samples
assert np.any(data != 0), "Output is all zeros with tone_freq=2000"
def test_properties(self):
"""Properties should reflect constructor arguments."""
from apollo.fm_voice_subcarrier_mod import fm_voice_subcarrier_mod
mod = fm_voice_subcarrier_mod(
tone_freq=1500.0,
subcarrier_freq=1_000_000,
fm_deviation=20_000,
)
assert mod.tone_freq == 1500.0
assert mod.subcarrier_freq == 1_000_000
assert mod.fm_deviation == 20_000
class TestFmVoiceModFunctional:
"""Functional tests with signal analysis."""
def test_produces_output(self):
"""Source block should produce non-zero float output."""
from apollo.fm_voice_subcarrier_mod import fm_voice_subcarrier_mod
sample_rate = SAMPLE_RATE_BASEBAND
n_samples = 51200
tb = gr.top_block()
src = fm_voice_subcarrier_mod(sample_rate=sample_rate)
head = blocks.head(gr.sizeof_float, n_samples)
snk = blocks.vector_sink_f()
tb.connect(src, head, snk)
tb.run()
data = np.array(snk.data())
assert len(data) == n_samples, f"Expected {n_samples} samples, got {len(data)}"
assert np.any(data != 0), "Output is all zeros"
def test_output_is_float(self):
"""Output samples should be real-valued floats."""
from apollo.fm_voice_subcarrier_mod import fm_voice_subcarrier_mod
sample_rate = SAMPLE_RATE_BASEBAND
n_samples = 1024
tb = gr.top_block()
src = fm_voice_subcarrier_mod(sample_rate=sample_rate)
head = blocks.head(gr.sizeof_float, n_samples)
snk = blocks.vector_sink_f()
tb.connect(src, head, snk)
tb.run()
data = np.array(snk.data())
assert data.dtype in (np.float32, np.float64), (
f"Expected float output, got {data.dtype}"
)
def test_spectral_energy_at_subcarrier(self):
"""Most spectral energy should be near the 1.25 MHz subcarrier."""
from apollo.fm_voice_subcarrier_mod import fm_voice_subcarrier_mod
sample_rate = SAMPLE_RATE_BASEBAND
n_samples = 51200 # ~10 ms
tb = gr.top_block()
src = fm_voice_subcarrier_mod(sample_rate=sample_rate)
head = blocks.head(gr.sizeof_float, n_samples)
snk = blocks.vector_sink_f()
tb.connect(src, head, snk)
tb.run()
data = np.array(snk.data())
fft_vals = np.fft.fft(data)
freqs = np.fft.fftfreq(len(data), 1.0 / sample_rate)
# Energy in the 1.2-1.3 MHz band (subcarrier +/- deviation margin)
voice_mask = (np.abs(freqs) > 1_200_000) & (np.abs(freqs) < 1_300_000)
voice_power = np.mean(np.abs(fft_vals[voice_mask]) ** 2)
total_power = np.mean(np.abs(fft_vals) ** 2)
assert voice_power > total_power * 0.1, (
f"Subcarrier band power ({voice_power:.1f}) is less than 10% of "
f"total power ({total_power:.1f})"
)
def test_output_bounded(self):
"""Output amplitude should stay bounded (not blow up)."""
from apollo.fm_voice_subcarrier_mod import fm_voice_subcarrier_mod
sample_rate = SAMPLE_RATE_BASEBAND
n_samples = 51200
tb = gr.top_block()
src = fm_voice_subcarrier_mod(sample_rate=sample_rate)
head = blocks.head(gr.sizeof_float, n_samples)
snk = blocks.vector_sink_f()
tb.connect(src, head, snk)
tb.run()
data = np.array(snk.data())
peak = np.max(np.abs(data))
# FM on a cosine carrier: peak should be around 1.0, certainly < 2.0
assert peak < 2.0, f"Output peak amplitude {peak:.3f} exceeds expected bound"
assert peak > 0.1, f"Output peak amplitude {peak:.3f} is suspiciously low"
class TestFmVoiceModExternalAudio:
"""Tests for external audio input mode."""
def test_default_is_source(self):
"""Default mode should be source (no input, backward compatible)."""
from apollo.fm_voice_subcarrier_mod import fm_voice_subcarrier_mod
mod = fm_voice_subcarrier_mod()
assert not mod.audio_input
def test_external_audio_instantiation(self):
"""Block with audio_input=True should instantiate."""
from apollo.fm_voice_subcarrier_mod import fm_voice_subcarrier_mod
mod = fm_voice_subcarrier_mod(audio_input=True)
assert mod is not None
def test_external_audio_property(self):
"""audio_input property should reflect constructor arg."""
from apollo.fm_voice_subcarrier_mod import fm_voice_subcarrier_mod
mod_ext = fm_voice_subcarrier_mod(audio_input=True)
assert mod_ext.audio_input is True
mod_int = fm_voice_subcarrier_mod(audio_input=False)
assert mod_int.audio_input is False
def test_external_audio_produces_output(self):
"""Feed a 1 kHz sine wave into external input, verify output."""
from apollo.fm_voice_subcarrier_mod import fm_voice_subcarrier_mod
sample_rate = SAMPLE_RATE_BASEBAND
n_samples = 51200
t = np.arange(n_samples, dtype=np.float32) / sample_rate
audio = np.sin(2 * np.pi * 1000 * t).astype(np.float32)
tb = gr.top_block()
src = blocks.vector_source_f(audio.tolist())
mod = fm_voice_subcarrier_mod(sample_rate=sample_rate, audio_input=True)
snk = blocks.vector_sink_f()
tb.connect(src, mod, snk)
tb.run()
data = np.array(snk.data())
assert len(data) == n_samples, f"Expected {n_samples} samples, got {len(data)}"
assert np.any(data != 0), "Output is all zeros with external audio input"
def test_external_audio_spectral_energy(self):
"""Feed audio, verify spectral energy near 1.25 MHz subcarrier."""
from apollo.fm_voice_subcarrier_mod import fm_voice_subcarrier_mod
sample_rate = SAMPLE_RATE_BASEBAND
n_samples = 51200
t = np.arange(n_samples, dtype=np.float32) / sample_rate
audio = np.sin(2 * np.pi * 1000 * t).astype(np.float32)
tb = gr.top_block()
src = blocks.vector_source_f(audio.tolist())
mod = fm_voice_subcarrier_mod(sample_rate=sample_rate, audio_input=True)
snk = blocks.vector_sink_f()
tb.connect(src, mod, snk)
tb.run()
data = np.array(snk.data())
fft_vals = np.fft.fft(data)
freqs = np.fft.fftfreq(len(data), 1.0 / sample_rate)
# Energy in the 1.2-1.3 MHz band (subcarrier +/- deviation margin)
voice_mask = (np.abs(freqs) > 1_200_000) & (np.abs(freqs) < 1_300_000)
voice_power = np.mean(np.abs(fft_vals[voice_mask]) ** 2)
total_power = np.mean(np.abs(fft_vals) ** 2)
assert voice_power > total_power * 0.1, (
f"Subcarrier band power ({voice_power:.1f}) is less than 10% of "
f"total power ({total_power:.1f})"
)
def test_external_audio_silence(self):
"""Feed zeros (silence), verify output still present (carrier only)."""
from apollo.fm_voice_subcarrier_mod import fm_voice_subcarrier_mod
sample_rate = SAMPLE_RATE_BASEBAND
n_samples = 51200
silence = np.zeros(n_samples, dtype=np.float32)
tb = gr.top_block()
src = blocks.vector_source_f(silence.tolist())
mod = fm_voice_subcarrier_mod(sample_rate=sample_rate, audio_input=True)
snk = blocks.vector_sink_f()
tb.connect(src, mod, snk)
tb.run()
data = np.array(snk.data())
assert len(data) == n_samples, f"Expected {n_samples} samples, got {len(data)}"
# With silence input, FM deviation is zero so the output is an
# unmodulated carrier at the subcarrier frequency -- still non-zero.
assert np.any(data != 0), "Output is all zeros with silence input"
peak = np.max(np.abs(data))
assert peak > 0.1, f"Carrier amplitude {peak:.3f} is suspiciously low"

144
tests/test_loopback.py Normal file
View File

@ -0,0 +1,144 @@
"""Loopback test: usb_signal_source -> usb_downlink_receiver round-trip.
The ultimate validation -- generates a PM-modulated signal with known PCM
frames using the transmit chain, feeds it through the complete receive chain,
and verifies that frames are recovered correctly.
This exercises every block in both the transmit and receive paths:
TX: pcm_frame_source -> nrz_encoder -> bpsk_subcarrier_mod -> pm_mod
RX: pm_demod -> subcarrier_extract -> bpsk_demod -> pcm_frame_sync -> pcm_demux
"""
import numpy as np
import pytest
try:
from gnuradio import blocks, gr
HAS_GNURADIO = True
except ImportError:
HAS_GNURADIO = False
from apollo.constants import (
PCM_HIGH_BIT_RATE,
PCM_HIGH_WORDS_PER_FRAME,
PCM_WORD_LENGTH,
SAMPLE_RATE_BASEBAND,
)
pytestmark = pytest.mark.skipif(not HAS_GNURADIO, reason="GNU Radio not installed")
class TestLoopback:
"""Round-trip: transmit -> receive -> verify."""
def test_loopback_recovers_frames(self):
"""TX signal source -> RX downlink receiver should produce frame PDUs."""
from apollo.usb_downlink_receiver import usb_downlink_receiver
from apollo.usb_signal_source import usb_signal_source
# Generate enough samples for several frames so the receiver PLL can settle.
# At 51.2 kbps high rate, one frame = 1024 bits = 102400 samples.
# Give the receiver 8 frames worth (~0.16 seconds).
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)
n_frames = 8
n_samples = n_frames * samples_per_frame
tb = gr.top_block()
# Transmit chain (clean, no noise)
tx = usb_signal_source(
sample_rate=SAMPLE_RATE_BASEBAND,
bit_rate=PCM_HIGH_BIT_RATE,
snr_db=None,
)
head = blocks.head(gr.sizeof_gr_complex, n_samples)
# Receive chain
rx = usb_downlink_receiver(
sample_rate=SAMPLE_RATE_BASEBAND,
bit_rate=PCM_HIGH_BIT_RATE,
)
snk = blocks.message_debug()
tb.connect(tx, head, rx)
tb.msg_connect(rx, "frames", snk, "store")
tb.run()
n_recovered = snk.num_messages()
# The receiver needs ~1-2 frames for PLL settling, so we expect
# at least a few frames from 8 transmitted.
assert n_recovered >= 1, (
f"Loopback recovered {n_recovered} frames from {n_frames} transmitted, "
f"expected >= 1"
)
def test_loopback_frame_structure(self):
"""Recovered frames should have valid sync word structure."""
from apollo.pcm_demux import DemuxEngine
from apollo.usb_downlink_receiver import usb_downlink_receiver
from apollo.usb_signal_source import usb_signal_source
import pmt
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)
n_samples = 8 * samples_per_frame
tb = gr.top_block()
tx = usb_signal_source(snr_db=None)
head = blocks.head(gr.sizeof_gr_complex, n_samples)
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()
n_recovered = snk.num_messages()
if n_recovered == 0:
pytest.skip("No frames recovered in loopback -- PLL may need tuning")
# Validate first recovered frame through the demux engine
msg = snk.get_message(0)
if pmt.is_pair(msg):
payload = pmt.cdr(msg)
else:
payload = msg
frame_bytes = bytes(pmt.u8vector_elements(payload))
demux = DemuxEngine(output_format="raw")
result = demux.process_frame(frame_bytes)
assert "sync" in result
assert "words" in result
assert result["sync"]["frame_id"] >= 1
assert result["sync"]["frame_id"] <= 50
def test_loopback_with_noise(self):
"""Loopback at 30 dB SNR should still recover frames."""
from apollo.usb_downlink_receiver import usb_downlink_receiver
from apollo.usb_signal_source import usb_signal_source
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)
n_samples = 10 * samples_per_frame # more frames for noisy recovery
tb = gr.top_block()
tx = usb_signal_source(snr_db=30.0)
head = blocks.head(gr.sizeof_gr_complex, n_samples)
rx = usb_downlink_receiver()
snk = blocks.message_debug()
tb.connect(tx, head, rx)
tb.msg_connect(rx, "frames", snk, "store")
tb.run()
n_recovered = snk.num_messages()
# At 30 dB SNR with 10 frames, should get at least 1
assert n_recovered >= 1, (
f"Noisy loopback recovered {n_recovered} frames, expected >= 1"
)

135
tests/test_nrz_encoder.py Normal file
View File

@ -0,0 +1,135 @@
"""Tests for the NRZ encoder block."""
import numpy as np
import pytest
try:
from gnuradio import blocks, gr
HAS_GNURADIO = True
except ImportError:
HAS_GNURADIO = False
from apollo.constants import PCM_HIGH_BIT_RATE, SAMPLE_RATE_BASEBAND
pytestmark = pytest.mark.skipif(not HAS_GNURADIO, reason="GNU Radio not installed")
class TestNRZEncoder:
"""Test NRZ encoding of bit streams to baseband waveforms."""
def test_bit_one_maps_to_positive(self):
"""A single 1-bit should produce +1.0 repeated for samples_per_bit."""
from apollo.nrz_encoder import nrz_encoder
tb = gr.top_block()
samples_per_bit = int(SAMPLE_RATE_BASEBAND / PCM_HIGH_BIT_RATE)
src = blocks.vector_source_b([1])
enc = nrz_encoder(bit_rate=PCM_HIGH_BIT_RATE, sample_rate=SAMPLE_RATE_BASEBAND)
snk = blocks.vector_sink_f()
tb.connect(src, enc, snk)
tb.run()
output = np.array(snk.data())
assert len(output) == samples_per_bit
np.testing.assert_allclose(output, 1.0, atol=1e-6)
def test_bit_zero_maps_to_negative(self):
"""A single 0-bit should produce -1.0 repeated for samples_per_bit."""
from apollo.nrz_encoder import nrz_encoder
tb = gr.top_block()
samples_per_bit = int(SAMPLE_RATE_BASEBAND / PCM_HIGH_BIT_RATE)
src = blocks.vector_source_b([0])
enc = nrz_encoder(bit_rate=PCM_HIGH_BIT_RATE, sample_rate=SAMPLE_RATE_BASEBAND)
snk = blocks.vector_sink_f()
tb.connect(src, enc, snk)
tb.run()
output = np.array(snk.data())
assert len(output) == samples_per_bit
np.testing.assert_allclose(output, -1.0, atol=1e-6)
def test_alternating_bits(self):
"""Alternating [1,0,1,0] should produce +1*N, -1*N, +1*N, -1*N."""
from apollo.nrz_encoder import nrz_encoder
tb = gr.top_block()
samples_per_bit = int(SAMPLE_RATE_BASEBAND / PCM_HIGH_BIT_RATE)
bits = [1, 0, 1, 0]
src = blocks.vector_source_b(bits)
enc = nrz_encoder(bit_rate=PCM_HIGH_BIT_RATE, sample_rate=SAMPLE_RATE_BASEBAND)
snk = blocks.vector_sink_f()
tb.connect(src, enc, snk)
tb.run()
output = np.array(snk.data())
expected_levels = [1.0, -1.0, 1.0, -1.0]
for i, level in enumerate(expected_levels):
start = i * samples_per_bit
end = (i + 1) * samples_per_bit
segment = output[start:end]
np.testing.assert_allclose(
segment, level, atol=1e-6,
err_msg=f"Bit {i} (value {bits[i]}): expected {level}",
)
def test_output_length(self):
"""4 bits at 51200/5120000 (100 samp/bit) should produce 400 samples."""
from apollo.nrz_encoder import nrz_encoder
tb = gr.top_block()
n_bits = 4
samples_per_bit = int(SAMPLE_RATE_BASEBAND / PCM_HIGH_BIT_RATE) # 100
src = blocks.vector_source_b([1, 0, 1, 1])
enc = nrz_encoder(bit_rate=PCM_HIGH_BIT_RATE, sample_rate=SAMPLE_RATE_BASEBAND)
snk = blocks.vector_sink_f()
tb.connect(src, enc, snk)
tb.run()
output = np.array(snk.data())
assert len(output) == n_bits * samples_per_bit
def test_upsampling_ratio(self):
"""Each NRZ level should be held for exactly samples_per_bit samples."""
from apollo.nrz_encoder import nrz_encoder
tb = gr.top_block()
# Use a different rate pair to verify generality: 1600 bps at 5.12 MHz
# gives 3200 samples per bit
bit_rate = 1600
sample_rate = SAMPLE_RATE_BASEBAND
samples_per_bit = int(sample_rate / bit_rate) # 3200
bits = [1, 0]
src = blocks.vector_source_b(bits)
enc = nrz_encoder(bit_rate=bit_rate, sample_rate=sample_rate)
snk = blocks.vector_sink_f()
tb.connect(src, enc, snk)
tb.run()
output = np.array(snk.data())
assert len(output) == len(bits) * samples_per_bit
# First bit (1) -> +1.0 held for samples_per_bit
np.testing.assert_allclose(output[:samples_per_bit], 1.0, atol=1e-6)
# Second bit (0) -> -1.0 held for samples_per_bit
np.testing.assert_allclose(output[samples_per_bit:], -1.0, atol=1e-6)
def test_block_instantiation(self):
"""Block should instantiate with default parameters."""
from apollo.nrz_encoder import nrz_encoder
enc = nrz_encoder()
assert enc is not None
assert enc.samples_per_bit == int(SAMPLE_RATE_BASEBAND / PCM_HIGH_BIT_RATE)

View File

@ -0,0 +1,196 @@
"""Tests for the PCM frame source block."""
import numpy as np
import pytest
try:
from gnuradio import blocks, gr
HAS_GNURADIO = True
except ImportError:
HAS_GNURADIO = False
from apollo.constants import (
PCM_HIGH_WORDS_PER_FRAME,
PCM_LOW_WORDS_PER_FRAME,
PCM_SYNC_WORD_LENGTH,
PCM_WORD_LENGTH,
SUBFRAME_FRAMES,
)
from apollo.pcm_frame_source import FrameSourceEngine
from apollo.protocol import bits_to_sync_word, parse_sync_word
class TestFrameSourceEngine:
"""Test the pure-Python frame generation engine (no GR needed)."""
def test_frame_length(self):
"""High-rate frame should be 128 words * 8 bits = 1024 bits."""
engine = FrameSourceEngine(bit_rate=51200)
bits = engine.next_frame()
assert len(bits) == PCM_HIGH_WORDS_PER_FRAME * PCM_WORD_LENGTH
def test_frame_length_low_rate(self):
"""Low-rate frame should be 200 words * 8 bits = 1600 bits."""
engine = FrameSourceEngine(bit_rate=1600)
bits = engine.next_frame()
assert len(bits) == PCM_LOW_WORDS_PER_FRAME * PCM_WORD_LENGTH
def test_bits_are_binary(self):
"""Every output value should be 0 or 1."""
engine = FrameSourceEngine()
bits = engine.next_frame()
assert all(b in (0, 1) for b in bits)
def test_frame_counter_wraps(self):
"""Frame counter should cycle 1 -> 50 -> 1."""
engine = FrameSourceEngine()
assert engine.frame_counter == 1
# Generate 50 frames (one full subframe)
for expected_id in range(1, SUBFRAME_FRAMES + 1):
assert engine.frame_counter == expected_id
engine.next_frame()
# Should wrap back to 1
assert engine.frame_counter == 1
# One more frame to confirm it keeps going
engine.next_frame()
assert engine.frame_counter == 2
def test_frame_id_in_sync_word(self):
"""The 6-bit frame ID field in the sync word should match the counter."""
engine = FrameSourceEngine()
for expected_id in range(1, 6):
bits = engine.next_frame()
sync_word = bits_to_sync_word(bits[:PCM_SYNC_WORD_LENGTH])
parsed = parse_sync_word(sync_word)
assert parsed["frame_id"] == expected_id
def test_odd_even_sync(self):
"""Odd frames should have complemented sync core vs even frames."""
engine = FrameSourceEngine()
# Frame 1 (odd) and frame 2 (even) should differ in the core field
bits_1 = engine.next_frame()
bits_2 = engine.next_frame()
sync_1 = bits_to_sync_word(bits_1[:PCM_SYNC_WORD_LENGTH])
sync_2 = bits_to_sync_word(bits_2[:PCM_SYNC_WORD_LENGTH])
parsed_1 = parse_sync_word(sync_1)
parsed_2 = parse_sync_word(sync_2)
# Cores should be bitwise complements (within 15 bits)
assert (parsed_1["core"] ^ parsed_2["core"]) == 0x7FFF
def test_custom_payload(self):
"""Injected data bytes should appear in the data portion of the frame."""
engine = FrameSourceEngine()
payload = bytes([0xAA, 0x55, 0xDE, 0xAD])
bits = engine.next_frame(data=payload)
# Data starts after the 32-bit sync word
data_start = PCM_SYNC_WORD_LENGTH
for byte_idx, expected_byte in enumerate(payload):
byte_bits = bits[data_start + byte_idx * 8 : data_start + (byte_idx + 1) * 8]
recovered = 0
for b in byte_bits:
recovered = (recovered << 1) | b
assert recovered == expected_byte, (
f"Byte {byte_idx}: expected 0x{expected_byte:02x}, got 0x{recovered:02x}"
)
def test_default_zero_fill(self):
"""Without explicit data, payload should be zero-filled."""
engine = FrameSourceEngine()
bits = engine.next_frame()
# All data bits after sync should be zero
data_bits = bits[PCM_SYNC_WORD_LENGTH:]
assert all(b == 0 for b in data_bits)
@pytest.mark.skipif(not HAS_GNURADIO, reason="GNU Radio not installed")
class TestPCMFrameSourceBlock:
"""Test the GNU Radio sync_block wrapper."""
def test_block_instantiation(self):
"""Block should instantiate with default parameters."""
from apollo.pcm_frame_source import pcm_frame_source
src = pcm_frame_source()
assert src is not None
def test_produces_output(self):
"""Source should produce a stream of 0s and 1s."""
from apollo.pcm_frame_source import pcm_frame_source
tb = gr.top_block()
n_samples = 2048
src = pcm_frame_source(bit_rate=51200)
head = blocks.head(gr.sizeof_char, n_samples)
snk = blocks.vector_sink_b()
tb.connect(src, head, snk)
tb.run()
data = np.array(snk.data(), dtype=np.uint8)
assert len(data) == n_samples
# All values should be 0 or 1
assert np.all((data == 0) | (data == 1))
def test_frame_boundary(self):
"""Getting exactly one frame's worth of bits should work."""
from apollo.pcm_frame_source import pcm_frame_source
tb = gr.top_block()
frame_bits = PCM_HIGH_WORDS_PER_FRAME * PCM_WORD_LENGTH
src = pcm_frame_source(bit_rate=51200)
head = blocks.head(gr.sizeof_char, frame_bits)
snk = blocks.vector_sink_b()
tb.connect(src, head, snk)
tb.run()
data = snk.data()
assert len(data) == frame_bits
def test_continuous_stream(self):
"""Multiple frames should produce the expected total length."""
from apollo.pcm_frame_source import pcm_frame_source
tb = gr.top_block()
n_frames = 5
frame_bits = PCM_HIGH_WORDS_PER_FRAME * PCM_WORD_LENGTH
total_bits = n_frames * frame_bits
src = pcm_frame_source(bit_rate=51200)
head = blocks.head(gr.sizeof_char, total_bits)
snk = blocks.vector_sink_b()
tb.connect(src, head, snk)
tb.run()
data = snk.data()
assert len(data) == total_bits
def test_low_rate(self):
"""Low-rate source should produce 200-word frames."""
from apollo.pcm_frame_source import pcm_frame_source
tb = gr.top_block()
frame_bits = PCM_LOW_WORDS_PER_FRAME * PCM_WORD_LENGTH
src = pcm_frame_source(bit_rate=1600)
head = blocks.head(gr.sizeof_char, frame_bits)
snk = blocks.vector_sink_b()
tb.connect(src, head, snk)
tb.run()
data = snk.data()
assert len(data) == frame_bits

146
tests/test_pm_mod.py Normal file
View File

@ -0,0 +1,146 @@
"""Tests for the PM modulator block."""
import numpy as np
import pytest
try:
from gnuradio import blocks, gr
HAS_GNURADIO = True
except ImportError:
HAS_GNURADIO = False
from apollo.constants import PM_PEAK_DEVIATION_RAD, SAMPLE_RATE_BASEBAND
pytestmark = pytest.mark.skipif(not HAS_GNURADIO, reason="GNU Radio not installed")
class TestPMMod:
"""Test PM modulation with synthetic signals."""
def test_zero_input_constant_envelope(self):
"""Zero input should produce exp(j*0) = 1+0j (unit carrier)."""
from apollo.pm_mod import pm_mod
tb = gr.top_block()
n_samples = 10000
data = [0.0] * n_samples
src = blocks.vector_source_f(data)
mod = pm_mod(pm_deviation=PM_PEAK_DEVIATION_RAD, sample_rate=SAMPLE_RATE_BASEBAND)
snk = blocks.vector_sink_c()
tb.connect(src, mod, snk)
tb.run()
output = np.array(snk.data())
assert len(output) == n_samples
# Magnitude should be 1.0 (constant envelope)
magnitudes = np.abs(output)
np.testing.assert_allclose(magnitudes, 1.0, atol=1e-6)
# Phase should be 0 (no modulation)
phases = np.angle(output)
np.testing.assert_allclose(phases, 0.0, atol=1e-6)
def test_sine_input_phase_deviation(self):
"""Sine wave input should produce phase swinging +/- pm_deviation."""
from apollo.pm_mod import pm_mod
tb = gr.top_block()
n_samples = 100000
sample_rate = SAMPLE_RATE_BASEBAND
# Unit-amplitude sine at 10 kHz as modulating signal
t = np.arange(n_samples, dtype=np.float64) / sample_rate
tone_freq = 10000.0
modulating = np.sin(2 * np.pi * tone_freq * t).astype(np.float32)
src = blocks.vector_source_f(modulating.tolist())
mod = pm_mod(pm_deviation=PM_PEAK_DEVIATION_RAD, sample_rate=sample_rate)
snk = blocks.vector_sink_c()
tb.connect(src, mod, snk)
tb.run()
output = np.array(snk.data())
phases = np.angle(output)
# Peak phase should be approximately pm_deviation
peak_phase = np.max(np.abs(phases))
assert peak_phase == pytest.approx(PM_PEAK_DEVIATION_RAD, abs=0.01), (
f"Peak phase {peak_phase} doesn't match deviation {PM_PEAK_DEVIATION_RAD}"
)
def test_constant_envelope(self):
"""PM output should always have |s(t)| = 1.0 regardless of input."""
from apollo.pm_mod import pm_mod
tb = gr.top_block()
n_samples = 50000
sample_rate = SAMPLE_RATE_BASEBAND
# Arbitrary varying input: sum of two tones
t = np.arange(n_samples, dtype=np.float64) / sample_rate
modulating = (
0.7 * np.sin(2 * np.pi * 5000 * t) + 0.3 * np.cos(2 * np.pi * 20000 * t)
).astype(np.float32)
src = blocks.vector_source_f(modulating.tolist())
mod = pm_mod(pm_deviation=PM_PEAK_DEVIATION_RAD, sample_rate=sample_rate)
snk = blocks.vector_sink_c()
tb.connect(src, mod, snk)
tb.run()
output = np.array(snk.data())
magnitudes = np.abs(output)
np.testing.assert_allclose(magnitudes, 1.0, atol=1e-6)
def test_custom_deviation(self):
"""Custom pm_deviation should scale output phase accordingly."""
from apollo.pm_mod import pm_mod
tb = gr.top_block()
n_samples = 100000
sample_rate = SAMPLE_RATE_BASEBAND
custom_dev = 0.5
# Unit-amplitude sine
t = np.arange(n_samples, dtype=np.float64) / sample_rate
tone_freq = 10000.0
modulating = np.sin(2 * np.pi * tone_freq * t).astype(np.float32)
src = blocks.vector_source_f(modulating.tolist())
mod = pm_mod(pm_deviation=custom_dev, sample_rate=sample_rate)
snk = blocks.vector_sink_c()
tb.connect(src, mod, snk)
tb.run()
output = np.array(snk.data())
phases = np.angle(output)
peak_phase = np.max(np.abs(phases))
assert peak_phase == pytest.approx(custom_dev, abs=0.02), (
f"Peak phase {peak_phase} doesn't match custom deviation {custom_dev}"
)
def test_block_instantiation(self):
"""Block should instantiate with default parameters."""
from apollo.pm_mod import pm_mod
mod = pm_mod()
assert mod is not None
assert mod.get_pm_deviation() == PM_PEAK_DEVIATION_RAD
def test_set_pm_deviation(self):
"""Runtime deviation update should take effect."""
from apollo.pm_mod import pm_mod
mod = pm_mod()
assert mod.get_pm_deviation() == PM_PEAK_DEVIATION_RAD
mod.set_pm_deviation(0.25)
assert mod.get_pm_deviation() == 0.25

178
tests/test_sco_mod.py Normal file
View File

@ -0,0 +1,178 @@
"""Tests for the SCO (Subcarrier Oscillator) modulator block."""
import numpy as np
import pytest
try:
from gnuradio import blocks, gr
HAS_GNURADIO = True
except ImportError:
HAS_GNURADIO = False
from apollo.constants import (
SAMPLE_RATE_BASEBAND,
SCO_FREQUENCIES,
)
pytestmark = pytest.mark.skipif(not HAS_GNURADIO, reason="GNU Radio not installed")
class TestSCOModInstantiation:
"""Test block creation and parameter validation."""
def test_all_channels(self):
"""Should instantiate for each valid SCO channel (1-9)."""
from apollo.sco_mod import sco_mod
for ch in range(1, 10):
mod = sco_mod(sco_number=ch)
assert mod is not None
assert mod.center_freq == SCO_FREQUENCIES[ch]
def test_invalid_channel_zero(self):
"""Channel 0 should raise ValueError."""
from apollo.sco_mod import sco_mod
with pytest.raises(ValueError, match="SCO number must be 1-9"):
sco_mod(sco_number=0)
def test_invalid_channel_ten(self):
"""Channel 10 should raise ValueError."""
from apollo.sco_mod import sco_mod
with pytest.raises(ValueError, match="SCO number must be 1-9"):
sco_mod(sco_number=10)
def test_deviation_property(self):
"""Deviation should be 7.5% of center frequency."""
from apollo.sco_mod import sco_mod
for ch in range(1, 10):
mod = sco_mod(sco_number=ch)
expected = SCO_FREQUENCIES[ch] * 0.075
assert abs(mod.deviation_hz - expected) < 0.01
def test_custom_sample_rate(self):
"""Should accept a custom sample rate."""
from apollo.sco_mod import sco_mod
mod = sco_mod(sco_number=1, sample_rate=10_240_000)
assert mod is not None
class TestSCOModFunctional:
"""Functional tests with constant-voltage inputs."""
def _get_output(self, sco_number, voltage, n_samples=None,
sample_rate=SAMPLE_RATE_BASEBAND):
"""Feed constant voltage through sco_mod and return output samples."""
from apollo.sco_mod import sco_mod
if n_samples is None:
n_samples = int(sample_rate * 0.1) # 100ms
tb = gr.top_block()
src = blocks.vector_source_f([voltage] * n_samples)
mod = sco_mod(sco_number=sco_number, sample_rate=sample_rate)
snk = blocks.vector_sink_f()
tb.connect(src, mod, snk)
tb.run()
return np.array(snk.data())
def test_midscale_produces_center_freq(self):
"""Feed 2.5V DC, verify spectral peak near center frequency."""
sco_ch = 5 # 52,500 Hz
sample_rate = SAMPLE_RATE_BASEBAND
output = self._get_output(sco_ch, voltage=2.5, sample_rate=sample_rate)
assert len(output) > 0, "Modulator produced no output"
# Find dominant frequency via FFT
spectrum = np.abs(np.fft.rfft(output))
freqs = np.fft.rfftfreq(len(output), d=1.0 / sample_rate)
peak_idx = np.argmax(spectrum[1:]) + 1 # skip DC
peak_freq = freqs[peak_idx]
expected = SCO_FREQUENCIES[sco_ch]
tolerance = expected * 0.02 # 2% tolerance
assert abs(peak_freq - expected) < tolerance, (
f"SCO ch{sco_ch} at 2.5V: peak at {peak_freq:.0f} Hz, "
f"expected {expected} Hz +/- {tolerance:.0f} Hz"
)
def test_produces_output(self):
"""Feed 2.5V, verify non-zero output."""
output = self._get_output(sco_number=5, voltage=2.5)
assert len(output) > 0, "Modulator produced no output"
assert np.any(output != 0.0), "Output is all zeros"
def test_output_bounded(self):
"""Peak amplitude should be reasonable (< 2.0, > 0.1)."""
output = self._get_output(sco_number=5, voltage=2.5)
peak = np.max(np.abs(output))
assert peak > 0.1, f"Output too small: peak amplitude {peak:.4f}"
assert peak < 2.0, f"Output too large: peak amplitude {peak:.4f}"
def test_all_channels_produce_output(self):
"""All 9 channels should produce non-zero output with 2.5V input."""
for ch in range(1, 10):
output = self._get_output(sco_number=ch, voltage=2.5)
assert len(output) > 0, f"SCO ch{ch} produced no output"
assert np.any(output != 0.0), f"SCO ch{ch} output is all zeros"
class TestSCOModDemodRoundtrip:
"""Round-trip tests: sco_mod -> sco_demod should recover the input voltage."""
def _roundtrip(self, sco_number, voltage, n_samples=None,
sample_rate=SAMPLE_RATE_BASEBAND):
"""Feed voltage through sco_mod -> sco_demod, return demod output."""
from apollo.sco_demod import sco_demod
from apollo.sco_mod import sco_mod
if n_samples is None:
n_samples = int(sample_rate * 0.2) # 200ms for settling
tb = gr.top_block()
src = blocks.vector_source_f([voltage] * n_samples)
mod = sco_mod(sco_number=sco_number, sample_rate=sample_rate)
demod = sco_demod(sco_number=sco_number, sample_rate=sample_rate)
snk = blocks.vector_sink_f()
tb.connect(src, mod, demod, snk)
tb.run()
return np.array(snk.data())
def test_roundtrip_midscale(self):
"""sco_mod(2.5V) -> sco_demod should recover ~2.5V."""
sco_ch = 5 # 52,500 Hz
output = self._roundtrip(sco_ch, voltage=2.5)
assert len(output) > 0, "Round-trip produced no output"
# Skip first 50% for filter settling
settled = output[len(output) // 2:]
if len(settled) > 10:
mean_v = np.mean(settled)
assert 1.5 < mean_v < 3.5, (
f"SCO ch{sco_ch} round-trip at 2.5V: mean output {mean_v:.2f}V, "
f"expected near 2.5V"
)
def test_roundtrip_monotonic(self):
"""Feed 0V, 2.5V, 5V through mod->demod; output should be monotonic."""
sco_ch = 6 # 70,000 Hz
voltages = [0.0, 2.5, 5.0]
means = []
for v_in in voltages:
output = self._roundtrip(sco_ch, voltage=v_in)
settled = output[len(output) // 2:]
mean_v = np.mean(settled) if len(settled) > 10 else float("nan")
means.append(mean_v)
assert means[0] < means[1] < means[2], (
f"Non-monotonic round-trip: "
f"V_in={voltages}, V_out={[f'{v:.2f}' for v in means]}"
)

View File

@ -0,0 +1,113 @@
"""Tests for the USB signal source (complete transmit chain)."""
import numpy as np
import pytest
try:
from gnuradio import blocks, gr
HAS_GNURADIO = True
except ImportError:
HAS_GNURADIO = False
from apollo.constants import (
PCM_HIGH_BIT_RATE,
PCM_HIGH_WORDS_PER_FRAME,
PCM_WORD_LENGTH,
SAMPLE_RATE_BASEBAND,
)
pytestmark = pytest.mark.skipif(not HAS_GNURADIO, reason="GNU Radio not installed")
class TestUSBSignalSource:
"""Test the convenience transmit wrapper."""
def _get_samples(self, n_samples, **kwargs):
"""Helper: run usb_signal_source and return complex samples."""
from apollo.usb_signal_source import usb_signal_source
tb = gr.top_block()
src = usb_signal_source(**kwargs)
head = blocks.head(gr.sizeof_gr_complex, n_samples)
snk = blocks.vector_sink_c()
tb.connect(src, head, snk)
tb.run()
return np.array(snk.data())
def test_block_instantiation(self):
"""Block should instantiate with default parameters."""
from apollo.usb_signal_source import usb_signal_source
src = usb_signal_source()
assert src is not None
def test_produces_complex_output(self):
"""Output should be complex-valued samples."""
n_samples = 51200 # ~10ms worth
data = self._get_samples(n_samples)
assert len(data) == n_samples
assert data.dtype == np.complex128 or data.dtype == np.complex64
def test_constant_envelope(self):
"""PM signal without noise should have near-constant envelope."""
n_samples = 102400 # 1 frame worth
data = self._get_samples(n_samples, snr_db=None)
envelope = np.abs(data)
# PM output: |exp(j*phi)| = 1.0 always
np.testing.assert_allclose(envelope, 1.0, atol=1e-4)
def test_spectral_content_pcm(self):
"""FFT of demodulated phase should show energy at 1.024 MHz."""
n_samples = 102400
data = self._get_samples(n_samples, snr_db=None)
# Extract phase (equivalent to PM demod)
phase = np.angle(data)
fft = np.fft.fft(phase)
freqs = np.fft.fftfreq(len(phase), 1.0 / SAMPLE_RATE_BASEBAND)
# Energy near 1.024 MHz
pcm_mask = (np.abs(freqs) > 950_000) & (np.abs(freqs) < 1_100_000)
pcm_power = np.mean(np.abs(fft[pcm_mask]) ** 2)
total_power = np.mean(np.abs(fft) ** 2)
assert pcm_power > total_power * 0.01
def test_with_voice(self):
"""With voice enabled, output should still be constant envelope."""
n_samples = 51200
data = self._get_samples(n_samples, voice_enabled=True, snr_db=None)
envelope = np.abs(data)
np.testing.assert_allclose(envelope, 1.0, atol=1e-4)
def test_with_noise(self):
"""With noise, envelope should vary (not constant)."""
n_samples = 51200
data = self._get_samples(n_samples, snr_db=10.0)
envelope = np.abs(data)
# With noise, std(envelope) should be > 0
assert np.std(envelope) > 0.01
def test_voice_spectral_content(self):
"""With voice, phase should contain 1.25 MHz energy."""
n_samples = 102400
data = self._get_samples(n_samples, voice_enabled=True, snr_db=None)
phase = np.angle(data)
fft = np.fft.fft(phase)
freqs = np.fft.fftfreq(len(phase), 1.0 / SAMPLE_RATE_BASEBAND)
# Energy near 1.25 MHz
voice_mask = (np.abs(freqs) > 1_200_000) & (np.abs(freqs) < 1_300_000)
voice_power = np.mean(np.abs(fft[voice_mask]) ** 2)
assert voice_power > 0
def test_frame_duration(self):
"""One frame at 51.2 kbps should produce the right number 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)
data = self._get_samples(samples_per_frame)
assert len(data) == samples_per_frame