Audio Backend Abstraction + Smarter Audio Bridge¶
Date: 2026-04-09 Status: Design (not implemented) Scope: Variant A (near-term) — smarter bridge on top of existing system audio devices (e.g., BlackHole/Loopback/VB-Cable via PortAudio/sounddevice)
1. Problem Statement¶
The current AudioBridge (audio_bridge.py) routes PCM between the radio and a virtual audio device (e.g. BlackHole) so that third-party apps (WSJT-X, fldigi, JS8Call) can use the radio as a sound card. It works, but has several pain points:
Device-name coupling¶
find_loopback_device()does substring matching on display names ("BlackHole","Loopback","VB-Audio","Virtual").- Display names are locale-dependent and change across OS updates. A user who upgrades macOS or installs a second virtual device may silently get the wrong device.
- There is no concept of a stable device ID (e.g., CoreAudio device UID on macOS) — only the integer
indexreturned bysounddevice.query_devices(), which is session-ephemeral.
No auto-detect / auto-connect¶
- If the device disappears mid-session (e.g. BlackHole unloaded), the bridge crashes with a sounddevice
PortAudioError. No reconnect. - If the user omits
--device, the bridge tries four hardcoded name patterns. If none match, the user must know the exact substring.
Sample-rate mismatch potential¶
AudioBridgehardcodesSAMPLE_RATE = 48000. The radio always sends 48 kHz, but some virtual devices default to 44.1 kHz. If the user's system audio graph disagrees, CoreAudio silently resamples — adding latency and potential artefacts.- No explicit sample-rate negotiation or mismatch detection.
No level normalization¶
- Radio PCM levels vary by radio model and mode. WSJT-X expects roughly -26 dB FS for proper decode.
- There is a basic noise gate (
silence_threshold = 10) in the TX path, but no RX level management, no limiter, no configurable target.
Metrics are minimal¶
statstracks frame counts, drops, and inter-frame intervals. No underrun/overrun counters from the audio backend, no jitter measurement, no latency breakdown (radio→bridge, bridge→device).
Two separate audio subsystems¶
AudioBridge(LAN path) andUsbAudioDriver(serial path) both opensounddevicestreams, both do device selection, but share no code.UsbAudioDriveralready has a better device model (UsbAudioDevicedataclass,select_usb_audio_devices()with override + auto-pick, topology resolution).AudioBridgeduplicates this logic poorly.
2. Proposed Architecture¶
2.1 Core Interfaces¶
# src/icom_lan/audio/backend.py
from __future__ import annotations
import enum
from dataclasses import dataclass, field
from typing import Protocol, runtime_checkable, Callable, AsyncIterator
class AudioDeviceId:
"""Stable, platform-specific device identifier.
Wraps a native persistent ID when available (e.g. CoreAudio device UID
on macOS) alongside the ephemeral integer index used by
sounddevice/PortAudio.
"""
__slots__ = ("platform_uid", "index", "display_name")
def __init__(self, platform_uid: str, index: int, display_name: str) -> None:
self.platform_uid = platform_uid # stable across sessions
self.index = index # ephemeral, used by sounddevice
self.display_name = display_name # human-readable
class StreamDirection(enum.Enum):
INPUT = "input" # capture (device → app)
OUTPUT = "output" # playback (app → device)
@dataclass(frozen=True, slots=True)
class AudioDeviceInfo:
"""Normalized device descriptor — superset of current UsbAudioDevice."""
id: AudioDeviceId
input_channels: int
output_channels: int
default_samplerate: int = 48_000
supported_samplerates: tuple[int, ...] = (48_000,)
is_virtual: bool = False # True for BlackHole, Loopback, etc.
is_default_input: bool = False
is_default_output: bool = False
@property
def supports_rx(self) -> bool:
return self.input_channels > 0
@property
def supports_tx(self) -> bool:
return self.output_channels > 0
@property
def duplex(self) -> bool:
return self.supports_rx and self.supports_tx
@runtime_checkable
class RxStream(Protocol):
"""Inbound audio stream contract (device → app)."""
async def read(self, num_frames: int) -> tuple[bytes, bool]:
"""Read PCM frames. Returns (pcm_data, overflowed)."""
...
def stop(self) -> None: ...
def close(self) -> None: ...
@property
def active(self) -> bool: ...
@property
def device(self) -> AudioDeviceId: ...
@runtime_checkable
class TxStream(Protocol):
"""Outbound audio stream contract (app → device)."""
async def write(self, pcm_data: bytes) -> int:
"""Write PCM frames. Returns frames written."""
...
def stop(self) -> None: ...
def close(self) -> None: ...
@property
def active(self) -> bool: ...
@property
def device(self) -> AudioDeviceId: ...
@runtime_checkable
class AudioBackend(Protocol):
"""Abstract audio backend — the main extension point."""
@property
def name(self) -> str:
"""Backend identifier (e.g. 'portaudio', 'coreaudio', 'pipewire')."""
...
def list_devices(self) -> list[AudioDeviceInfo]: ...
def find_device(
self,
*,
name: str | None = None,
uid: str | None = None,
virtual_only: bool = False,
) -> AudioDeviceInfo | None: ...
async def open_rx_stream(
self,
device: AudioDeviceId,
*,
sample_rate: int = 48_000,
channels: int = 1,
frame_ms: int = 20,
) -> RxStream: ...
async def open_tx_stream(
self,
device: AudioDeviceId,
*,
sample_rate: int = 48_000,
channels: int = 1,
frame_ms: int = 20,
) -> TxStream: ...
2.2 Backend Implementations¶
PortAudioBackend (default, cross-platform)¶
- Wraps
sounddevice(PortAudio bindings) — the dependency we already have. - Implements
AudioBackendby delegating tosd.InputStream/sd.OutputStream. AudioDeviceId.platform_uid: on macOS, query CoreAudiokAudioDevicePropertyDeviceUIDviactypescall toAudioObjectGetPropertyData. On Linux/Windows, fall back tof"{name}:{hostapi}"as a semi-stable identifier (PortAudio does not expose native IDs).- Virtual device detection: check name against known patterns + check
hostapifor loopback-class APIs. - This replaces both
find_loopback_device()inaudio_bridge.pyandlist_usb_audio_devices()inusb_driver.pywith a single implementation.
CoreAudioLoopbackBackend (macOS, Variant A enhancement)¶
- This is still the same PortAudio/sounddevice backend, but with better device identification on macOS by pulling the CoreAudio device UID.
- It can also optionally read device sample-rate / “device alive” properties to improve auto-reconnect behavior.
(We intentionally do not design or discuss any native virtual-device drivers in this document.)
2.3 Backend Registry and Selection¶
# src/icom_lan/audio/backends/__init__.py
_BACKENDS: dict[str, type[AudioBackend]] = {}
def register_backend(name: str, cls: type[AudioBackend]) -> None:
_BACKENDS[name] = cls
def get_backend(name: str | None = None) -> AudioBackend:
"""Get backend by name, or auto-detect the best available."""
if name:
return _BACKENDS[name]()
# Auto-detect: try platform-specific first, fall back to portaudio
for candidate in ("coreaudio", "pipewire", "wasapi", "portaudio"):
if candidate in _BACKENDS:
return _BACKENDS[candidate]()
raise RuntimeError("No audio backend available")
2.4 Unified Architecture Diagram¶
┌─────────────────────────┐
│ AudioBridge v2 │
│ (bridge + DSP pipeline) │
└────────┬────────────────┘
│
┌────────────▼────────────────┐
│ AudioBackend │ ← Protocol
│ list_devices() │
│ find_device() │
│ open_rx_stream() │
│ open_tx_stream() │
└────────────┬────────────────┘
│
┌────────────────┼────────────────────┐
│ │ │
PortAudioBackend CoreAudioBackend
(sounddevice) (macOS UID helpers)
Both AudioBridge (LAN path) and UsbAudioDriver (serial path) would use the same AudioBackend interface, eliminating the duplicated device enumeration and stream management code.
3. Bridge Behavior (Variant A)¶
3.1 Auto-detect Device Selection¶
Priority order when --device is not specified:
- Saved preference: check
~/.config/icom-lan/audio.tomlforbridge.device_uid. - Virtual device heuristic: scan
list_devices(), filteris_virtual == True, prefer devices with "BlackHole" or "Loopback" in name. - Most-recently-used: if multiple virtual devices exist, prefer the one used in the last session (stored in config).
- Fail with actionable error: list available virtual devices and suggest
brew install blackhole-2ch.
When --device is specified, resolve it as:
- Exact UID match → use directly
- Substring of display name → resolve to UID, warn if ambiguous
- Integer index → use as ephemeral fallback, warn about instability
3.2 Auto-connect / Reconnect¶
State machine:
IDLE ──start()──► CONNECTING ──device_found──► RUNNING
│ │
│ device_lost/error
│ │
◄───── RECONNECTING ◄───────┘
(backoff: 1s, 2s, 4s, max 30s)
│
max_retries (10)
│
▼
FAILED ──start()──► CONNECTING
- On device disappearance: close streams, enter RECONNECTING.
- On each retry: re-enumerate devices, try to find the same UID.
- Emit a
bridge_state_changedevent (for Web UI status display). - Log at WARNING on each retry, ERROR on FAILED.
3.3 Sample-rate Policy¶
Default: match radio (48 kHz). Always.
Mismatch handling:
1. Query device's supported sample rates via backend.
2. If 48 kHz is supported → use it (most virtual devices support this).
3. If not supported → insert a resampler in the pipeline. Use samplerate library (already available via numpy ecosystem) or scipy.signal.resample_poly.
4. Warn the user: "Device 'X' does not support 48kHz natively; resampling from 48kHz to 44.1kHz (adds ~5ms latency).".
CLI override: --bridge-sample-rate 44100 forces a specific rate (user takes responsibility for quality).
3.4 Level Normalization¶
DSP pipeline (optional, off by default to preserve existing behavior):
Radio PCM ──► [Noise Gate] ──► [Normalizer] ──► [Limiter] ──► Device
Device PCM ──► [Noise Gate] ──► [Normalizer] ──► [Limiter] ──► Radio
Components:
| Stage | Default | Description |
|---|---|---|
| Noise gate | threshold=-60 dBFS, hold=50ms | Suppress silence/noise during RX gaps. TX already has this (threshold=10 ≈ -70 dBFS). |
| Normalizer | target=-26 dBFS RMS | Slow-tracking RMS normalizer (attack 100ms, release 1s). -26 dBFS is the WSJT-X sweet spot. |
| Limiter | ceiling=-1 dBFS | Hard limiter to prevent clipping. Lookahead 1ms for transparent limiting. |
CLI flags:
- --bridge-normalize — enable the DSP pipeline (off by default)
- --bridge-level-target -26 — target RMS in dBFS
- --bridge-noise-gate -60 — noise gate threshold in dBFS
- --bridge-no-limiter — disable the limiter
All DSP operates on int16 PCM in numpy. No additional dependencies needed.
3.5 Metrics & Observability¶
Extend bridge.stats to include:
@dataclass
class BridgeMetrics:
# Existing
rx_frames: int = 0
tx_frames: int = 0
rx_drops: int = 0
uptime_seconds: float = 0.0
# New: backend-reported
rx_underruns: int = 0 # sounddevice xrun count
tx_overruns: int = 0
rx_latency_ms: float = 0.0 # sounddevice reported latency
tx_latency_ms: float = 0.0
# New: bridge-measured
rx_jitter_ms: float = 0.0 # stddev of inter-frame intervals
tx_jitter_ms: float = 0.0
rx_peak_dbfs: float = -96.0 # current peak level
tx_peak_dbfs: float = -96.0
rx_rms_dbfs: float = -96.0 # current RMS level
tx_rms_dbfs: float = -96.0
# State
state: str = "idle" # idle/connecting/running/reconnecting/failed
device_name: str = ""
device_uid: str = ""
sample_rate_actual: int = 0
resampling: bool = False
Expose via:
- bridge.metrics property (for CLI periodic logging)
- WebSocket event bridge_metrics (for Web UI level meters)
- Structured JSON log at INFO every 60s when running
4. Configuration Surface¶
4.1 CLI Changes¶
New flags (on audio bridge subcommand):
| Flag | Type | Description |
|---|---|---|
--device-uid UID |
str | Select device by stable platform UID |
--device NAME |
str | Select device by display name (existing, kept) |
--auto |
flag | Auto-detect virtual device (default when no device specified) |
--sample-rate HZ |
int | Force output sample rate (default: match radio) |
--normalize |
flag | Enable level normalization pipeline |
--level-target DB |
float | Target RMS in dBFS (default: -26) |
--noise-gate DB |
float | Noise gate threshold in dBFS (default: -60) |
--no-limiter |
flag | Disable hard limiter |
--backend NAME |
str | Force audio backend (portaudio/coreaudio/pipewire) |
--reconnect |
flag | Auto-reconnect on device loss (default: on) |
--no-reconnect |
flag | Disable auto-reconnect |
Deprecation:
- --bridge "BlackHole 2ch" on the web subcommand → emits deprecation warning, maps to --bridge-device "BlackHole 2ch". Remove in v1.0.
New on web subcommand:
- --bridge-device-uid, --bridge-normalize, --bridge-level-target (mirror the audio bridge flags).
4.2 Config File¶
# ~/.config/icom-lan/audio.toml
[bridge]
device_uid = "BlackHole2ch_UID" # stable platform UID
auto_detect = true # fall back to heuristic if UID not found
reconnect = true
reconnect_max_retries = 10
[bridge.levels]
normalize = false
target_rms_dbfs = -26.0
noise_gate_dbfs = -60.0
limiter = true
limiter_ceiling_dbfs = -1.0
[bridge.sample_rate]
policy = "match_radio" # "match_radio" | "force"
force_rate = 48000 # only used when policy = "force"
CLI flags override config file values. Config file overrides defaults.
4.3 RadioProfile Integration¶
The existing RadioProfile (per-radio persistent config) gains an optional [audio_bridge] section:
This allows per-radio tuning (e.g., IC-705 may need different levels than IC-7610).
5. Testing Plan¶
5.1 FakeAudioBackend¶
# tests/fakes/fake_audio_backend.py
class FakeAudioBackend:
"""Deterministic audio backend for unit tests.
- Devices are injected at construction time
- Streams record all writes and return canned reads
- No sounddevice/numpy dependency
"""
def __init__(self, devices: list[AudioDeviceInfo]) -> None:
self._devices = devices
self.opened_streams: list[tuple[str, AudioDeviceId]] = []
self.rx_data: list[bytes] = [] # canned RX data
self.tx_written: list[bytes] = [] # captured TX data
# ... implements AudioBackend protocol
5.2 Unit Tests¶
| Test file | Coverage |
|---|---|
tests/test_audio_backend.py |
AudioDeviceId creation, AudioDeviceInfo properties, backend registry |
tests/test_audio_bridge_v2.py |
Bridge state machine (IDLE→CONNECTING→RUNNING→RECONNECTING→FAILED), device selection priority, reconnect backoff |
tests/test_audio_dsp.py |
Noise gate (silence vs. signal), normalizer (convergence to target RMS), limiter (ceiling enforcement), passthrough when disabled |
tests/test_audio_levels.py |
dBFS conversion helpers, RMS calculation, peak detection — pure math, no audio deps |
tests/test_audio_metrics.py |
Metrics accumulation, jitter calculation, level tracking |
tests/test_audio_samplerate.py |
Resampler correctness (48k→44.1k round-trip within tolerance), passthrough when rates match |
tests/test_portaudio_backend.py |
PortAudioBackend with mocked sounddevice module — device enumeration, UID extraction, virtual detection |
5.3 Integration Tests¶
@pytest.mark.integration
@pytest.mark.skipif(not HAS_SOUNDDEVICE, reason="sounddevice not installed")
class TestPortAudioBackendReal:
"""Run against real system audio devices — never in CI."""
def test_list_devices_returns_nonempty(self): ...
def test_open_and_close_stream(self): ...
def test_write_silence_no_error(self): ...
5.4 Optional Dependency Gating¶
Tests that import sounddevice or numpy must be gated:
HAS_SOUNDDEVICE = importlib.util.find_spec("sounddevice") is not None
HAS_NUMPY = importlib.util.find_spec("numpy") is not None
pytestmark = pytest.mark.skipif(
not (HAS_SOUNDDEVICE and HAS_NUMPY),
reason="requires icom-lan[bridge]",
)
6. Migration / Compatibility¶
Phase 1: Add AudioBackend, keep old bridge working¶
- Introduce
AudioBackendprotocol andPortAudioBackendinsrc/icom_lan/audio/backend.py. - Refactor
UsbAudioDriverto usePortAudioBackendinternally (it already has the cleanest device model — start here). AudioBridgeremains unchanged externally. Internally, replacefind_loopback_device()withPortAudioBackend.find_device(virtual_only=True).--bridge "BlackHole 2ch"continues to work identically.
Phase 2: New bridge features¶
- Add
--device-uid,--normalize,--reconnectflags. - Add DSP pipeline (off by default).
- Add
BridgeMetricsand WebSocket events. - Deprecation warning on
--bridgepositional argument inwebsubcommand.
Phase 3: Consolidation¶
UsbAudioDriverbecomes a thin wrapper aroundAudioBackend+ topology resolver.- Remove
find_loopback_device()andlist_audio_devices()fromaudio_bridge.py(moved to backend).
Backward Compatibility Guarantees¶
AudioBridge(radio, device_name="BlackHole 2ch")API unchanged through all phases.--bridge "BlackHole 2ch"CLI flag works through v1.0 (deprecation warning in Phase 2).icom-lan[bridge]extras unchanged (sounddevice,numpy).- No new required dependencies.
sampleratelibrary is optional (graceful fallback to scipy or skip resampling with warning).
7. Risks / Non-Goals¶
(Note: this design intentionally scopes to Variant A only — improving robustness on top of existing virtual audio devices like BlackHole/Loopback/VB-Cable.)
Risks¶
| Risk | Mitigation |
|---|---|
| CoreAudio UID extraction requires ctypes | Isolate in a _macos_uid.py module. Fall back to name-based ID on failure. Test on macOS 13+. |
| Resampler quality | Use samplerate (libsamplerate bindings, high quality). Fall back to scipy. Document that resampling adds latency. |
| PortAudio hotplug is unreliable | Don't rely on PortAudio callbacks for device loss. Instead, poll device_is_alive every 2s in the reconnect watchdog. |
| DSP pipeline adds latency | Measure and document. Noise gate + normalizer + limiter on a 960-sample frame should be <0.1ms on any modern CPU. Keep it optional. |
| Config file proliferation | Use a single audio.toml rather than adding to the main config. Keep it optional — CLI flags and defaults are sufficient. |
Non-Goals¶
- Writing a brand-new OS-level virtual audio device. This document is strictly about improving the bridge on top of existing virtual devices.
- Multi-radio bridge (bridging multiple radios to different virtual devices simultaneously). Out of scope — would require a bridge manager.
- Audio effects (EQ, compression, DSP beyond normalization). The pipeline is extensible but we only implement gate + normalizer + limiter.
- Replacing sounddevice with a different PortAudio binding.
sounddeviceis mature and well-maintained. - Android/iOS support. Mobile platforms have entirely different audio APIs.
8. Follow-up Issues Checklist (Variant A Implementation)¶
-
[ ]
AudioBackendprotocol +PortAudioBackend— DefineAudioBackend,AudioDeviceId,AudioDeviceInfo,RxStream,TxStreamprotocols insrc/icom_lan/audio/backend.py. ImplementPortAudioBackendwrappingsounddevice. IncludeFakeAudioBackendfor tests. -
[ ] Stable device UIDs on macOS — Add
_macos_uid.pythat querieskAudioDevicePropertyDeviceUIDvia ctypes. Integrate intoPortAudioBackend.list_devices(). Fall back to"{name}:{hostapi}"on non-macOS. -
[ ] Refactor
UsbAudioDriverto useAudioBackend— Replace internalsounddevicecalls withPortAudioBackend. Keepselect_usb_audio_devices()logic and topology resolver, but delegate stream open/close to the backend. -
[ ] Refactor
AudioBridgeto useAudioBackend— Replacefind_loopback_device(),list_audio_devices(), and directsd.OutputStream/sd.InputStreamusage withAudioBackendcalls. Addvirtual_only=Truefiltering. -
[ ] Bridge reconnect state machine — Implement IDLE→CONNECTING→RUNNING→RECONNECTING→FAILED states. Add exponential backoff. Emit
bridge_state_changedevents. Add tests withFakeAudioBackendthat simulates device disappearance. -
[ ] Sample-rate negotiation — Query device supported rates from backend. Add resampler (optional
sampleratedep) when 48 kHz not natively supported. Add--bridge-sample-rateCLI flag. -
[ ] Level normalization DSP pipeline — Implement noise gate, RMS normalizer, hard limiter operating on int16 numpy arrays. Add
--bridge-normalize,--bridge-level-target,--bridge-noise-gateCLI flags. All off by default. -
[ ]
BridgeMetrics+ WebSocket events — Replacebridge.statsdict with typedBridgeMetricsdataclass. Add underrun/overrun tracking, jitter, peak/RMS levels. Emitbridge_metricsWebSocket event for Web UI level meters. -
[ ] CLI flag updates + deprecations — Add
--device-uid,--backend,--reconnect/--no-reconnectflags. Add deprecation warning for--bridge DEVICEpositional onwebsubcommand. Update help text and docs. -
[ ] Config file support (
audio.toml) — Define schema. Implement load/save insrc/icom_lan/config/audio_config.py. Wire intoAudioBridgeconstructor (CLI flags > config file > defaults). Store last-used device UID.