Files
tueit_Transkriptor/main.py
T
thomas.kopp 52e55e112f 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.
2026-08-02 01:25:15 +02:00

173 lines
5.8 KiB
Python

import asyncio
import os
import signal
import threading
import webbrowser
from pathlib import Path
import uvicorn
from fastapi import FastAPI
from fastapi.responses import FileResponse, RedirectResponse
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
# ── FastAPI ────────────────────────────────────────────────────────────────────
app = FastAPI(title="tüit Transkriptor")
app.include_router(router)
FRONTEND_DIR = Path(__file__).parent / "frontend"
@app.get("/")
async def index():
return FileResponse(str(FRONTEND_DIR / "index.html"))
@app.get("/login")
async def login_page():
return FileResponse(str(FRONTEND_DIR / "login.html"))
@app.get("/app.js")
async def appjs():
return FileResponse(str(FRONTEND_DIR / "app.js"))
@app.get("/logo.svg")
async def logo():
return FileResponse(str(FRONTEND_DIR / "logo.svg"), media_type="image/svg+xml")
@app.get("/settings.js")
async def settingsjs():
return FileResponse(str(FRONTEND_DIR / "settings.js"))
# ── PID file ───────────────────────────────────────────────────────────────────
def write_pid(pid_path: str):
os.makedirs(os.path.dirname(pid_path), exist_ok=True)
Path(pid_path).write_text(str(os.getpid()))
def remove_pid(pid_path: str):
try:
os.unlink(pid_path)
except FileNotFoundError:
pass
# ── SIGUSR1 → toggle ──────────────────────────────────────────────────────────
# We capture uvicorn's event loop after it starts, so the signal handler can
# schedule the toggle coroutine in the correct loop — not a separate one.
_uvicorn_loop: asyncio.AbstractEventLoop | None = None
def _sigusr1_handler(signum, frame):
if _uvicorn_loop:
_uvicorn_loop.call_soon_threadsafe(
lambda: asyncio.ensure_future(_async_toggle(), loop=_uvicorn_loop)
)
async def _async_toggle():
from api.router import toggle_recording
# Toggle without a real user dependency — use guest context for signal-triggered recordings.
from api.router import _guest_user
await toggle_recording(user=_guest_user())
# ── Tray ───────────────────────────────────────────────────────────────────────
def _make_icon(recording: bool = False) -> Image.Image:
img = Image.new("RGBA", (64, 64), (0, 0, 0, 0))
draw = ImageDraw.Draw(img)
color = (218, 37, 28, 255) if recording else (80, 80, 80, 255)
draw.ellipse([8, 8, 56, 56], fill=color)
return img
def run_tray(port: int):
icon = pystray.Icon(
"tueit-transcriber",
_make_icon(False),
"tüit Transkriptor",
menu=pystray.Menu(
pystray.MenuItem("Aufnahme starten/stoppen", lambda i, it: (
_uvicorn_loop and _uvicorn_loop.call_soon_threadsafe(
lambda: asyncio.ensure_future(_async_toggle(), loop=_uvicorn_loop)
)
), default=True),
pystray.MenuItem("Öffnen", lambda i, it: webbrowser.open(f"http://localhost:{port}")),
pystray.MenuItem("Beenden", lambda i, it: (icon.stop(), os._exit(0))),
),
)
def update_icon(s):
icon.icon = _make_icon(s.status == Status.RECORDING)
state.subscribe(update_icon)
icon.run()
# ── Server ─────────────────────────────────────────────────────────────────────
class _LoopCapture(uvicorn.Server):
"""Subclass that exposes its event loop for the SIGUSR1 handler."""
def install_signal_handlers(self):
# Disable uvicorn's own signal handlers so our SIGUSR1 handler works.
pass
async def startup(self, sockets=None):
global _uvicorn_loop
_uvicorn_loop = asyncio.get_running_loop()
await super().startup(sockets=sockets)
def run_server(config: uvicorn.Config):
server = _LoopCapture(config)
server.run()
# ── Entrypoint ─────────────────────────────────────────────────────────────────
if __name__ == "__main__":
cfg = load_config()
port = cfg["server"]["port"]
host = cfg.get("network", {}).get("host", "127.0.0.1")
pid_path = cfg.get("pid_file", os.path.expanduser("~/.local/run/tueit-transcriber.pid"))
write_pid(pid_path)
# 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")
server_thread = threading.Thread(target=run_server, args=(uvicorn_cfg,), daemon=True)
server_thread.start()
# Wait until uvicorn has captured its loop
import time
for _ in range(50):
if _uvicorn_loop is not None:
break
time.sleep(0.1)
from auth import has_users
start_path = "/setup" if not has_users() else "/"
webbrowser.open(f"http://localhost:{port}{start_path}")
try:
run_tray(port)
finally:
remove_pid(pid_path)