From 378a4f2da304a56bbf700d2227c9d9a22ce10193 Mon Sep 17 00:00:00 2001 From: Xavier Karma Date: Fri, 6 Nov 2026 23:52:48 +0530 Subject: [PATCH] fix(audio): pin system-audio capture to the sink monitor pw-record --target 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 --- src/vocalinux/audio/pipewire.py | 26 +++++++++++++++++++-- tests/test_pipewire_capture.py | 40 +++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 2 deletions(-) diff --git a/src/vocalinux/audio/pipewire.py b/src/vocalinux/audio/pipewire.py index 50d9d47..a2ccbd0 100644 --- a/src/vocalinux/audio/pipewire.py +++ b/src/vocalinux/audio/pipewire.py @@ -25,7 +25,7 @@ import shutil import subprocess import time from dataclasses import dataclass -from typing import Any, Callable, List, Optional, Tuple +from typing import Any, Callable, Dict, List, Mapping, Optional, Tuple from ..utils.host_process import host_env from .errors import CaptureStalledError @@ -52,6 +52,28 @@ _MONITOR_CLASSES = frozenset({"Audio/Sink", "Audio/Sink/Virtual"}) #: Metadata keys carrying the effective and configured default sink. _DEFAULT_SINK_KEYS = ("default.audio.sink", "default.configured.audio.sink") +#: Stream property that makes ``--target `` record the sink's monitor. +#: Without it PipeWire also links the default microphone to the stream (issue +#: #41), so system-audio capture must always set it. +_CAPTURE_SINK_PROPERTY = "stream.capture.sink = true" + + +def _capture_sink_env(base: Mapping[str, str]) -> Dict[str, str]: + """Return ``base`` with ``PIPEWIRE_PROPS`` pinning capture to the sink monitor. + + This is the form verified on the real machine (PipeWire 1.6.10): the stream + linked the sink's monitor and no microphone. A user's existing + ``PIPEWIRE_PROPS`` is kept, and only this child process sees the change. + """ + env = dict(base) + existing = env.get("PIPEWIRE_PROPS", "").strip() + if existing.startswith("{") and existing.endswith("}"): + existing = existing[1:-1].strip() + parts = [part for part in (existing, _CAPTURE_SINK_PROPERTY) if part] + env["PIPEWIRE_PROPS"] = "{ " + " ".join(parts) + " }" + return env + + _COMMAND_TIMEOUT_SECONDS = 3.0 CommandResult = Tuple[int, str, str] @@ -374,7 +396,7 @@ class PipeWireCaptureSource: ], stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, - env=host_env(), + env=_capture_sink_env(host_env()), ) def reopen( diff --git a/tests/test_pipewire_capture.py b/tests/test_pipewire_capture.py index c2b1d01..0a6c05d 100644 --- a/tests/test_pipewire_capture.py +++ b/tests/test_pipewire_capture.py @@ -347,6 +347,46 @@ class TestPipeWireCaptureSource(unittest.TestCase): assert "env" in kwargs # host binaries get the de-bundled environment assert capture.stream is process + def test_open_pins_capture_to_the_sink_monitor(self) -> None: + # Without stream.capture.sink, pw-record --target also links the + # default microphone (issue #41). Set it through PIPEWIRE_PROPS, the + # form verified on the real machine. + calls: List[Tuple[List[str], Dict[str, Any]]] = [] + with ( + patch("vocalinux.audio.pipewire.resolve_pipewire_source", return_value=_source()), + patch("vocalinux.audio.pipewire.host_env", return_value={"PATH": "/usr/bin"}), + ): + capture = PipeWireCaptureSource( + device_index=PIPEWIRE_INDEX_BASE, + popen=_popen_factory(_FakeProcess(), calls), + which=lambda n: "/usr/bin/pw-record", + ) + capture.open() + args, kwargs = calls[0] + assert args[args.index("--target") + 1] == "alsa_output.dac" + assert args[-1] == "-" + assert "stream.capture.sink = true" in kwargs["env"]["PIPEWIRE_PROPS"] + assert kwargs["env"]["PATH"] == "/usr/bin" + + def test_open_keeps_user_pipewire_props_when_pinning_the_monitor(self) -> None: + calls: List[Tuple[List[str], Dict[str, Any]]] = [] + user_props = "{ node.latency = 1024/48000 }" + with ( + patch("vocalinux.audio.pipewire.resolve_pipewire_source", return_value=_source()), + patch( + "vocalinux.audio.pipewire.host_env", + return_value={"PIPEWIRE_PROPS": user_props}, + ), + ): + capture = PipeWireCaptureSource( + device_index=PIPEWIRE_INDEX_BASE, + popen=_popen_factory(_FakeProcess(), calls), + which=lambda n: "/usr/bin/pw-record", + ) + capture.open() + props = calls[0][1]["env"]["PIPEWIRE_PROPS"] + assert props == "{ node.latency = 1024/48000 stream.capture.sink = true }" + def test_open_requires_pw_record(self) -> None: capture = PipeWireCaptureSource(device_index=PIPEWIRE_INDEX_BASE, which=lambda name: None) with self.assertRaises(FileNotFoundError):