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