You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

118 lines
5.3 KiB

#!/usr/bin/env python3
"""Изолированный PipeWire/PulseAudio с виртуальным sink; системный сервер не затрагивается."""
import os
import json
from pathlib import Path
import shutil
import signal
import subprocess
import sys
import tempfile
import time
def wait_for(predicate, seconds=10):
deadline = time.monotonic() + seconds
while time.monotonic() < deadline:
if predicate():
return
time.sleep(0.02)
raise RuntimeError("private audio server/test did not reach expected state")
def main():
for executable in ("pipewire", "pipewire-pulse", "wireplumber", "dbus-run-session", "pactl"):
if not shutil.which(executable):
raise RuntimeError(f"required executable missing: {executable}")
binary = Path(sys.argv[1] if len(sys.argv) > 1 else "tools/chatgui/build/test_audio_recovery").resolve()
with tempfile.TemporaryDirectory(prefix="utun-pulse-") as root:
directory = Path(root)
config = directory / "config/wireplumber/wireplumber.conf.d"
config.mkdir(parents=True)
(config / "test.conf").write_text('''wireplumber.profiles = {
main = {
monitor.alsa = disabled
monitor.alsa-midi = disabled
monitor.bluez = disabled
monitor.bluez-midi = disabled
monitor.v4l2 = disabled
monitor.libcamera = disabled
}
}''')
env = os.environ.copy()
env.update(XDG_RUNTIME_DIR=root, PIPEWIRE_RUNTIME_DIR=root, XDG_CONFIG_HOME=str(directory / "config"),
XDG_STATE_HOME=str(directory / "state"), PIPEWIRE_REMOTE="pipewire-0", PULSE_SERVER=f"unix:{root}/pulse/native")
processes, logs = [], []
def launch(command, label):
log = (directory / f"{label}.log").open("w+")
logs.append(log)
process = subprocess.Popen(command, env=env, stdout=log, stderr=subprocess.STDOUT, start_new_session=True)
processes.append(process)
return process
def pulse_ready():
return subprocess.run(["pactl", "info"], env=env, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL, timeout=2).returncode == 0
def pactl(*args):
return subprocess.check_output(["pactl", *args], env=env, text=True, timeout=5)
def create_sinks():
for name in ("utun_test_main", "utun_test_voice"):
pactl("load-module", "module-null-sink", f"sink_name={name}")
pactl("set-default-sink", "utun_test_main")
pactl("set-default-source", "utun_test_main.monitor")
try:
launch(["pipewire"], "pipewire")
wait_for(lambda: (directory / "pipewire-0").exists())
launch(["dbus-run-session", "--", "wireplumber"], "wireplumber")
pulse = launch(["pipewire-pulse"], "pulse-before")
wait_for(pulse_ready)
create_sinks()
test = launch([str(binary), "--pulse-integration", root], "client")
wait_for(lambda: (directory / "routes_ready").exists() or test.poll() is not None)
if test.poll() is not None:
raise RuntimeError("client failed before route move")
sinks = json.loads(pactl("--format=json", "list", "sinks"))
main_sink = next(sink["index"] for sink in sinks if sink["name"] == "utun_test_main")
streams = json.loads(pactl("--format=json", "list", "sink-inputs"))
main_streams = [stream for stream in streams if stream["sink"] == main_sink]
if len(main_streams) != 1:
raise RuntimeError("expected one shared default stream")
# Явно выбранный stream имеет DONT_MOVE; default stream разрешено перемещать сервером.
pactl("move-sink-input", str(main_streams[0]["index"]), "utun_test_voice")
(directory / "moved").touch()
wait_for(lambda: (directory / "ready").exists() or test.poll() is not None)
if test.poll() is not None:
raise RuntimeError("client failed before server restart")
pulse.terminate(); pulse.wait(timeout=5)
wait_for(lambda: (directory / "closed").exists() or test.poll() is not None)
launch(["pipewire-pulse"], "pulse-after")
wait_for(pulse_ready)
create_sinks()
if test.wait(timeout=15) != 0:
raise RuntimeError("client recovery failed")
logs[3].flush(); logs[3].seek(0)
print(logs[3].read())
print("private PulseAudio server restart: PASS")
except Exception:
for log in logs:
log.flush(); log.seek(0)
print(f"--- {Path(log.name).name} ---\n{log.read()}", file=sys.stderr)
raise
finally:
for process in reversed(processes):
if process.poll() is None:
os.killpg(process.pid, signal.SIGTERM)
try:
process.wait(timeout=5)
except subprocess.TimeoutExpired:
os.killpg(process.pid, signal.SIGKILL); process.wait()
for log in logs:
log.close()
if __name__ == "__main__":
main()