fix(audio): system audio never opens the mic, configured mics fail visibly #48

Merged
xavierk merged 3 commits from fix/capture-privacy into main 2026-11-06 18:35:20 +00:00
Owner

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

  • Bug fix
  • New feature
  • Breaking change
  • Documentation
  • Refactor
  • 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

  • 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.
xavierk added 2 commits 2026-11-06 18:28:48 +00:00
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
fix(audio): never record from another microphone when one is configured
CI / security (pull_request) Successful in 1m10s
CI / lint (pull_request) Successful in 23s
CI / ai-review (pull_request) Successful in 25s
71b97fe633
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
xavierk added 1 commit 2026-11-06 18:32:43 +00:00
fix(audio): apply host_env outermost to the pw-record spawn
CI / security (pull_request) Successful in 1m11s
CI / lint (pull_request) Successful in 22s
CI / ai-review (pull_request) Successful in 8s
5201383638
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 main 2026-11-06 18:35:19 +00:00
Sign in to join this conversation.