Two capture-path bugs found in the #32 hardware acceptance run. Both are in the audio layer, so they ship together.
#41 (privacy, high): system-audio capture could open the microphone.pw-record --target <sink> also linked the default input unless the stream property stream.capture.sink was set. PipeWireCaptureSource now sets it through PIPEWIRE_PROPS for the spawned pw-record only, merging any value the user already has. The environment form is the one verified on the real machine (see below). Commit 378a4f2.
#42: a configured device silently fell back to the hardware microphone. A configured device that was virtual, output-only, renamed or unplugged was replaced by the default input, with only a log warning. An explicit selection is now used exactly or refused: PortAudioCaptureSource.open() and reopen() raise the new AudioDeviceUnavailableError (an IOError subclass, audio/errors.py). The manager skips reconnection for this error, since retrying cannot help, and reports it through the existing _show_notification path as "Audio Input Unavailable", naming the device. The PipeWireCaptureSource "source is no longer available" case raises the same typed error. System Default (unset, or -1) now goes through the System Default path (_resolve_system_default). A bare -1 previously pinned the first safe hardware input. Commit 71b97fe.
Behaviour change
An explicit microphone that is unplugged or renamed now fails visibly instead of recording from the default input. This matches "never silently switch input" in #42. Pulse source selection (snap) is unchanged. docs/USER_GUIDE.md describes the new behaviour.
Tests that pinned the old fallback
Rewritten to assert the new behaviour rather than deleted:
Two test_recognition_manager_downloads.py reconnection tests that expected a fallback now assert a refusal (..._refuses_missing_configured_device, ..._refuses_when_no_safe_input_exists).
test_portaudio_selection_is_untouched used an explicit virtual default device, which is now refused. It now uses a real microphone name; the PULSE_SOURCE assertion is unchanged.
_resolve_valid_input_device and _resolve_device_by_name keep their helper contracts and tests. Explicit selections never reach their substitution branch.
#41:PipeWireCaptureSource.open() with an injected popen, asserting the spawned environment pins stream.capture.sink = true and keeps existing PIPEWIRE_PROPS. Behavioural proof: real machine, below.
#42, device resolution:PortAudioCaptureSource.open() and reopen() against a mocked PyAudio enumeration. Covers virtual refusal, a device that disappeared (no index substitution), the System Default -1 sentinel, a usable device opening by name, and reopen refusing.
#42, reporting:SpeechRecognitionManager._record_audio() with the real capture source, asserting the notification title and device name, no reconnect attempt, and the mid-dictation case (read error, then refused reconnect).
Verification on the real machine
Host: Void Linux, PipeWire 1.6.10, the default input amd_dmic_highpass. The worktree code was loaded (module path asserted in the script).
#41: a null sink created with pactl load-module module-null-sink (module unloaded afterwards). PipeWireCaptureSource resolved the sink, opened, read four chunks, and was closed. Result: the stream linked the sink monitor (2 links), and no Audio/Source node linked to it. A poll every 100 ms would have closed the stream on any microphone link; none appeared. No pw-record was left running.
#42: read-only, using PyAudio's real device list. The configured virtual pipewire (index 11) and default (index 12) devices were both refused before any stream opened. A spy on _open_capture_stream recorded only the System Default open.
Not verified on hardware: the notification text on a real desktop (the notification path is covered by the manager tests).
Note on the flag: pw-record -P was not used. The environment form is the one verified here.
Checklist
Code follows project style (Black, isort, flake8)
Documentation updated when user-facing behavior or install steps change
Tests added or updated for the change
Local tests pass for the affected files (see below)
Conventional commit message style
Gates on fix/capture-privacy, run with the locked lint group (uv run --locked --only-group lint):
black --check and isort --check on every changed file: pass.
flake8 --select=E9,F63,F7,F82 on src/ tests/ snap/local/: 0 errors.
mypy src/: no issues in 88 source files.
pytest on the nine affected files, run alone with -o addopts= (the pytest-timeout plugin is not installed locally): 453 passed, 21 failed. The 21 are the pre-existing gi-mock, order-dependent failures in test_recognition_manager_device_config.py (whisper GPU) and test_pulse_sources.py (settings GTK), identical on main. No new failures.
Notes for reviewers
Commits: 378a4f2 (#41), 71b97fe (#42, with its doc change), 5201383 (#41: host_env outermost on the spawn, so the form matches the host_process convention).
Not changed, and possibly still silent: the PulseAudio (snap) "source is gone" path still records from the server default, by design (#1011). The Settings "Test microphone" path still opens the stored index directly.
Matching is by device name, so a replugged microphone with the same name still works. A microphone whose name changes (for example a renamed ALSA card) now fails visibly until the user reselects it. That is the intended trade-off.
## Description
Two capture-path bugs found in the #32 hardware acceptance run. Both are in the audio layer, so they ship together.
**#41 (privacy, high): system-audio capture could open the microphone.** `pw-record --target <sink>` also linked the default input unless the stream property `stream.capture.sink` was set. `PipeWireCaptureSource` now sets it through `PIPEWIRE_PROPS` for the spawned `pw-record` only, merging any value the user already has. The environment form is the one verified on the real machine (see below). Commit `378a4f2`.
**#42: a configured device silently fell back to the hardware microphone.** A configured device that was virtual, output-only, renamed or unplugged was replaced by the default input, with only a log warning. An explicit selection is now used exactly or refused: `PortAudioCaptureSource.open()` and `reopen()` raise the new `AudioDeviceUnavailableError` (an `IOError` subclass, `audio/errors.py`). The manager skips reconnection for this error, since retrying cannot help, and reports it through the existing `_show_notification` path as "Audio Input Unavailable", naming the device. The `PipeWireCaptureSource` "source is no longer available" case raises the same typed error. System Default (unset, or `-1`) now goes through the System Default path (`_resolve_system_default`). A bare `-1` previously pinned the first safe hardware input. Commit `71b97fe`.
### Behaviour change
An explicit microphone that is unplugged or renamed now fails visibly instead of recording from the default input. This matches "never silently switch input" in #42. Pulse source selection (snap) is unchanged. `docs/USER_GUIDE.md` describes the new behaviour.
### Tests that pinned the old fallback
Rewritten to assert the new behaviour rather than deleted:
- `test_record_audio_falls_back_to_system_default_when_no_safe_device` becomes `test_record_audio_refuses_system_default_for_unusable_configured_device`.
- Two `test_recognition_manager_downloads.py` reconnection tests that expected a fallback now assert a refusal (`..._refuses_missing_configured_device`, `..._refuses_when_no_safe_input_exists`).
- `test_portaudio_selection_is_untouched` used an explicit virtual `default` device, which is now refused. It now uses a real microphone name; the `PULSE_SOURCE` assertion is unchanged.
`_resolve_valid_input_device` and `_resolve_device_by_name` keep their helper contracts and tests. Explicit selections never reach their substitution branch.
## Related issue
Fixes #41
Fixes #42
## Type of change
- [x] Bug fix
- [ ] New feature
- [ ] Breaking change
- [ ] Documentation
- [ ] Refactor
- [x] Tests
- [ ] Packaging / CI
## Seams under test
- **#41:** `PipeWireCaptureSource.open()` with an injected `popen`, asserting the spawned environment pins `stream.capture.sink = true` and keeps existing `PIPEWIRE_PROPS`. Behavioural proof: real machine, below.
- **#42, device resolution:** `PortAudioCaptureSource.open()` and `reopen()` against a mocked PyAudio enumeration. Covers virtual refusal, a device that disappeared (no index substitution), the System Default `-1` sentinel, a usable device opening by name, and reopen refusing.
- **#42, reporting:** `SpeechRecognitionManager._record_audio()` with the real capture source, asserting the notification title and device name, no reconnect attempt, and the mid-dictation case (read error, then refused reconnect).
## Verification on the real machine
Host: Void Linux, PipeWire 1.6.10, the default input `amd_dmic_highpass`. The worktree code was loaded (module path asserted in the script).
- **#41:** a null sink created with `pactl load-module module-null-sink` (module unloaded afterwards). `PipeWireCaptureSource` resolved the sink, opened, read four chunks, and was closed. Result: the stream linked the sink monitor (2 links), and no `Audio/Source` node linked to it. A poll every 100 ms would have closed the stream on any microphone link; none appeared. No `pw-record` was left running.
- **#42:** read-only, using PyAudio's real device list. The configured virtual `pipewire` (index 11) and `default` (index 12) devices were both refused before any stream opened. A spy on `_open_capture_stream` recorded only the System Default open.
Not verified on hardware: the notification text on a real desktop (the notification path is covered by the manager tests).
Note on the flag: `pw-record -P` was not used. The environment form is the one verified here.
## Checklist
- [x] Code follows project style (Black, isort, flake8)
- [x] Documentation updated when user-facing behavior or install steps change
- [x] Tests added or updated for the change
- [x] Local tests pass for the affected files (see below)
- [x] Conventional commit message style
Gates on `fix/capture-privacy`, run with the locked lint group (`uv run --locked --only-group lint`):
- `black --check` and `isort --check` on every changed file: pass.
- `flake8 --select=E9,F63,F7,F82` on `src/ tests/ snap/local/`: 0 errors.
- `mypy src/`: no issues in 88 source files.
- pytest on the nine affected files, run alone with `-o addopts=` (the pytest-timeout plugin is not installed locally): 453 passed, 21 failed. The 21 are the pre-existing gi-mock, order-dependent failures in `test_recognition_manager_device_config.py` (whisper GPU) and `test_pulse_sources.py` (settings GTK), identical on `main`. No new failures.
## Notes for reviewers
- Commits: `378a4f2` (#41), `71b97fe` (#42, with its doc change), `5201383` (#41: `host_env` outermost on the spawn, so the form matches the `host_process` convention).
- Not changed, and possibly still silent: the PulseAudio (snap) "source is gone" path still records from the server default, by design (#1011). The Settings "Test microphone" path still opens the stored index directly.
- Matching is by device name, so a replugged microphone with the same name still works. A microphone whose name changes (for example a renamed ALSA card) now fails visibly until the user reselects it. That is the intended trade-off.
pw-record --target <sink> also linked the default microphone to the
stream unless stream.capture.sink was set, so system-audio capture could
open an input device. Set the property in PIPEWIRE_PROPS for the spawned
pw-record only, keeping any value the user already has.
Verified on PipeWire 1.6.10 with a null sink: the stream linked the sink
monitor and no Audio/Source node.
Fixes#41
A configured device that was virtual, output-only, renamed or unplugged
was replaced by the hardware default input, with only a log warning. The
capture source now refuses an explicit selection that cannot be used as
configured: it raises AudioDeviceUnavailableError, skips reconnection
(retrying cannot help), and reports it through the desktop notification
path. System Default (unset, or -1) keeps its implicit default.
Behaviour change: an unplugged or renamed configured microphone now fails
visibly instead of recording from the default input. Pulse source
selection is unchanged.
Fixes#42
host_env() now wraps the whole child environment, and the
stream.capture.sink property is merged inside it, which is the form the
host_process convention expects. The two capture tests patch os.environ
instead of host_env.
Fixes#41
xavierk
merged commit b0d8afad92 into main2026-11-06 18:35:19 +00:00
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.
Description
Two capture-path bugs found in the #32 hardware acceptance run. Both are in the audio layer, so they ship together.
#41 (privacy, high): system-audio capture could open the microphone.
pw-record --target <sink>also linked the default input unless the stream propertystream.capture.sinkwas set.PipeWireCaptureSourcenow sets it throughPIPEWIRE_PROPSfor the spawnedpw-recordonly, merging any value the user already has. The environment form is the one verified on the real machine (see below). Commit378a4f2.#42: a configured device silently fell back to the hardware microphone. A configured device that was virtual, output-only, renamed or unplugged was replaced by the default input, with only a log warning. An explicit selection is now used exactly or refused:
PortAudioCaptureSource.open()andreopen()raise the newAudioDeviceUnavailableError(anIOErrorsubclass,audio/errors.py). The manager skips reconnection for this error, since retrying cannot help, and reports it through the existing_show_notificationpath as "Audio Input Unavailable", naming the device. ThePipeWireCaptureSource"source is no longer available" case raises the same typed error. System Default (unset, or-1) now goes through the System Default path (_resolve_system_default). A bare-1previously pinned the first safe hardware input. Commit71b97fe.Behaviour change
An explicit microphone that is unplugged or renamed now fails visibly instead of recording from the default input. This matches "never silently switch input" in #42. Pulse source selection (snap) is unchanged.
docs/USER_GUIDE.mddescribes the new behaviour.Tests that pinned the old fallback
Rewritten to assert the new behaviour rather than deleted:
test_record_audio_falls_back_to_system_default_when_no_safe_devicebecomestest_record_audio_refuses_system_default_for_unusable_configured_device.test_recognition_manager_downloads.pyreconnection tests that expected a fallback now assert a refusal (..._refuses_missing_configured_device,..._refuses_when_no_safe_input_exists).test_portaudio_selection_is_untouchedused an explicit virtualdefaultdevice, which is now refused. It now uses a real microphone name; thePULSE_SOURCEassertion is unchanged._resolve_valid_input_deviceand_resolve_device_by_namekeep their helper contracts and tests. Explicit selections never reach their substitution branch.Related issue
Fixes #41
Fixes #42
Type of change
Seams under test
PipeWireCaptureSource.open()with an injectedpopen, asserting the spawned environment pinsstream.capture.sink = trueand keeps existingPIPEWIRE_PROPS. Behavioural proof: real machine, below.PortAudioCaptureSource.open()andreopen()against a mocked PyAudio enumeration. Covers virtual refusal, a device that disappeared (no index substitution), the System Default-1sentinel, a usable device opening by name, and reopen refusing.SpeechRecognitionManager._record_audio()with the real capture source, asserting the notification title and device name, no reconnect attempt, and the mid-dictation case (read error, then refused reconnect).Verification on the real machine
Host: Void Linux, PipeWire 1.6.10, the default input
amd_dmic_highpass. The worktree code was loaded (module path asserted in the script).pactl load-module module-null-sink(module unloaded afterwards).PipeWireCaptureSourceresolved the sink, opened, read four chunks, and was closed. Result: the stream linked the sink monitor (2 links), and noAudio/Sourcenode linked to it. A poll every 100 ms would have closed the stream on any microphone link; none appeared. Nopw-recordwas left running.pipewire(index 11) anddefault(index 12) devices were both refused before any stream opened. A spy on_open_capture_streamrecorded only the System Default open.Not verified on hardware: the notification text on a real desktop (the notification path is covered by the manager tests).
Note on the flag:
pw-record -Pwas not used. The environment form is the one verified here.Checklist
Gates on
fix/capture-privacy, run with the locked lint group (uv run --locked --only-group lint):black --checkandisort --checkon every changed file: pass.flake8 --select=E9,F63,F7,F82onsrc/ tests/ snap/local/: 0 errors.mypy src/: no issues in 88 source files.-o addopts=(the pytest-timeout plugin is not installed locally): 453 passed, 21 failed. The 21 are the pre-existing gi-mock, order-dependent failures intest_recognition_manager_device_config.py(whisper GPU) andtest_pulse_sources.py(settings GTK), identical onmain. No new failures.Notes for reviewers
378a4f2(#41),71b97fe(#42, with its doc change),5201383(#41:host_envoutermost on the spawn, so the form matches thehost_processconvention).