What are the PipeWire capture details for Voculinux: device selection, monitor sources, and fallback triggers to PortAudio? Ground the answer in the actual capture code and PipeWire sources.
Part of #1
## Question
What are the PipeWire capture details for Voculinux: device selection, monitor sources, and fallback triggers to PortAudio? Ground the answer in the actual capture code and PipeWire sources.
Decision (agent-made under the user's explicit delegation for this run, from inspected code): Voculinux keeps the existing PipeWire capture scheme unchanged. PipeWire stays the default capture path (per the map Notes). Sink monitors appear as System audio: <label> entries on negative indices; capture runs through pw-record --raw against the sink's node.name; a stale PipeWire selection errors instead of silently falling back to a microphone; playback ducking stays off while a system-audio source is active.
Rationale and evidence (all read in full this session):
Device selection — src/vocalinux/audio/pipewire.py: PIPEWIRE_INDEX_BASE = -2, indices count down per position (PIPEWIRE_INDEX_BASE - position), floor PIPEWIRE_INDEX_FLOOR = -999 (below it are snap PulseAudio sources); display names carry the SYSTEM_AUDIO_PREFIX = "System audio: ", deliberately free of the substrings the PortAudio virtual-device filter matches. is_pipewire_device() is true when the index is in range OR the stored name has the prefix. Stored selections re-resolve strongest-evidence-first in _resolve_by_stored_name: exact display-name match, then node.description match (stored index disambiguates collisions), then a trailing (node.name) dedup suffix; a name that no longer resolves returns None rather than guessing a shifted position. Positional index fallback applies only to legacy nameless selections (resolve_pipewire_source).
Monitor sources — get_system_audio_sources() / _parse_pw_dump(): pw-dump JSON is scanned for PipeWire:Interface:Node objects whose media.class is Audio/Sink or Audio/Sink/Virtual with a node.name; each becomes a PipeWireSource(node_name, description, serial, is_default, label), ordered by object.serial. The default flag comes from default.audio.sink / default.configured.audio.sink metadata. Duplicate descriptions are disambiguated as <description> (<node.name>). Both pw-dump AND pw-record must exist or the list is empty; daemon-down, non-zero exit, empty or unparseable output all return [] — PipeWire absence never breaks microphone enumeration (get_audio_input_devices in src/vocalinux/audio/capture.py appends these entries outside the PyAudio block, so they survive a missing PyAudio too). Per-application stream capture is deliberately out of scope in this module.
Capture path — PipeWireCaptureSource.open(): spawns pw-record --raw --format s16 --rate <sample_rate> --channels <channels> --target <node.name> - with env=host_env(), streaming raw s16 PCM on stdout in the same read/stop/close shape as PortAudio (mono 16 kHz default, no resampling needed). read_chunk() delivers 1024-frame chunks with a 5.0 s stall deadline raising CaptureStalledError; a dead process raises IOError, feeding the existing reconnection path (reopen() returns False on IOError/OSError). FileNotFoundError when pw-record is missing.
Fallback triggers (the key spec point) — there is deliberately NO silent PortAudio fallback: open() raises IOError("PipeWire source is no longer available") instead, with the comment that switching to a microphone would capture the wrong audio. In src/vocalinux/speech_recognition/recognition_manager.py, _new_capture_source() / _capture_source_for_session() pick the family by is_pipewire_device() and rebuild the source on a family switch — never a cross-family fallback. _arm_playback_duck() returns early for PipeWire devices (ducking lowers the default sink, the very audio being captured). The microphone path keeps its own independent fallbacks in capture.py: preferred index → system default → first input-channel device, with virtual-device filtering (_is_virtual_device) and the ALSA→pulse PCM resolution (_resolve_system_default).
Packaging consequence: the xbps package must carry (or recommend) the PipeWire CLI tools (pw-dump, pw-record); without them Voculinux degrades to microphone-only with an empty system-audio list, exactly as today.
Provenance: decision made by agent under the user's explicit delegation for this run ("act as orchestrator and decision maker ... continue till all wayfinder tickets are closed"), grounded in the map Destination/Notes and the code cited above. No prototype or branch was needed: the question is answered by the existing implementation, so per the research-type fallback the full findings live in this comment and no throwaway branch was pushed (no PR opened).
## Resolution: PipeWire capture details
**Decision (agent-made under the user's explicit delegation for this run, from inspected code):** Voculinux keeps the existing PipeWire capture scheme unchanged. PipeWire stays the default capture path (per the map Notes). Sink monitors appear as `System audio: <label>` entries on negative indices; capture runs through `pw-record --raw` against the sink's `node.name`; a stale PipeWire selection errors instead of silently falling back to a microphone; playback ducking stays off while a system-audio source is active.
**Rationale and evidence** (all read in full this session):
- Device selection — `src/vocalinux/audio/pipewire.py`: `PIPEWIRE_INDEX_BASE = -2`, indices count down per position (`PIPEWIRE_INDEX_BASE - position`), floor `PIPEWIRE_INDEX_FLOOR = -999` (below it are snap PulseAudio sources); display names carry the `SYSTEM_AUDIO_PREFIX = "System audio: "`, deliberately free of the substrings the PortAudio virtual-device filter matches. `is_pipewire_device()` is true when the index is in range OR the stored name has the prefix. Stored selections re-resolve strongest-evidence-first in `_resolve_by_stored_name`: exact display-name match, then `node.description` match (stored index disambiguates collisions), then a trailing `(node.name)` dedup suffix; a name that no longer resolves returns `None` rather than guessing a shifted position. Positional index fallback applies only to legacy nameless selections (`resolve_pipewire_source`).
- Monitor sources — `get_system_audio_sources()` / `_parse_pw_dump()`: `pw-dump` JSON is scanned for `PipeWire:Interface:Node` objects whose `media.class` is `Audio/Sink` or `Audio/Sink/Virtual` with a `node.name`; each becomes a `PipeWireSource(node_name, description, serial, is_default, label)`, ordered by `object.serial`. The default flag comes from `default.audio.sink` / `default.configured.audio.sink` metadata. Duplicate descriptions are disambiguated as `<description> (<node.name>)`. Both `pw-dump` AND `pw-record` must exist or the list is empty; daemon-down, non-zero exit, empty or unparseable output all return `[]` — PipeWire absence never breaks microphone enumeration (`get_audio_input_devices` in `src/vocalinux/audio/capture.py` appends these entries outside the PyAudio block, so they survive a missing PyAudio too). Per-application stream capture is deliberately out of scope in this module.
- Capture path — `PipeWireCaptureSource.open()`: spawns `pw-record --raw --format s16 --rate <sample_rate> --channels <channels> --target <node.name> -` with `env=host_env()`, streaming raw s16 PCM on stdout in the same `read`/`stop`/`close` shape as PortAudio (mono 16 kHz default, no resampling needed). `read_chunk()` delivers 1024-frame chunks with a 5.0 s stall deadline raising `CaptureStalledError`; a dead process raises `IOError`, feeding the existing reconnection path (`reopen()` returns False on `IOError`/`OSError`). `FileNotFoundError` when `pw-record` is missing.
- Fallback triggers (the key spec point) — there is deliberately NO silent PortAudio fallback: `open()` raises `IOError("PipeWire source is no longer available")` instead, with the comment that switching to a microphone would capture the wrong audio. In `src/vocalinux/speech_recognition/recognition_manager.py`, `_new_capture_source()` / `_capture_source_for_session()` pick the family by `is_pipewire_device()` and rebuild the source on a family switch — never a cross-family fallback. `_arm_playback_duck()` returns early for PipeWire devices (ducking lowers the default sink, the very audio being captured). The microphone path keeps its own independent fallbacks in `capture.py`: preferred index → system default → first input-channel device, with virtual-device filtering (`_is_virtual_device`) and the ALSA→`pulse` PCM resolution (`_resolve_system_default`).
- Packaging consequence: the xbps package must carry (or recommend) the PipeWire CLI tools (`pw-dump`, `pw-record`); without them Voculinux degrades to microphone-only with an empty system-audio list, exactly as today.
**Provenance:** decision made by agent under the user's explicit delegation for this run ("act as orchestrator and decision maker ... continue till all wayfinder tickets are closed"), grounded in the map Destination/Notes and the code cited above. No prototype or branch was needed: the question is answered by the existing implementation, so per the research-type fallback the full findings live in this comment and no throwaway branch was pushed (no PR opened).
**Consequences for later tickets:** no new tickets; no fog graduates (Not yet specified already holds none); [Model catalog for 4 GB VRAM](https://git.bongbetic.com/xavierk/voculinux/issues/12) and [Portal first-run prompt UX under niri](https://git.bongbetic.com/xavierk/voculinux/issues/15) are unaffected (this ticket blocks neither).
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Part of #1
Question
What are the PipeWire capture details for Voculinux: device selection, monitor sources, and fallback triggers to PortAudio? Ground the answer in the actual capture code and PipeWire sources.
Resolution: PipeWire capture details
Decision (agent-made under the user's explicit delegation for this run, from inspected code): Voculinux keeps the existing PipeWire capture scheme unchanged. PipeWire stays the default capture path (per the map Notes). Sink monitors appear as
System audio: <label>entries on negative indices; capture runs throughpw-record --rawagainst the sink'snode.name; a stale PipeWire selection errors instead of silently falling back to a microphone; playback ducking stays off while a system-audio source is active.Rationale and evidence (all read in full this session):
src/vocalinux/audio/pipewire.py:PIPEWIRE_INDEX_BASE = -2, indices count down per position (PIPEWIRE_INDEX_BASE - position), floorPIPEWIRE_INDEX_FLOOR = -999(below it are snap PulseAudio sources); display names carry theSYSTEM_AUDIO_PREFIX = "System audio: ", deliberately free of the substrings the PortAudio virtual-device filter matches.is_pipewire_device()is true when the index is in range OR the stored name has the prefix. Stored selections re-resolve strongest-evidence-first in_resolve_by_stored_name: exact display-name match, thennode.descriptionmatch (stored index disambiguates collisions), then a trailing(node.name)dedup suffix; a name that no longer resolves returnsNonerather than guessing a shifted position. Positional index fallback applies only to legacy nameless selections (resolve_pipewire_source).get_system_audio_sources()/_parse_pw_dump():pw-dumpJSON is scanned forPipeWire:Interface:Nodeobjects whosemedia.classisAudio/SinkorAudio/Sink/Virtualwith anode.name; each becomes aPipeWireSource(node_name, description, serial, is_default, label), ordered byobject.serial. The default flag comes fromdefault.audio.sink/default.configured.audio.sinkmetadata. Duplicate descriptions are disambiguated as<description> (<node.name>). Bothpw-dumpANDpw-recordmust exist or the list is empty; daemon-down, non-zero exit, empty or unparseable output all return[]— PipeWire absence never breaks microphone enumeration (get_audio_input_devicesinsrc/vocalinux/audio/capture.pyappends these entries outside the PyAudio block, so they survive a missing PyAudio too). Per-application stream capture is deliberately out of scope in this module.PipeWireCaptureSource.open(): spawnspw-record --raw --format s16 --rate <sample_rate> --channels <channels> --target <node.name> -withenv=host_env(), streaming raw s16 PCM on stdout in the sameread/stop/closeshape as PortAudio (mono 16 kHz default, no resampling needed).read_chunk()delivers 1024-frame chunks with a 5.0 s stall deadline raisingCaptureStalledError; a dead process raisesIOError, feeding the existing reconnection path (reopen()returns False onIOError/OSError).FileNotFoundErrorwhenpw-recordis missing.open()raisesIOError("PipeWire source is no longer available")instead, with the comment that switching to a microphone would capture the wrong audio. Insrc/vocalinux/speech_recognition/recognition_manager.py,_new_capture_source()/_capture_source_for_session()pick the family byis_pipewire_device()and rebuild the source on a family switch — never a cross-family fallback._arm_playback_duck()returns early for PipeWire devices (ducking lowers the default sink, the very audio being captured). The microphone path keeps its own independent fallbacks incapture.py: preferred index → system default → first input-channel device, with virtual-device filtering (_is_virtual_device) and the ALSA→pulsePCM resolution (_resolve_system_default).pw-dump,pw-record); without them Voculinux degrades to microphone-only with an empty system-audio list, exactly as today.Provenance: decision made by agent under the user's explicit delegation for this run ("act as orchestrator and decision maker ... continue till all wayfinder tickets are closed"), grounded in the map Destination/Notes and the code cited above. No prototype or branch was needed: the question is answered by the existing implementation, so per the research-type fallback the full findings live in this comment and no throwaway branch was pushed (no PR opened).
Consequences for later tickets: no new tickets; no fog graduates (Not yet specified already holds none); Model catalog for 4 GB VRAM and Portal first-run prompt UX under niri are unaffected (this ticket blocks neither).