fix: build PipeWire capture chain on demand instead of permanently

The null sink and its two loopbacks were loaded at startup and stayed
loaded forever. While loaded they link the microphone and the speaker
device into a single PipeWire driver group: the microphone drives the
graph and the speaker device runs as a clock follower, so every playback
stream pays for continuous cross-device resampling.

Nothing needs the chain while idle — recording is triggered manually.
Build it in toggle_recording() and drop it as soon as the audio is
captured, before transcription runs for minutes.

Module ids are now derived from pactl instead of tracked in a state file,
so a crashed process cannot leave stale ids behind. The state file only
holds the device selection. Startup tears down any leftover chain.

Loopbacks get latency_msec=200; transcription is offline, and a small
buffer would put these nodes on the graph's realtime deadline.
This commit is contained in:
2026-08-02 01:25:15 +02:00
parent 6b0ee60d94
commit 52e55e112f
5 changed files with 322 additions and 64 deletions
+4 -40
View File
@@ -12,6 +12,7 @@ from fastapi.staticfiles import StaticFiles
import pystray
from PIL import Image, ImageDraw
import pipewire
from api.router import router
from api.state import state, Status
from config import load as load_config
@@ -49,45 +50,6 @@ async def settingsjs():
return FileResponse(str(FRONTEND_DIR / "settings.js"))
# ── PipeWire combined source restore ──────────────────────────────────────────
def _restore_pipewire_combined():
"""Recreate transkriptor-combined.monitor on startup if it was previously configured."""
import json, subprocess, logging
state_path = Path(os.path.expanduser("~/.config/tueit-transcriber/pipewire-modules.json"))
if not state_path.exists():
return
try:
data = json.loads(state_path.read_text())
mic = data.get("mic")
monitor = data.get("monitor")
if not mic or not monitor:
return
sources = subprocess.check_output(
["pactl", "list", "sources", "short"], stderr=subprocess.DEVNULL, timeout=5
).decode()
if "transkriptor-combined.monitor" in sources:
return # already loaded
sink_id = subprocess.check_output([
"pactl", "load-module", "module-null-sink",
"sink_name=transkriptor-combined",
"sink_properties=device.description=transkriptor-combined",
], timeout=5).decode().strip()
mic_id = subprocess.check_output([
"pactl", "load-module", "module-loopback",
f"source={mic}", "sink=transkriptor-combined",
], timeout=5).decode().strip()
mon_id = subprocess.check_output([
"pactl", "load-module", "module-loopback",
f"source={monitor}", "sink=transkriptor-combined",
], timeout=5).decode().strip()
ids = [int(sink_id), int(mic_id), int(mon_id)]
state_path.write_text(json.dumps({"ids": ids, "mic": mic, "monitor": monitor}))
logging.getLogger(__name__).info("Restored PipeWire combined source (ids: %s)", ids)
except Exception as e:
logging.getLogger(__name__).warning("Could not restore PipeWire combined source: %s", e)
# ── PID file ───────────────────────────────────────────────────────────────────
def write_pid(pid_path: str):
@@ -184,7 +146,9 @@ if __name__ == "__main__":
pid_path = cfg.get("pid_file", os.path.expanduser("~/.local/run/tueit-transcriber.pid"))
write_pid(pid_path)
_restore_pipewire_combined()
# Clear a chain left behind by a previous crash. It is built on demand when
# a recording starts, never while idle.
pipewire.teardown()
signal.signal(signal.SIGUSR1, _sigusr1_handler)
uvicorn_cfg = uvicorn.Config(app, host=host, port=port, log_level="debug")