Shared USB audio pairing¶
The shared rule lives in src/rigplane/audio/_usb_pairing.py: pair.
src/rigplane/audio/_usb_adapt_windows.py: adapt_windows,
src/rigplane/audio/_usb_adapt_macos.py: adapt_macos and
src/rigplane/audio/_usb_adapt_linux.py: adapt_linux normalize platform evidence
without querying devices. Both
src/rigplane/audio/usb_driver.py: UsbAudioDriver._select_topology_devices and
src/rigplane/audio/_usb_resolve.py: resolve_audio_for_serial_port use this rule.
Driver refusals raise src/rigplane/audio/_usb_pairing.py: WindowsAudioTopologyError,
which retains the reason, evidence, and candidates. The driver keeps its
unambiguous-name fallback on Windows when raw topology is unavailable.
The stages run in order, stopping at the first refusal:
- S0 — availability. An unavailable device tree, serial inventory,
PortAudio enumeration, or endpoint ownership refuses with
unavailable. - S1 — serial port. A case-insensitive port match must identify exactly
one serial reference with a USB device node; otherwise
serial_not_found. - S2/S3 — containment and uniqueness. The serial device's direct parent H
must be an ordinary hub, never a root hub or controller. Inspect H's direct
children and the children of any direct child hub, with no deeper search.
There must be exactly one serial-owning USB device (the requested device),
unless the profile declares
serial_functions. Multiple serial owners may count as one radio only when each is directly under H and the multiset of their VID:PID is contained in the declared multiset (observed count per VID:PID at most the declared count; duplicates mean several functions of the same chip type). Undeclared, nested, or over-count additional serial owners refuse withserial_ambiguous. Multiple ports on one USB owner still count as one device. There must also be exactly one USB device owning both capture and render endpoints; a capture-only camera does not qualify. Missing parent links, cycles, and duplicate node IDs refuse withtopology_incomplete; absent or unsuitable H givescontainment_unproven. Other refusals areserial_ambiguous,no_codec, orcodec_ambiguous. WindowsPortsdevices count independently of COM names, including LPT entries and unresolved USB owners; LPT names do not create COM references. - S4 — endpoint ownership. Match PortAudio names to OS endpoint names in
the corresponding direction. Normalize whitespace around parentheses; retain
localization, user renames, and numeric prefixes. MME also accepts a listed
name of exactly 31 characters when its normalized key prefixes the endpoint
key. Multiple owners of a matching name contest that record, including
non-USB and virtual devices.
src/rigplane/audio/_usb_names.py: endpoint_name_keynormalizes ALSA's: <PCM>and, USB Audiodecorations only with a trailing(hw:N,M)or(plughw:N,M)address, retaining that address in the key. Linux endpoints use the linked card's name and(hw:N,0)address; directions come from matching PortAudio channel counts. Separate cards with equal product names remain distinct without relying on enumeration order. - S5 — host API. In PortAudio's host API order, select the first API with
exactly one owned capture record and one owned render record, and no
contested record matching the selected codec. A duplex record can serve
both directions. If none qualifies, return
contestedwhen any API was rejected for a collision, otherwiseendpoint_names.
Profiles may carry optional [usb] audio_codec and serial_functions VID:PID
lists, stored as
src/rigplane/core/usb_identity.py: UsbAudioIdentity. Only our own recordings
justify entries. Each serial_functions entry is one serial function, so the
same VID:PID may be listed more than once. A non-empty audio_codec list checks the chosen codec after
containment; a mismatch refuses with codec_identity_mismatch. Absence or an
empty codec list omits that check. Absence or an empty serial_functions list
retains strict single-serial S3. Radio facts belong in profile data; the
mechanism has no model-specific branches or product-name heuristics.
Accepted residual risk: without a codec list, a serial device directly on
a shared external hub can pair with a foreign headset when the radio codec is
absent, that headset is the only capture-and-render device within the boundary,
and no second serial device is present. A recorded codec list rejects a headset
whose VID:PID differs. The graph test
tests/test_usb_pairing_rules.py: test_s2_residual_risk_shared_hub_foreign_headset_without_identity_pairs
pins this accepted behavior; the rule does not prove an internal radio boundary.
Every refusal includes src/rigplane/audio/_usb_pairing.py: AudioCandidate
entries for the first host API with endpoint-matched records. Each record and
direction has an index, display name, host API, stable ID, and near_radio
flag. A unique endpoint gives <host API>|<endpoint OS ID>; otherwise the ID is
<host API>|name:<listed name>. Nearby USB owners sort first, then PortAudio
index. Unmatched mapper entries are omitted. Candidates support explicit device
selection; they do not change the refusal into a successful pairing.
Ambiguity refusals map to usb_ambiguous, name failures to usb_topology_names,
and the remaining reasons to usb_topology_unavailable. Capture replay tests
are in tests/test_usb_adapt_windows.py and tests/test_usb_pairing_windows.py;
the graph clause pins and stage anchors are in tests/test_usb_pairing_rules.py.
Linux uses src/rigplane/audio/_usb_raw_linux.py: read_raw_linux in process,
including in frozen applications. The adapter identifies root hubs from their
parentless sysfs devpath=0 and hub class, and retains USB device ownership
across tty and audio interface links. Endpoint IDs use the linked sysfs device
path plus :pcm0, so PortAudio order and ALSA card numbering are not identity.
Explicit <host API>|<endpoint OS ID> overrides use the same picker path.
The real web audio-start seam is exercised in
tests/test_web_usb_pairing_integration.py; Linux's sysfs/ALSA/PortAudio inputs
are synthetic and not hardware-verified. Topology, profile, refusal and
deadline coverage lives in tests/test_usb_pairing_linux.py.