9. Architecture Overview (ART 0512)

9.1 Logical Architecture

Mobile Browser
  index.html / mobile.css / controls.js / mobile.js / modules
  Web Audio playback, mic capture, PTT UI, waterfall canvas
        |
        | HTTPS/WSS  (COOP: same-origin / COEP: credentialless / CORP: cross-origin)
        v
FastAPI SunMRRC App (`server.py`)
  static file catch-all
  /WSCTRX control
  /WSaudioRX RX audio fan-out
  /WSaudioTX TX mic uplink -> TxOpusDecoder -> TXModulator -> TX IQ stream
  /WSspectrum waterfall fan-out
        |
        | in-process imports
        v
Shared SunSDR + DSP Modules (`../web_control`)
  SunSDR2DXClient
  StreamProcessor
  SpectrumProcessor
  AudioDemodulator
        |
        | UDP
        v
SunSDR2 DX

9.1.1 TX Audio Flow (Browser Side)

Browser Mic (48 kHz)
  β†’ TxCaptureSABProcessor AudioWorklet (3:1 box-average downsample β†’ 16 kHz float32)
  β†’ SharedArrayBuffer ring buffer (lock-free SPSC, 16384 samples β‰ˆ 1.024 s)
  β†’ TX Opus Worker (poll every 3 ms, batch into 20 ms frames)
  β†’ Opus encode (28 kbps CBR, VBR, FEC, DTX)
  β†’ /WSaudioTX binary frames (tagged 0x01 Opus or 0x00 PCM)

Zero-main-thread path: the main thread creates the SAB and passes it to both the AudioWorklet (producer) and Opus Worker (consumer), then never touches audio samples again. GC pauses and UI jank cannot stall the TX stream.

9.1.2 TX Signal Chain (Server Side)

Server TX Pipeline:
  WSaudioTX Opus/PCM tagged frame
    β†’ TxOpusDecoder (if Opus) or Int16β†’float
    β†’ TXModulator.feed_audio()
    β†’ continuous DC blocker (one-pole IIR highpass @ 20 Hz, cross-frame continuous)
    β†’ 300 Hz 4th-order Butterworth highpass (removes ~38% sub-voice energy, reallocates PA headroom)
    β†’ anti-alias LPF (4th-order Butterworth @ 3.6 kHz, safety net below 7.8 kHz Nyquist)
    β†’ continuous fractional resampler (16k β†’ 15625 Hz)
    β†’ overlap-save Hilbert SSB (USB/LSB, 80-sample hops, 256-sample margin)
    β†’ linear upsample (15625 β†’ 39063 Hz, Γ—2.5)
    β†’ TX_DRIVE_GAIN (Γ—2.8) Γ— drive (0..1)
    β†’ tanh soft limiter @ TX_IQ_PEAK (1.0)
    β†’ 24-bit IQ packing (200 samples β†’ 1200 bytes)
    β†’ jitter buffer (hysteresis: prime 24 pkts, reprime 12 pkts)
    β†’ TX pacer thread (5.12 ms/pkt, adaptive Β±25%)
    β†’ 0xFFFD UDP to device :50002

TX modulation is Python Hilbert only β€” no WDSP TX C-chain. The WDSP library is used exclusively for RX (NR2, AGC, ANF, SNB on the IQ-rate DSP path). The TX path is a pure Python/NumPy/SciPy pipeline with Hilbert analytic-signal SSB generation.

TX gain staging (verified 2026-06-25 against server.log level probes): | Stage | Peak | RMS | Notes | |β€”β€”-|β€”β€”|—–|β€”β€”-| | Client mic input | ~0.15 | ~0.03 | After preamp Γ—1.5 + EQ, before AudioWorklet Int16 clip | | Server input (post-decode) | ~0.50 | ~0.05 | Client gain staging keeps headroom below 1.0 | | After 300 Hz HPF | ~0.35 | ~0.04 | ~38% energy removed (sub-voice rumble, proximity effect) | | After Hilbert SSB | ~0.46 | ~0.05 | Hilbert adds ~30% peak from analytic-phase construction | | After drive gain (Γ—2.8) | ~1.28 | ~0.14 | Drive lifts into tanh knee (~25% of peaks engage) | | After tanh(1.0) | ~0.85 | ~0.13 | tanh engages lightly β€” clean SSB envelope |

Key design point: The client preamp was reduced from 3.0β†’1.5 on 2026-06-25. TX_DRIVE_GAIN was reduced from 3.5β†’2.8 on 2026-06-29 (3.5 put tanh at ~60% duty cycle on voice peaks, gain-modulating the SSB envelope). At Γ—2.8, the tanh barely engages (~25% of peaks), preserving the clean SSB envelope while device drive (0x0017) controls actual RF power.

The browser renders each waterfall row by accumulating ~10 frames (38 Hz -> ~3.8 Hz), subtracting a per-row adaptive noise floor (30th percentile), then biasing the noise to a blue baseline and stretching signal contrast before mapping through a black/blue/cyan/yellow/red colour ramp. The S-meter applies asymmetric exponential smoothing client-side (attack alpha 0.5 / release alpha 0.15) so the needle rises fast and falls slowly.

RX audio is tagged dual-codec: each /WSaudioRX binary frame carries a 1-byte prefix (0x00=Int16 PCM, 0x01=Opus 16 kHz mono). The client inspects the tag and decodes accordingly β€” Opus via WASM OpusDecoder, PCM via decodeInt16Audio(). Default is Opus (~18-24 kbps); switchable via setOpus: / Audio Codec menu. Falls back to PCM if libopus is unavailable on the server.

9.2 Runtime View

Runtime Element Responsibility
app = FastAPI(title="SunMRRC") Owns HTTP and WebSocket route registration
_coop_coep_middleware Sets COOP:same-origin / COEP:credentialless / CORP:cross-origin headers required for SharedArrayBuffer
startup() Pre-binds IQ socket, starts dedicated keep-alive thread, boots device, creates DSP processor, starts IQ processing task
_iq_keepalive_thread() Dedicated OS thread: sends 0xFFFE keep-alive to port 50002 every 0.5s, independent of asyncio event loop β€” survives blocking WDSP init
_process_iq_stream() Owns UDP IQ socket, packet loop, PTT TX stream manageent, DSP feed, audio/spectrum dispatch
_tx_pacer_thread() Dedicated OS thread: time.sleep()-paced TX IQ at 5.12 ms/pkt (adaptive Β±25%), sends 0xFFFD to device:50002
_send_ctrl() Broadcasts text state updates to control clients
_broadcast_audio() Resamples DSP PCM to 48 kHz via Catmull-Rom cubic interpolation, Opus-encodes and broadcasts tagged RX frames
_broadcast_spectrum() Quantizes FFT bins and broadcasts waterfall frames
_find_ssl() Selects HTTPS runtime based on local cert/key files

9.3 WebSocket Architecture

Endpoint Client File Server Handler Payload
/WSCTRX controls.js, mobile.js, modules ws_ctrl() Text commands and text responses
/WSaudioRX controls.js ws_audio_rx() Binary tagged dual-codec (0x00=PCM / 0x01=Opus) server β†’ client
/WSaudioTX tx_opus_worker.js (SAB path) or controls.js (legacy path) ws_audio_tx() Binary tagged mic frames (Opus/PCM) β†’ decode β†’ TXModulator β†’ SunSDR TX IQ
/WSspectrum controls.js ws_spectrum() Binary uint8 spectrum rows

TX audio WebSocket ownership: With the SAB ring buffer path, the /WSaudioTX WebSocket is owned and managed by the TX Opus Worker thread, not the main thread. The Worker handles connection lifecycle, reconnection, and sending independently. The legacy postMessage path (used when SAB is unavailable) retains the main-thread WebSocket via controls.js.

9.4 Control Command Architecture

Command Group Commands Target
Liveness PING -> PONG Control socket
Query getFreq, getMode, getPTT, getWDSPStatus, getWDSPNotches Radio/DSP snapshot
Radio setFreq, setPTT, tune, setAFGain, setRFGain, setPreamp, setAGC, setFilter, setDrive SunSDR2DXClient and demodulator
DSP setMode, setWDSPEnabled, setWDSPNR2Level, setWDSPNR2, setWDSPNB, setWDSPANF, setWDSPNFEnabled, setWDSPAGC, notch commands AudioDemodulator
Safety/Misc s, cq Force RX, unblock CQ state machine
Codec setOpus, getOpus Toggle RX audio codec between Opus and Int16 PCM
ATT setATT Hardware attenuator: 0=-20dB, 1=-10dB, 2=0dB, 3=+10dB
Sample Rate setSampleRate IQ bandwidth selector: 39k/78k/156k/312k via 0x0001 HW_INIT

9.5 Signal Processing Architecture

9.5.1 RX Signal Chain

UDP IQ packet
  -> validate magic/subtype
  -> 24-bit signed I/Q decode
  -> complex64 normalized IQ
  -> StreamProcessor.feed_iq()
  -> SpectrumProcessor FFT -> latest_spectrum
  |    -> percentile -> getSignalLevel:* (control broadcast)
  |    -> _broadcast_spectrum() uint8 quantize -> WSspectrum (when spectrum_clients present)
  -> AudioDemodulator -> PCM audio buffer
  -> server Catmull-Rom cubic resample to 48 kHz
  -> Opus encode (or PCM passthrough) with 1-byte codec tag
  -> WSaudioRX broadcast

9.5.2 TX Signal Chain

Browser Side

Browser Mic (48 kHz)
  -> TxCaptureSABProcessor AudioWorklet (3:1 box-average downsample β†’ 16 kHz float32)
  -> SharedArrayBuffer ring buffer (lock-free SPSC, word[0]=write_pos, word[1]=read_pos, word[2+]=float32 data, 16384 samples)
  -> TX Opus Worker (poll every 3 ms, batch into 20 ms frames)
  -> Opus encode (28 kbps CBR, VBR, FEC, DTX)
  -> /WSaudioTX binary frames (tagged 0x01 Opus or 0x00 PCM)

Zero-main-thread path: After the SAB is created and passed to the AudioWorklet (producer) and Opus Worker (consumer), the main thread never touches audio samples. The Opus Worker owns its own WebSocket connection to /WSaudioTX β€” it manages connection, reconnection, encoding, and sending independently. This eliminates audio dropouts caused by main-thread GC pauses and UI jank.

Legacy fallback path (SAB unavailable / older browsers):

AudioWorklet -> postMessage Int16 frames -> main thread -> wsAudioTX.send()

Browser EQ Pipeline (Web Audio, all phase-continuous)

micSource β†’ preamp(Γ—1.5, +3.5dB) β†’ antiAlias(4.5kHz, Γ—2) β†’
eqLow(350Hz peaking) β†’ eqMid(1500Hz peaking) β†’ eqHigh(2700Hz highshelf) β†’
midCut β†’ presence β†’ compressor(3:1, thr=-24dB, knee=12) β†’
makeup(Γ—1.6) β†’ noiseGate β†’ gain_node β†’ AudioWorklet sink
Presets: DEFAULT(+6/+8/-6dB), MEDIUM(+9/+10/-12dB), STRONG(+12/+12/-18dB), RAGCHEW

Server Side

Server TX Pipeline:
  WSaudioTX Opus/PCM tagged frame
    -> TxOpusDecoder (if Opus) or Int16β†’float
    -> TXModulator.feed_audio()
    -> continuous DC blocker (one-pole IIR highpass @ 20 Hz, cross-frame continuous state)
    -> 300 Hz 4th-order Butterworth highpass (reclaims ~38% PA headroom from sub-voice energy)
    -> anti-alias LPF (4th-order Butterworth @ 3.6 kHz)
    -> continuous fractional resampler (16k β†’ 15625 Hz)
    -> overlap-save Python Hilbert SSB (USB/LSB, 80-sample hops, 256-sample margin)
    -> linear upsample (15625 β†’ 39063 Hz, Γ—2.5)
    -> TX_DRIVE_GAIN (Γ—2.8) Γ— drive (0..1)
    -> tanh soft limiter @ TX_IQ_PEAK (1.0)
    -> 24-bit IQ packing (200 samples β†’ 1200 bytes, vectorized numpy)
    -> jitter buffer (hysteresis: prime 60 pkts, reprime 20 pkts)
    -> TX pacer thread (5.12 ms/pkt, adaptive Β±25%)
    -> 0xFFFD UDP to device :50002

TX modulation properties: - Python Hilbert (scipy.signal.hilbert) is the sole SSB modulation path β€” no WDSP TX C-chain - Continuous DC blocker: one-pole IIR H(z) = (1 - z^-1) / (1 - Rz^-1), R = exp(-2Ο€20/16000), persistent state across feed_audio() calls. Replaces per-frame x-=x.mean() which caused DC jumps at 76% of frame boundaries (audible clicks). - 300 Hz HPF: 4th-order Butterworth (sosfilt, phase-continuous). Removes room rumble, proximity effect, subsonic noise β€” ~38% of mic energy sits below 300 Hz where SSB suppresses it, wasting PA headroom. - tanh soft limiter: smooth saturation curve replaces hard magnitude clipping (which generated wideband splatter at syllable boundaries). Ceiling is fixed at TX_IQ_PEAK=1.0, independent of drive β€” drive scales the level under a constant clip point. - TX_DRIVE_GAIN=2.8: keeps tanh engagement at ~25% of voice peaks (down from 60% at Γ—3.5, which gain-modulated the SSB envelope).

TX gain staging (verified against server.log level probes): | Stage | Peak | RMS | Notes | |β€”β€”-|β€”β€”|—–|β€”β€”-| | Client mic input | ~0.15 | ~0.03 | After preamp Γ—1.5 + EQ, before AudioWorklet | | Server input (post-decode) | ~0.50 | ~0.05 | Client gain staging keeps headroom below 1.0 | | After 300 Hz HPF | ~0.35 | ~0.04 | ~38% energy removed (sub-voice rumble) | | After Hilbert SSB | ~0.46 | ~0.05 | Hilbert adds ~30% peak | | After drive gain (Γ—2.8) | ~1.28 | ~0.14 | Drive lifts into tanh knee | | After tanh(1.0) | ~0.85 | ~0.13 | tanh engages ~25% β€” clean SSB |

9.6 Frontend Architecture

File Responsibility
index.html Mobile UI structure, hidden compatibility DOM, script order
mobile.css Mobile visual system, safe areas, controls, panels
controls.js Core audio/control WebSockets, RX decode/playback, signal display, /WSspectrum waterfall rendering (frame accumulation + adaptive noise floor + colour ramp), SAB ring buffer creation + TX Worker/Worklet orchestration
mobile.js Mobile-specific state, band/mode UX, updateSMeter exponential smoothing, memory manager, menus, DSP panel, ATR frontend hooks
tx_button.js Touch/PTT flow, lock handling, watchdog, warm-up frames
tx_capture_worklet.js AudioWorklet TX capture: 48k→16k box-average downsample, float32 → SAB ring buffer write (zero-main-thread path) or Int16 → postMessage (legacy fallback)
tx_opus_worker.js Dedicated Worker thread: SAB ring buffer consumer, Opus encoder, owns /WSaudioTX WebSocket, polls SAB every 3 ms, encodes 20 ms frames
modules/ptt_manager.js PTT command state, release ACK retry, status display
modules/tune_cq.js Tune and CQ UI state machine
modules/tx_audio_eq.js Browser TX EQ presets (DEFAULT/MEDIUM/STRONG/RAGCHEW), preamp Γ—1.5, compressor 3:1, anti-alias 4.5 kHz
modules/settings_manager.js Cookie-backed preferences and frequency bookmarks
modules/opus_codec.js Browser-side WASM Opus encoder/decoder
modules/opus_wasm.js Opus WASM binary loader
rx_worklet_processor.js AudioWorklet queue player for Float32 RX frames
sw.js Static asset cache; explicitly bypasses JS/HTML

9.7 Deployment Architecture

restart.sh
  -> find old server.py by cwd
  -> terminate old process
  -> clear listening port only
  -> activate ../venv if present
  -> run python3 server.py
  -> log to server.log

server.py
  -> _find_ssl()
  -> uvicorn.run(... ssl_certfile/keyfile ...) or HTTP fallback

9.8 Known Architectural Gaps

Gap Impact
Fixed local IQ bind/send IPs in server.py Deployment tied to current LAN topology
/WSATR1000 stub ATR UI status remains placeholder; accepts connections but doesn’t interface with real tuner hardware. Only available SWR source.
CW/FT8 pages absent Menu links removed from the navigation; pages not present in this snapshot
Device telemetry: no SWR 0x1F00 has no reverse-power field (off30=forward W, off16=supply V, off18=PA temp); external tuner required for SWR
SAB requires COOP/COEP SharedArrayBuffer needs COOP:same-origin + COEP:credentialless headers; without them, falls back to legacy postMessage path with reduced TX reliability