9. Architecture Overview (ART 0512)

9.1 Logical Architecture

Mobile/Desktop Browser
  index.html / ft710.css / ft710_main.js / ft710_ui.js
  Web Audio playback, mic capture, PTT UI, waterfall canvas
        |
        | HTTP/WS
        v
FastAPI MRRC FT-710 Server (`server.py`)
  static file serving
  /WSradio — control (JSON)
  /WSaudioRX — RX audio fan-out (binary tagged Opus/PCM)
  /WSaudioTX — TX mic uplink (binary tagged → decode → PyAudio)
  /WSspectrum — waterfall fan-out (binary)
  /api/mem_channels — memory channel CRUD
  /api/status — full radio state
  /api/auth/* — session management
        |
        | in-process imports
        v
Backend Modules
  cat_controller.py — serial CAT protocol (pyserial + asyncio threads)
  radio_state.py — shared state with dirty-field tracking
  poll_scheduler.py — 7-task adaptive polling with priority-command preemption
  audio_handler.py — PyAudio capture/playback + Opus codec
  opus_rx.py — libopus ctypes wrapper
  scope_handler.py — spectrum data + S-meter fallback
  scope_pipe.py — standalone FT4222 SPI subprocess
  config.py — constants, mode/band tables, S-meter calibration
        |
        | Serial / SPI / USB Audio
        v
Yaesu FT-710

9.2 WebSocket Endpoints

9.2.1 /WSradio (JSON text)

Control channel. Carries all radio commands, state updates, and memory management.

Server → Client: - {"type":"fullState","data":{...},"bands":[...],"modes":[...]} — initial sync on connect - {"type":"stateUpdate","fields":{...},"dirty":[...]} — partial changed-field update - {"type":"value","field":"...","value":...} — single-value query response - {"type":"memChannels","channels":[...]} — memory channel broadcast - {"type":"pong"} — keepalive response

Client → Server: - {"type":"set","field":"...","value":...} — command (40+ supported fields) - {"type":"get","field":"..."} — query current value - {"type":"memSave","channels":[...]} — save memory channels - {"type":"memDelete","index":N} — delete memory slot - {"type":"ping"} — keepalive

9.2.2 /WSaudioRX (binary)

Format: 1-byte codec tag (0x00=PCM, 0x01=Opus) + payload.

Server captures 48kHz Int16 mono from FT-710 USB audio → Opus encodes (64kbps default) → broadcasts to all audio_rx_clients at 20ms intervals. Browser decodes via WASM OpusDecoder (or Int16→Float32 for PCM) → AudioWorklet playback with jitter buffer.

9.2.3 /WSaudioTX (binary + text)

Binary: 1-byte codec tag + encoded mic audio. Server decodes (Opus→PCM or pass-through PCM) → queues to PyAudio output stream → played to FT-710 USB audio input.

Text: "s:" = stop TX; "m:rate,encode,..." = settings.

9.2.4 /WSspectrum (binary)

v1 format: 1-byte version (0x01) + 850 bytes wf1 = 851 bytes. v2 format: 1-byte version (0x02) + 850 bytes wf1 + 850 bytes wf2 = 1701 bytes.

Broadcast at ~30 fps from FT4222 data or S-meter fallback.

9.3 RX Audio Signal Chain

FT-710 USB Audio Output
  → PyAudio capture (44.1kHz, Int16, mono, 882-sample chunks = 20ms)
    → AudioHandler.read_rx_chunk() (non-blocking poll from asyncio loop)
      → resample_441_to_48 (882→960 samples, frame-aligned)
        → AudioHandler.encode_rx_audio()
        → Opus encode (RxOpusEncoder, 64kbps, 20ms frames)
          → 1-byte tag (0x01) + Opus packet
        → OR PCM fallback: 1-byte tag (0x00) + Int16 bytes
          → /WSaudioRX broadcast to all audio_rx_clients
            → Browser: decodeRxAudioFrame()
              → Opus: WASM OpusDecoder.decode_float() → Float32Array
              → PCM: Int16Array → Float32Array (÷32767)
                → AudioWorklet 'rx-player': port.postMessage({type:'push', payload})
                  → Jitter buffer (220ms prebuffer, 90ms recovery, 800ms max)
                    → AudioContext.destination → speakers

9.4 TX Audio Signal Chain

Browser Microphone
  → getUserMedia({sampleRate:48000, channelCount:1})
    → AudioContext.createMediaStreamSource → AudioWorklet 'tx-capture' (ScriptProcessor fallback)
      → 48kHz float32 20ms frames → postMessage → main thread → Worker ('float_frame')
        → Opus Worker (tx_opus_worker.js, 48kHz):
          → OpusEncoder.encode_float(960-sample frames)
            → 1-byte tag (0x01) + Opus packet (64kbps CBR, complexity=5)
          → OR PCM fallback: 1-byte tag (0x00) + Int16 bytes
            → postMessage({type:'tx_audio', data:tagged.buffer}, [tagged.buffer])
              → main thread: wsAudioTX.send(tagged)
                → /WSaudioTX → server
                  → Opus: TxOpusDecoder.decode() → Int16 PCM (48kHz, 960 samples/frame)
                  → PCM: pass-through
                    → AudioHandler.feed_tx_audio() → resample_48_to_441 (960→882, frame-aligned) → _tx_queue
                      → AudioHandler.write_tx_chunk() (10ms drain loop)
                        → PyAudio output stream (44.1kHz, mono) → FT-710 USB Audio Input

TX runs at 48 kHz throughout the codec domain — browser capture and Opus encode/decode — and the server bridges to the FT-710’s native 44.1 kHz USB audio via frame-aligned resampling (960↔︎882 = exactly 20 ms, ratio 160:147). This eliminates the v1.0 sample-rate mismatch (16 kHz mic → 48 kHz playback) that caused audible crackling on transmitted audio.

9.5 Spectrum Signal Chain

9.5.1 FT4222 Path (Real FFT Data)

FT-710 FT4222 SPI chip
  → scope_pipe.py subprocess (ctypes → libft4222)
    → 4096-byte SPI reads → frame sync → v2 payload
      → stdout: [4-byte BE length][version(1B) + wf1(850B) + wf2(850B) + metadata]
        → server.py _read_scope_pipe() (asyncio stdout reader)
          → parse_pipe_payload() → ScopeFrame
            → scope.spectrum_rx1 = wf1, scope.spectrum_rx2 = wf2
            → metadata: s_meter, vfoa_freq, mode, span, preamp, att
            → _on_scope_frame() → update RadioState, broadcast
    → stderr: "STATUS:..." diagnostic lines → server logging

9.5.2 S-Meter Fallback (Synthetic Spectrum)

CAT SM0; poll (100ms)
  → radio_state.s_meter (0–255)
    → scope_handler.update_from_radio_state(radio)
      → generate Gaussian peaks at band edges + center
        → scale to 850-point wf1 array
          → scope.get_spectrum_binary() → v1 (851B) or v2 (1701B) frame
            → /WSspectrum broadcast (same format as FT4222 path)

9.6 CAT Polling Architecture

PollScheduler (asyncio, 7 cooperative tasks)
  Tier 1  (100ms):      FA; MD0; SM0;                     → vfo_a_freq, mode, s_meter
  Tier 1b (500ms):      VS; FB;                           → active_vfo, vfo_b_freq
  Tier 2A (500ms, TX):  RM3; RM4; RM5; RM6;              → comp, alc, power, swr
  Tier 2B (500ms):      TX;                               → tx_status
  Tier 3  (2s):         SH0; AG0; RG0; PC; PA0; RA0; NB0; NR0; BC; AC; SS01; AN; GT; MS;
                        → filter/gains/preamp/att/NR/NB/AN/tuner/scope/antenna/agc/meter_display
  Tier 4  (5s):         RM7; RM8; PR; CO; AO; RI0;       → id, vd, compressor, contour, amc, radio-info telemetry
  Tier 5  (1s):         connection watchdog               → reconnect + full-state re-sync

User commands skip next poll for affected fields (`skip_next_poll`).
PTT/TUNE commands use priority writes (`send_priority_set_command`) and set `_cancel_polls`,
which poll loops and read threads observe to release the serial lock quickly.
CAT I/O remains serialized through `CatController._lock`; blocking serial work is offloaded via `asyncio.to_thread()`.

9.7 State Broadcasting

Command or Poll → RadioState.update(field=value)
  → _dirty_fields.add(field)
    → _broadcast_state()
      → dirty = radio.get_and_clear_dirty()
        → update = {type:"stateUpdate", fields:radio.to_dirty_dict(dirty), dirty:list(dirty)}
          → json.dumps → send_text to all ctrl_clients
            → browser: handleMessage() → Object.assign(radioState, msg.fields) → renderUpdates(msg.dirty)