fix(audio): pin system-audio capture to the sink monitor

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
This commit is contained in:
2026-11-06 23:52:48 +05:30
parent a4a831c3c6
commit 378a4f2da3
2 changed files with 64 additions and 2 deletions
+24 -2
View File
@@ -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 <sink>`` 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(
+40
View File
@@ -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 <sink> 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):