8. Architecture Decisions (ART 0513)
AD-001: Use FastAPI/Uvicorn for MRRC FT-710 Server
| Attribute | Value |
|---|---|
| Type | Architectural |
| Status | Implemented |
| Decision | Use FastAPI with native WebSocket routes and Uvicorn runtime |
Problem: The server needs static file serving, 4 WebSocket endpoints, async serial CAT I/O, scope subprocess management, audio streaming, and auth — all in one process.
Rationale: FastAPI/Uvicorn provides direct async integration, lifespan management, middleware, and a small code surface. No Tornado, Flask, or Django needed.
Consequences: All server logic lives in server.py with modular imports from sibling modules. Lifecycle managed via @asynccontextmanager lifespan.
AD-002: Direct Serial CAT — No Hamlib/Rigctld
| Attribute | Value |
|---|---|
| Type | Architectural |
| Status | Implemented |
| Decision | Use pyserial (sync API) with asyncio.to_thread() for serial I/O |
Problem: Hamlib adds a large dependency and another process to manage. FT-710 CAT protocol is well-documented (Yaesu standard) and straightforward.
Rationale: A dedicated CatController class with an asyncio.Lock for serialized access and thread-pool offloading is simpler, more debuggable, and has fewer failure modes than Hamlib/rigctld.
Consequences: The codebase is FT-710 specific. Adding another radio model requires a new commander class.
AD-003: Dirty-Field State Broadcasting
| Attribute | Value |
|---|---|
| Type | Design |
| Status | Implemented |
| Decision | RadioState dataclass tracks changed fields via _dirty_fields set; broadcast only changed fields |
Problem: Full state broadcasts on every poll would be wasteful (~44 fields at 10Hz).
Rationale: RadioState.update() records which fields changed; to_dirty_dict() sends only those fields. Clients merge partial updates into their local state.
Consequences: stateUpdate messages are compact; clients must maintain local state mirror.
AD-004: Tagged Dual-Codec Audio Transport (Opus + Int16 PCM)
| Attribute | Value |
|---|---|
| Type | Architectural |
| Status | Implemented |
| Decision | Both /WSaudioRX and /WSaudioTX carry a 1-byte codec tag per frame: 0x00 = Int16 PCM, 0x01 = Opus. RX: 48kHz @ 64kbps (fullband, transparent for broadcast music). TX: 48kHz @ 64kbps CBR (voice with fidelity priority: complexity=5, SIGNAL=VOICE, VBR/FEC/DTX disabled). Default Opus; falls back to PCM. |
Problem: (RX) Int16 PCM at 48kHz mono costs ~768kbps — heavy on mobile/WiFi. Opus at 64kbps cuts that 12×. (TX) Browser mic Opus encoding saves uplink bandwidth. A per-frame tag removes negotiation races — receiver inspects tag and decodes accordingly.
Rationale: opus_rx.py (copied from sunmrrc) provides direct ctypes libopus bindings. Uses max_data_bytes cap on opus_encode() to control bitrate — avoids arm64 variadic opus_encoder_ctl issues. Browser uses WASM OpusDecoder/OpusEncoder. TX encoder configured for voice with fidelity priority (static/modules/opus_codec.js): complexity=5, 64kbps CBR (stable packet size), FEC=OFF (WebSocket TCP is reliable), DTX=OFF (no priming gaps), fullband, SIGNAL=VOICE, LSB depth 16. (Raised from the original 28kbps/complexity=3 for transmit-fidelity headroom.)
Consequences: Adds libopus dependency (optional — degrades gracefully to PCM). Codec is user-switchable. AUDIO_TAG_PCM / AUDIO_TAG_OPUS are constants in both Python and JavaScript.
AD-005: scope_pipe as Standalone Subprocess
| Attribute | Value |
|---|---|
| Type | Architectural |
| Status | Implemented |
| Decision | FT4222 SPI I/O runs in a separate Python process (scope_pipe.py), communicating with the server via stdout/stderr pipes |
Problem: FT4222 ctypes calls are blocking and can hang. Running them in the asyncio event loop would stall the entire server. Threading is fragile with FTDI D2XX driver state.
Rationale: A subprocess isolates the FTDI driver. If scope_pipe crashes, the server continues (falls back to S-meter). Frame format: 4-byte BE uint32 length + payload. stderr carries machine-parseable STATUS: lines for diagnostics. Heartbeat frames (len=0) keep pipe alive when idle.
Consequences: scope_pipe is independently restartable. Server handles pipe exit gracefully (marks scope disconnected, switches to fallback). Two process lifecycle to manage.
AD-006: Dual-Mode Spectrum (FT4222 + S-Meter Fallback)
| Attribute | Value |
|---|---|
| Type | Design |
| Status | Implemented |
| Decision | When FT4222 is available, broadcast real 850-point FFT data. When unavailable, generate synthetic multi-peak Gaussian spectrum from CAT S-meter readings |
Problem: FT4222 requires specific libraries, D2XX driver config, and exclusive device access. It’s not always available.
Rationale: The S-meter fallback provides useful visual context (shows band activity) even without hardware scope. The binary frame format is identical in both modes — clients don’t care about the source.
Consequences: ScopeHandler has two code paths: update_from_scope_frame() (real data) and update_from_radio_state() (synthetic). scope._connected flag determines which is active.
AD-007: PTT Release as Safety-Critical Flow
| Attribute | Value |
|---|---|
| Type | Safety |
| Status | Implemented |
| Decision | Multiple independent release paths: normal WebSocket command (fire-and-forget TX0), PTT watchdog, dead-man switch on WS disconnect, beforeunload beacon, pagehide handler |
Problem: A lost or unprocessed PTT release command can leave the radio transmitting indefinitely — a serious safety and regulatory issue.
Rationale: Release is more safety-critical than keying. Each layer catches a different failure mode: lost WS message, half-open socket, browser crash, tab close, app switch. See Chapter 15 for detailed PTT Safety Architecture.
Consequences: Frontend PTT logic is more complex; polling skip-on-PTT ensures state consistency. (V1.2 removed the 3×200ms post-release verify loop — it added ~600ms to every release; stuck-keyup detection now relies on the 500ms TX-status poll plus the browser watchdog.)
AD-008: PyAudio Auto-Detection of FT-710 USB Audio
| Attribute | Value |
|---|---|
| Type | Design |
| Status | Implemented |
| Decision | Multi-layer device selection: (1) explicit FT710_AUDIO_RX_DEVICE/FT710_AUDIO_TX_DEVICE env var (index or name substring), (2) name match for “FT-710”/“FT710”/“YAESU”, (3) mono-channel heuristic (FT-710 USB audio has 1 input channel vs typical stereo USB mics), (4) full-duplex heuristic for TX (device with both input + output), (5) system default fallback |
Problem: The FT-710 USB audio device name varies by OS and driver version. Hardcoding a device index is fragile. Previous version only searched by name substring and fell back to first input device — could select webcam mic instead of FT-710.
Rationale: Name-based matching is more robust than index-based. The mono-channel heuristic is reliable: FT-710 provides exactly 1 input channel (mono RX), while webcams and USB mics typically offer 2 (stereo). Full-duplex preference for TX ensures the same device is used for both RX and TX paths. Logs all available devices at startup for debugging.
Consequences: Audio may still use wrong device if multiple mono USB audio devices are present. Configurable device override via env vars is the recommended approach for such setups.
AD-009: 7-Task Adaptive Polling with Bounded Lock Time
| Attribute | Value |
|---|---|
| Type | Design |
| Status | Implemented |
| Decision | Background CAT polling split into 7 cooperative tasks (IF, VFO, TX status, TX meters, settings, slow telemetry, connection watchdog), with skip-on-command and short per-query timeout |
Problem: Polling too fast floods the serial port; too slow makes the UI feel unresponsive. Some fields (S-meter) change rapidly; others (filter width) rarely.
Rationale: Fast path keeps only FA/MD0/SM0 at 100ms. VS/FB run separately at 500ms, so active-VFO tracking does not bloat the IF loop. TX status and TX meters are independent 500ms tasks; TX meter polling includes RM3/RM4/RM5/RM6 and is TX-only. Settings (2s) include RG0, MS, and tuner state; slow telemetry (5s) includes RM7/RM8, PR, AO, and RI0. Poll query timeout is 0.25s to cap lock occupancy.
Consequences: PollScheduler owns task-level cadence and backpressure controls (skip_next_poll(), short pause after user command, and _cancel_polls awareness). CAT errors remain per-command and non-fatal.
AD-010: Memory Channels as Server-Side JSON
| Attribute | Value |
|---|---|
| Type | Design |
| Status | Implemented |
| Decision | Memory channels stored server-side in mem_channels.json; API: GET/POST /api/mem_channels; auto-broadcast to all clients on change |
Problem: Client-side-only storage loses channels across devices/browsers. Server-side persistence ensures all clients see the same channels.
Rationale: Simple JSON file is adequate for 6-99 channel slots. No database needed. Auto-broadcast keeps all clients in sync.
Consequences: Channels survive server restarts. File is human-editable. No per-user channel isolation (single shared-password model).
AD-011: 48kHz Codec Domain with 44.1kHz Device Bridge
| Attribute | Value |
|---|---|
| Type | Design |
| Status | Implemented |
| Decision | TX audio chain runs at 48 kHz in the codec domain (browser capture → Opus encode → server decode) and plays to the radio at 44.1 kHz — the FT-710 USB audio native rate — via a frame-aligned resample bridge (audio_resample.py, numpy linear interp; 960↔︎882 samples = exactly 20 ms, ratio 160:147). RX uses the inverse bridge (44.1k capture → 48k Opus encode). |
Problem: V1.0 captured mic audio at 16 kHz (320 samples/20ms frame) but PyAudio played back at 48 kHz (expecting 960 samples/20ms). The 3:1 rate mismatch caused the output stream to underrun — every 20ms Opus frame produced 320 samples that filled only 1/3 of the 960-sample playback buffer. The remaining 2/3 was stale/residual buffer data, producing audible crackling (“咔咔咔”) on transmitted audio. The first fix unified everything at 48 kHz — but later measurement showed the FT-710 USB audio interface natively runs at 44.1 kHz, so 48 kHz PCM still could not be written straight to the device stream.
Rationale: Opus mandates 48 kHz; the FT-710 mandates 44.1 kHz. A stateless numpy linear-interp resampler bridges the two domains per 20 ms frame with exact integer alignment (960→882), costing ~µs per call with zero phase drift. Browser capture at 48 kHz works on all modern platforms (iOS 15+, Chrome, Firefox).
Consequences: Each direction has exactly one SRC step at the server boundary, owned by audio_resample.py. Browser and codec stay at 48 kHz; both PyAudio streams run at 44.1 kHz. The v1.0 underrun class of bug is impossible in both domains.
AD-012: Active-VFO-Aware Frequency Model
| Attribute | Value |
|---|---|
| Type | Design |
| Status | Implemented |
| Decision | Poll VS + FB at 0.5s and treat freq set command as “apply to currently active VFO” |
Problem: Using freq as VFO-A-only could update the wrong oscillator when VFO-B is active.
Rationale: Active-VFO tracking keeps UI and CAT semantics aligned with front-panel behavior.
Consequences: State now carries active_vfo, vfo_a_freq, and vfo_b_freq continuously.
AD-013: FT-710 Meter Calibration Tables
| Attribute | Value |
|---|---|
| Type | Design |
| Status | Implemented |
| Decision | Convert raw RM meter values (0–255) into engineering units via piecewise-linear calibration tables in config.py |
Problem: Raw meter values are not user-meaningful and are non-linear.
Rationale: Calibration points from FT-710 rig data provide practical watt/SWR/volt/amp displays without firmware changes.
Consequences: UI meters show engineering units; calibration can be tuned independently of polling logic.
AD-014: FT-710 CAT Errata Handling
| Attribute | Value |
|---|---|
| Type | Design |
| Status | Implemented |
| Decision | Apply FT-710-specific command corrections: treat DN as step-down (never poll), use PR00/PR01 for compressor, and map tuner control to AC000/AC001/AC003 |
Problem: Yaesu CAT documentation ambiguities can cause unintended RF behavior (frequency drift, invalid tuner command forms).
Rationale: Runtime behavior is grounded in observed FT-710 responses and cross-command consistency.
Consequences: Safer default control path; ambiguous commands are either corrected or intentionally omitted.
AD-015: Priority CAT Command Preemption
| Attribute | Value |
|---|---|
| Type | Safety / Responsiveness |
| Status | Implemented |
| Decision | Introduce send_priority_set_command() and _cancel_polls cooperative abort so latency-sensitive commands (PTT/TUNE) preempt poll queries |
Problem: User TX/RX transitions can stall behind in-flight poll cycles if every query holds the serial lock to timeout.
Rationale: Priority commands set a cancel flag observed by poll loops, queued query waiters, and _read_until() threads, reducing worst-case handoff delay.
Consequences: Poll loops must be cancel-aware; UX is significantly more responsive during fast PTT/tune transitions.
8.16 Decision Summary
| ID | Topic | Status |
|---|---|---|
| AD-001 | FastAPI/Uvicorn backend | Implemented |
| AD-002 | Direct serial CAT (no Hamlib) | Implemented |
| AD-003 | Dirty-field state broadcasting | Implemented |
| AD-004 | Tagged dual-codec audio (Opus + PCM) | Implemented |
| AD-005 | scope_pipe standalone subprocess | Implemented |
| AD-006 | Dual-mode spectrum (FT4222 + fallback) | Implemented |
| AD-007 | PTT release safety flow | Implemented |
| AD-008 | PyAudio FT-710 auto-detection | Implemented |
| AD-009 | 7-task adaptive polling with bounded lock time | Implemented |
| AD-010 | Memory channels as server-side JSON | Implemented |
| AD-011 | Unified 48kHz TX audio pipeline | Implemented |
| AD-012 | Active-VFO-aware frequency model | Implemented |
| AD-013 | FT-710 meter calibration tables | Implemented |
| AD-014 | FT-710 CAT errata handling | Implemented |
| AD-015 | Priority CAT command preemption | Implemented |