SmartestHome/mic-follow/audio_sources.py

225 lines
8.5 KiB
Python
Executable File

#!/usr/bin/env python3
"""Finding, selecting and moving audio inputs on a PipeWire desktop.
Everything here goes through `pactl` (pipewire-pulse's compatibility layer) rather
than a mix of `wpctl`, `pw-dump` and `pw-metadata`. One tool covers all four things
this needs — list sources, list capture streams, set the default, move a live stream —
and it is the only one of them with a documented JSON output mode, which is the
difference between parsing a stable structure and scraping a table that changes
between releases.
WHY MOVING STREAMS MATTERS, and is not the same as setting the default: changing the
default source only affects applications that asked for "default". Discord, once you
have picked a specific microphone in its settings, holds that device — and the whole
point of this component is that somebody who has set up a studio mic has certainly
picked it explicitly. So the switch does both: it sets the default (for anything that
follows it) and moves the already-running capture streams of the configured
applications (for anything that does not).
Nothing in this file has run against a real PipeWire — see mic-follow/README.md. The
parsing and selection logic is tested against captured pactl output in
test_selection.py; what is unverified is the exact shape of that output on the user's
own version, and every command that changes state.
"""
from __future__ import annotations
import json
import logging
import subprocess
from dataclasses import dataclass
log = logging.getLogger(__name__)
@dataclass(frozen=True)
class Source:
index: int
name: str
description: str
def matches(self, pattern: str) -> bool:
pattern = pattern.strip().lower()
return (pattern == self.name.lower()
or pattern in self.name.lower()
or pattern in self.description.lower())
@dataclass(frozen=True)
class CaptureStream:
index: int
source: int
application: str
def _pactl_json(*args: str) -> list | dict | None:
try:
result = subprocess.run(["pactl", "-f", "json", *args],
capture_output=True, text=True, timeout=10, check=False)
except (OSError, subprocess.SubprocessError) as exc:
log.warning("pactl %s: %s", " ".join(args), exc)
return None
if result.returncode != 0:
log.warning("pactl %s failed: %s", " ".join(args), (result.stderr or "").strip()[:200])
return None
try:
return json.loads(result.stdout or "null")
except json.JSONDecodeError as exc:
log.warning("pactl %s returned unparseable JSON: %s", " ".join(args), exc)
return None
def parse_sources(payload) -> list[Source]:
"""pactl's `list sources` JSON -> Source objects, monitors dropped.
Monitor sources (the loopback of an output) are excluded deliberately: they match
name patterns surprisingly often, and selecting one means transmitting whatever the
desktop is playing instead of what the person is saying — the single worst outcome
this component could produce.
"""
sources: list[Source] = []
for entry in payload or []:
if not isinstance(entry, dict):
continue
name = str(entry.get("name") or "")
if not name or name.endswith(".monitor"):
continue
properties = entry.get("properties") or {}
if str(properties.get("device.class", "")).lower() == "monitor":
continue
sources.append(Source(
index=int(entry.get("index", -1)),
name=name,
description=str(entry.get("description") or properties.get("device.description") or name),
))
return sources
def parse_capture_streams(payload) -> list[CaptureStream]:
"""pactl's `list source-outputs` JSON -> the live recording streams."""
streams: list[CaptureStream] = []
for entry in payload or []:
if not isinstance(entry, dict):
continue
properties = entry.get("properties") or {}
application = str(
properties.get("application.name")
or properties.get("application.process.binary")
or ""
)
source = entry.get("source")
streams.append(CaptureStream(
index=int(entry.get("index", -1)),
source=int(source) if isinstance(source, int) else -1,
application=application,
))
return streams
def select_source(sources: list[Source], pattern: str) -> Source | None:
"""The configured pattern -> one source, preferring the least surprising match.
Exact `node.name` first, then a substring of the name, then a substring of the
human description. Ties inside a tier are resolved by lowest index (the order
pactl reports, which is stable within a boot) and logged, because a pattern that
matches two microphones is a configuration mistake the user should hear about
rather than a coin flip that lands differently after a reboot.
"""
pattern = (pattern or "").strip().lower()
if not pattern:
return None
exact = [s for s in sources if s.name.lower() == pattern]
by_name = [s for s in sources if pattern in s.name.lower()]
by_description = [s for s in sources if pattern in s.description.lower()]
for tier, label in ((exact, "exact name"), (by_name, "name"), (by_description, "description")):
if not tier:
continue
chosen = sorted(tier, key=lambda s: s.index)[0]
if len(tier) > 1:
log.warning("%r matches %d sources by %s (%s) — using %r",
pattern, len(tier), label,
", ".join(s.name for s in tier), chosen.name)
return chosen
return None
def streams_to_move(streams: list[CaptureStream], applications: list[str],
target: Source) -> list[CaptureStream]:
"""Which live capture streams belong to the configured apps and are on the wrong
source already. Streams already on the target are left alone: moving a stream that
is where it should be is a needless glitch in somebody's live audio."""
wanted = [a.strip().lower() for a in applications if a.strip()]
if not wanted:
return []
return [
stream for stream in streams
if stream.source != target.index
and any(pattern in stream.application.lower() for pattern in wanted)
]
# --- The four live commands. Everything above is pure and tested; these are not. -----
def list_sources() -> list[Source]:
return parse_sources(_pactl_json("list", "sources"))
def list_capture_streams() -> list[CaptureStream]:
return parse_capture_streams(_pactl_json("list", "source-outputs"))
def _run(*args: str) -> bool:
try:
result = subprocess.run(["pactl", *args], capture_output=True, text=True,
timeout=10, check=False)
except (OSError, subprocess.SubprocessError) as exc:
log.warning("pactl %s: %s", " ".join(args), exc)
return False
if result.returncode != 0:
log.warning("pactl %s failed: %s", " ".join(args), (result.stderr or "").strip()[:200])
return False
return True
def set_default_source(source: Source) -> bool:
return _run("set-default-source", source.name)
def move_stream(stream: CaptureStream, target: Source) -> bool:
return _run("move-source-output", str(stream.index), target.name)
def current_default_source_name() -> str:
payload = _pactl_json("info")
if isinstance(payload, dict):
return str(payload.get("default_source_name") or "")
return ""
def main() -> int:
"""`audio_sources.py` on its own prints what this desktop has, which is how you
fill in the source patterns in CoreSystemConfig.json."""
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
sources = list_sources()
if not sources:
print("No sources found — is pactl installed and a PipeWire session running?")
return 1
default = current_default_source_name()
print("Audio inputs on this machine (any part of a name or description works as a")
print("`source` pattern in CoreSystemConfig.json):\n")
for source in sources:
marker = "*" if source.name == default else " "
print(f" {marker} {source.description}")
print(f" {source.name}")
print("\n* = current default")
streams = list_capture_streams()
if streams:
print("\nApplications recording right now:")
for stream in streams:
print(f" {stream.application or '(unnamed)'} (stream {stream.index}, source {stream.source})")
return 0
if __name__ == "__main__":
raise SystemExit(main())