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"})
+10 -23
View File
@@ -112,8 +112,11 @@ async def toggle_recording(user: dict = Depends(current_user)):
return {"action": "reset"}
if state.status == Status.IDLE:
from audio import AudioRecorder
import pipewire
cfg = load_config()
audio_device = cfg.get("audio", {}).get("device") or None
# Build the capture chain only for the duration of the recording.
pipewire.setup()
state._recorder = AudioRecorder(device=audio_device)
state._recorder.start()
state.recording_user = user["username"]
@@ -303,7 +306,8 @@ async def list_audio_devices(user: dict = Depends(current_user)):
@router.post("/audio/combined")
async def create_combined_source(body: dict, user: dict = Depends(current_user)):
import subprocess, json, pathlib
import subprocess
import pipewire
if not user.get("is_admin"):
raise HTTPException(status_code=403, detail="Nur Administratoren")
mic_sd = body.get("mic", "")
@@ -320,28 +324,11 @@ async def create_combined_source(body: dict, user: dict = Depends(current_user))
known = {line.split("\t")[1] for line in out.strip().splitlines() if "\t" in line}
if mic not in known or monitor not in known:
raise HTTPException(status_code=400, detail="Unbekanntes Audio-Device")
# Use description without spaces so sounddevice name == sink_name
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()
state_path = pathlib.Path(
os.path.expanduser("~/.config/tueit-transcriber/pipewire-modules.json")
)
state_path.parent.mkdir(parents=True, exist_ok=True)
ids = [int(sink_id), int(mic_id), int(mon_id)]
# Store pactl names for restore, sounddevice name as device
state_path.write_text(json.dumps({"ids": ids, "mic": mic, "monitor": monitor}))
return {"device": "transkriptor-combined", "module_ids": ids}
# Only remember the selection. The chain itself is loaded when a recording
# starts and unloaded when it ends, so it does not couple the microphone
# and the speaker device into one PipeWire clock domain while idle.
pipewire.save_devices(mic, monitor)
return {"device": pipewire.SINK_NAME}
@router.get("/settings")