#!/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())