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:
@@ -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(
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user