RX: radio USB codec -> arecord -> gain -> shared track -> MediaRelay -> Opus (per listener) -> browser
TX: browser mic -> Opus -> decode -> mono 48 kHz -> [SERVER GATE] -> gain + limiter -> aplay -> radio USB codec
Internal format: mono, s16, 48 kHz, 20 ms frames. Opus runs at 32 kbps (aiortc defaults to 96 kbps stereo); configurable with
audio.opus_bitrate. The audio runs in the same process as the rest of the server (a separate process was considered; the measured CPU cost on a Pi 4
makes it unnecessary).
http://): the installer sets that up, and your device must trust the Pi's certificate once
(security) or use the Tailscale name (tailscale).audio.max_peers, default 3). Any operator may attach a microphone, but the server passes audio only from
the connection that owns PTT.guard.keyed and guard.owner == conn_id). The browser also mutes its mic track outside PTT, but that is a
convenience, not the protection./api/audio/offer rejects a conn that belongs to someone else.On a real FT-991A with a Raspberry Pi 4:
tools/audio_spike.py on a Pi 4 (aarch64, Python 3.13): Opus encode 3-4 % of one core per stream, decode below 1 %, one
loopback listener about 21 % and three about 56 % of one core with both ends in one process (the real server-side cost is lower).
A Pi 4 has ample headroom for the default audio.max_peers = 3; a Pi 3B+ is expected to be 2-3 times slower per core: use 1-2.Automated tests (no radio) cover real WebRTC sessions in-process: RX audio and level meter, mic audio reaching the sink only while the gate is open, wrong connection, viewer role, peer limit, device loss and recovery.
Not measured yet: the latency of either transport (WebSocket audio, receive and transmit, and the automatic fallback with UDP blocked were verified on a real FT-991A with a Raspberry Pi 4), figure (budget: capture 20-80 ms + Opus 20 ms + network + the browser's jitter buffer).
WebRTC sends the audio as UDP packets. Some networks block that (hotel, office or school Wi-Fi, some mobile carriers, strict firewalls). The page then works but the audio does not connect. For that case the same Opus audio can travel over the page's own HTTPS/WebSocket connection (TCP).
AudioDecoder/AudioEncoder: current Chrome, Edge, Firefox and Safari 17 or newer) and, for the microphone, AudioWorklet and HTTPS.
Otherwise the page says so and WebRTC stays in use.audio.max_peers counts both kinds of listener.sudo apt install alsa-utils libopus0 # the installer does this
sudo usermod -aG audio radio-remote # service user needs the audio group (the installer does this)
arecord -l ; aplay -l # note the CARD=<id> of the USB Audio CODEC
Use Raspberry Pi OS Lite (no PipeWire/PulseAudio grabbing the device). Set audio.input_device /
audio.output_device to plughw:CARD=<id>,DEV=0 (a full ALSA name, never a bare number) only if auto-detection finds zero or several codecs.
The admin-only GET /api/audio/devices shows what the server sees.
arecord -l / aplay -l: confirm the codec appears when the radio is on, and what its card ID is.arecord -D plughw:CARD=<id>,DEV=0 -f S16_LE -r 48000 -c 1 -d 5 /tmp/rx.wav with a signal on the radio: play it back. If that is silent the problem is
on the radio side (menus, USB level), not in the app.audio.rx_gain_db.audio.tx_gain_db and the radio's data-out level until the ALC is just touching.Three places, in this order: 1. Browser Volume slider (Audio tab) at 100 %. 2. The radio's own USB output level menus (0-100, default 50): 107 SSB OUT LEVEL, 073 DATA OUT LEVEL, 046 AM OUT LEVEL, 075 FM OUT LEVEL, 054 CW OUT LEVEL, 099 RTTY OUT LEVEL (CAT manual p.9). This is the cleanest fix because it improves the signal-to-noise ratio instead of just amplifying. (On the tested FT-991A, menu 107 = 90 fixed a quiet receive level.) 3. Admin > Config > Audio > rx_gain_db: boost in dB (-30..30, live, no restart). +6 dB doubles the amplitude, +12 dB is four times. A boost above 0 dB runs through the same soft limiter as the transmit path, so loud signals do not clip hard.
The radio's AF gain knob is believed not to affect the USB audio (not verified separately); the app therefore does not offer an AF gain slider.