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
+8 -1
View File
@@ -5,6 +5,7 @@ import tempfile
import traceback
from datetime import datetime
import pipewire
from api.state import state, Status
from api.router import broadcast
from config import load as load_config
@@ -26,7 +27,13 @@ async def run_pipeline():
diar_cfg = cfg.get("diarization", {})
use_diarization = diar_cfg.get("enabled") and diar_cfg.get("hf_token")
recorder.stop()
# Drop the capture chain as soon as the audio is captured — transcription
# runs for minutes and must not keep the PipeWire clock domains coupled.
try:
recorder.stop()
finally:
pipewire.teardown()
await state.set_status(Status.PROCESSING)
await broadcast({"event": "processing"})