mirror of
https://github.com/R0m1k3/LiveFlow.git
synced 2026-10-11 17:27:19 +02:00
- Sélecteur de micro intégré à l'interface (persisté, deviceId exact) - Amplification serveur des segments trop faibles (mode mains-libres BT) - Alerte visible si aucun son n'est capté pendant 5 s - Niveau crête de chaque segment dans les logs - Cache-Control: no-cache sur les fichiers statiques (fini le vieux JS en cache) https://claude.ai/code/session_01YHMp3EKzr4s6o8w1ygxuUe
381 lines
13 KiB
Python
381 lines
13 KiB
Python
import asyncio
|
|
import audioop
|
|
import hashlib
|
|
import hmac
|
|
import io
|
|
import json
|
|
import os
|
|
import secrets
|
|
import time
|
|
import wave
|
|
from contextlib import asynccontextmanager
|
|
from datetime import datetime, timezone
|
|
|
|
import aiosqlite
|
|
import httpx
|
|
from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect
|
|
from fastapi.responses import FileResponse, JSONResponse, RedirectResponse, Response
|
|
from fastapi.staticfiles import StaticFiles
|
|
from pydantic import BaseModel
|
|
|
|
from segmenter import SAMPLE_RATE, Segment, SpeechSegmenter
|
|
|
|
DB_PATH = os.environ.get("DB_PATH", "/data/liveflow.db")
|
|
ASR_BASE_URL = os.environ.get("ASR_BASE_URL", "http://asr:8000/v1").rstrip("/")
|
|
ASR_MODEL = os.environ.get("ASR_MODEL", "Qwen/Qwen3-ASR-1.7B")
|
|
ASR_API_KEY = os.environ.get("ASR_API_KEY", "sk-local")
|
|
ASR_LANGUAGE = os.environ.get("ASR_LANGUAGE", "").strip()
|
|
|
|
LIVEFLOW_USER = os.environ.get("LIVEFLOW_USER", "admin")
|
|
LIVEFLOW_PASSWORD = os.environ.get("LIVEFLOW_PASSWORD", "admin")
|
|
SESSION_TTL = 7 * 24 * 3600 # 7 jours
|
|
SESSION_COOKIE = "liveflow_session"
|
|
|
|
db: aiosqlite.Connection | None = None
|
|
http: httpx.AsyncClient | None = None
|
|
session_secret: bytes = b""
|
|
|
|
|
|
def load_session_secret() -> bytes:
|
|
"""Secret HMAC persistant pour signer les cookies de session."""
|
|
path = os.path.join(os.path.dirname(DB_PATH), "session-secret")
|
|
try:
|
|
with open(path, "rb") as f:
|
|
return f.read()
|
|
except FileNotFoundError:
|
|
secret = secrets.token_bytes(32)
|
|
with open(path, "wb") as f:
|
|
f.write(secret)
|
|
return secret
|
|
|
|
|
|
def make_session_token() -> str:
|
|
expiry = str(int(time.time()) + SESSION_TTL)
|
|
sig = hmac.new(session_secret, expiry.encode(), hashlib.sha256).hexdigest()
|
|
return f"{expiry}.{sig}"
|
|
|
|
|
|
def session_valid(token: str) -> bool:
|
|
try:
|
|
expiry, sig = token.split(".", 1)
|
|
expected = hmac.new(session_secret, expiry.encode(), hashlib.sha256).hexdigest()
|
|
return hmac.compare_digest(sig, expected) and time.time() < int(expiry)
|
|
except (ValueError, AttributeError):
|
|
return False
|
|
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(app: FastAPI):
|
|
global db, http, session_secret
|
|
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
|
|
session_secret = load_session_secret()
|
|
db = await aiosqlite.connect(DB_PATH)
|
|
db.row_factory = aiosqlite.Row
|
|
await db.executescript(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS meetings (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
title TEXT NOT NULL,
|
|
created_at TEXT NOT NULL
|
|
);
|
|
CREATE TABLE IF NOT EXISTS segments (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
meeting_id INTEGER NOT NULL REFERENCES meetings(id) ON DELETE CASCADE,
|
|
t0 REAL NOT NULL,
|
|
t1 REAL NOT NULL,
|
|
text TEXT NOT NULL
|
|
);
|
|
"""
|
|
)
|
|
await db.execute("PRAGMA foreign_keys = ON")
|
|
await db.commit()
|
|
http = httpx.AsyncClient(timeout=120)
|
|
yield
|
|
await http.aclose()
|
|
await db.close()
|
|
|
|
|
|
app = FastAPI(title="LiveFlow", lifespan=lifespan)
|
|
|
|
|
|
# ----------------------------------------------------------- authentification
|
|
|
|
@app.middleware("http")
|
|
async def auth_middleware(request: Request, call_next):
|
|
path = request.url.path
|
|
authed = session_valid(request.cookies.get(SESSION_COOKIE, ""))
|
|
if path.startswith("/api") and path != "/api/login" and not authed:
|
|
return JSONResponse({"detail": "Non authentifié"}, status_code=401)
|
|
if path == "/" and not authed:
|
|
return RedirectResponse("/login")
|
|
if path == "/login" and authed:
|
|
return RedirectResponse("/")
|
|
return await call_next(request)
|
|
|
|
|
|
class LoginBody(BaseModel):
|
|
username: str
|
|
password: str
|
|
|
|
|
|
@app.get("/login")
|
|
async def login_page():
|
|
return FileResponse("static/login.html")
|
|
|
|
|
|
@app.post("/api/login")
|
|
async def login(body: LoginBody):
|
|
user_ok = hmac.compare_digest(body.username.encode(), LIVEFLOW_USER.encode())
|
|
pass_ok = hmac.compare_digest(body.password.encode(), LIVEFLOW_PASSWORD.encode())
|
|
if not (user_ok and pass_ok):
|
|
raise HTTPException(401, "Identifiants invalides")
|
|
resp = JSONResponse({"ok": True})
|
|
resp.set_cookie(
|
|
SESSION_COOKIE, make_session_token(),
|
|
max_age=SESSION_TTL, httponly=True, samesite="lax",
|
|
)
|
|
return resp
|
|
|
|
|
|
@app.post("/api/logout")
|
|
async def logout():
|
|
resp = JSONResponse({"ok": True})
|
|
resp.delete_cookie(SESSION_COOKIE)
|
|
return resp
|
|
|
|
|
|
def pcm_to_wav(pcm: bytes) -> bytes:
|
|
buf = io.BytesIO()
|
|
with wave.open(buf, "wb") as w:
|
|
w.setnchannels(1)
|
|
w.setsampwidth(2)
|
|
w.setframerate(SAMPLE_RATE)
|
|
w.writeframes(pcm)
|
|
return buf.getvalue()
|
|
|
|
|
|
QUIET_PEAK = 9000 # niveau crête en dessous duquel on amplifie (échelle int16)
|
|
|
|
|
|
def boost_quiet_audio(pcm: bytes) -> tuple[bytes, int]:
|
|
"""Amplifie les segments trop faibles (micros Bluetooth mains-libres...).
|
|
|
|
Retourne (audio, niveau crête d'origine).
|
|
"""
|
|
peak = audioop.max(pcm, 2)
|
|
if 0 < peak < QUIET_PEAK:
|
|
factor = min(6.0, 26000 / peak)
|
|
return audioop.mul(pcm, 2, factor), peak
|
|
return pcm, peak
|
|
|
|
|
|
async def _post_transcription(data: dict, wav: bytes) -> httpx.Response:
|
|
return await http.post(
|
|
f"{ASR_BASE_URL}/audio/transcriptions",
|
|
headers={"Authorization": f"Bearer {ASR_API_KEY}"},
|
|
data=data,
|
|
files={"file": ("segment.wav", wav, "audio/wav")},
|
|
)
|
|
|
|
|
|
async def transcribe(pcm: bytes) -> str:
|
|
wav = pcm_to_wav(pcm)
|
|
data = {"model": ASR_MODEL}
|
|
if ASR_LANGUAGE:
|
|
data["language"] = ASR_LANGUAGE
|
|
resp = await _post_transcription(data, wav)
|
|
if resp.status_code == 400 and "language" in data:
|
|
# Certains moteurs (ex. Qwen3-ASR via vLLM) rejettent le paramètre
|
|
# language : on retente en laissant la détection automatique.
|
|
print(f"ASR a refusé language={ASR_LANGUAGE!r} ({resp.text[:200]}), "
|
|
"nouvel essai sans ce paramètre", flush=True)
|
|
del data["language"]
|
|
resp = await _post_transcription(data, wav)
|
|
if resp.status_code != 200:
|
|
raise RuntimeError(f"ASR HTTP {resp.status_code} : {resp.text[:300]}")
|
|
return resp.json().get("text", "").strip()
|
|
|
|
|
|
# ---------------------------------------------------------------- WebSocket
|
|
|
|
@app.websocket("/ws")
|
|
async def ws_transcribe(ws: WebSocket):
|
|
if not session_valid(ws.cookies.get(SESSION_COOKIE, "")):
|
|
await ws.close(code=4401)
|
|
return
|
|
await ws.accept()
|
|
|
|
# Premier message : {"type": "start", "title": "..."}
|
|
try:
|
|
start = json.loads(await ws.receive_text())
|
|
assert start.get("type") == "start"
|
|
except Exception:
|
|
await ws.close(code=4000)
|
|
return
|
|
|
|
title = (start.get("title") or "").strip() or datetime.now().strftime("Réunion du %d/%m/%Y %H:%M")
|
|
cur = await db.execute(
|
|
"INSERT INTO meetings (title, created_at) VALUES (?, ?)",
|
|
(title, datetime.now(timezone.utc).isoformat()),
|
|
)
|
|
meeting_id = cur.lastrowid
|
|
await db.commit()
|
|
await ws.send_json({"type": "ready", "meeting_id": meeting_id, "title": title})
|
|
|
|
segmenter = SpeechSegmenter()
|
|
queue: asyncio.Queue[Segment | None] = asyncio.Queue()
|
|
|
|
async def worker():
|
|
"""Transcrit les segments dans l'ordre et pousse le texte au client."""
|
|
while True:
|
|
seg = await queue.get()
|
|
if seg is None:
|
|
return
|
|
pcm, peak = boost_quiet_audio(seg.pcm)
|
|
print(f"[réunion {meeting_id}] segment {seg.t0:.1f}s → {seg.t1:.1f}s "
|
|
f"({len(seg.pcm) / 2 / SAMPLE_RATE:.1f}s d'audio, niveau crête {peak}"
|
|
f"{', amplifié' if pcm is not seg.pcm else ''}), transcription...", flush=True)
|
|
try:
|
|
text = await transcribe(pcm)
|
|
except Exception as exc:
|
|
print(f"[réunion {meeting_id}] ERREUR ASR : {exc}", flush=True)
|
|
await ws.send_json({"type": "error", "message": f"Transcription échouée : {exc}"})
|
|
continue
|
|
print(f"[réunion {meeting_id}] texte : {text[:80]!r}", flush=True)
|
|
if not text:
|
|
continue
|
|
await db.execute(
|
|
"INSERT INTO segments (meeting_id, t0, t1, text) VALUES (?, ?, ?, ?)",
|
|
(meeting_id, seg.t0, seg.t1, text),
|
|
)
|
|
await db.commit()
|
|
await ws.send_json({"type": "segment", "t0": seg.t0, "t1": seg.t1, "text": text})
|
|
|
|
worker_task = asyncio.create_task(worker())
|
|
try:
|
|
while True:
|
|
msg = await ws.receive()
|
|
if msg["type"] == "websocket.disconnect":
|
|
break
|
|
if msg.get("bytes") is not None:
|
|
for seg in segmenter.feed(msg["bytes"]):
|
|
queue.put_nowait(seg)
|
|
elif msg.get("text"):
|
|
control = json.loads(msg["text"])
|
|
if control.get("type") == "stop":
|
|
if (last := segmenter.flush()) is not None:
|
|
queue.put_nowait(last)
|
|
queue.put_nowait(None)
|
|
await worker_task
|
|
worker_task = None
|
|
await ws.send_json({"type": "done", "meeting_id": meeting_id})
|
|
break
|
|
except WebSocketDisconnect:
|
|
pass
|
|
finally:
|
|
if worker_task is not None:
|
|
# Déconnexion brutale : on transcrit quand même ce qui restait.
|
|
if (last := segmenter.flush()) is not None:
|
|
queue.put_nowait(last)
|
|
queue.put_nowait(None)
|
|
try:
|
|
await worker_task
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
# --------------------------------------------------------------------- API
|
|
|
|
@app.get("/api/meetings")
|
|
async def list_meetings():
|
|
rows = await db.execute_fetchall(
|
|
"""
|
|
SELECT m.id, m.title, m.created_at, COUNT(s.id) AS segments,
|
|
COALESCE(MAX(s.t1), 0) AS duration
|
|
FROM meetings m LEFT JOIN segments s ON s.meeting_id = m.id
|
|
GROUP BY m.id ORDER BY m.id DESC
|
|
"""
|
|
)
|
|
return [dict(r) for r in rows]
|
|
|
|
|
|
async def get_meeting_or_404(meeting_id: int) -> dict:
|
|
cur = await db.execute("SELECT * FROM meetings WHERE id = ?", (meeting_id,))
|
|
row = await cur.fetchone()
|
|
if row is None:
|
|
raise HTTPException(404, "Réunion introuvable")
|
|
return dict(row)
|
|
|
|
|
|
@app.get("/api/meetings/{meeting_id}")
|
|
async def get_meeting(meeting_id: int):
|
|
meeting = await get_meeting_or_404(meeting_id)
|
|
rows = await db.execute_fetchall(
|
|
"SELECT t0, t1, text FROM segments WHERE meeting_id = ? ORDER BY id", (meeting_id,)
|
|
)
|
|
meeting["segments"] = [dict(r) for r in rows]
|
|
return meeting
|
|
|
|
|
|
@app.delete("/api/meetings/{meeting_id}")
|
|
async def delete_meeting(meeting_id: int):
|
|
await get_meeting_or_404(meeting_id)
|
|
await db.execute("DELETE FROM meetings WHERE id = ?", (meeting_id,))
|
|
await db.commit()
|
|
return JSONResponse({"ok": True})
|
|
|
|
|
|
def fmt_ts(seconds: float, srt: bool = False) -> str:
|
|
h, rem = divmod(int(seconds), 3600)
|
|
m, s = divmod(rem, 60)
|
|
if srt:
|
|
ms = int(round((seconds - int(seconds)) * 1000))
|
|
return f"{h:02}:{m:02}:{s:02},{ms:03}"
|
|
return f"{h:02}:{m:02}:{s:02}"
|
|
|
|
|
|
@app.get("/api/meetings/{meeting_id}/export")
|
|
async def export_meeting(meeting_id: int, format: str = "txt"):
|
|
meeting = await get_meeting(meeting_id)
|
|
segs = meeting["segments"]
|
|
title = meeting["title"]
|
|
# Les en-têtes HTTP n'acceptent que l'ASCII : on translittère le titre.
|
|
safe = "".join(
|
|
c if c.isascii() and (c.isalnum() or c in " -_") else "_" for c in title
|
|
).strip() or "reunion"
|
|
|
|
if format == "json":
|
|
body, mime, ext = json.dumps(meeting, ensure_ascii=False, indent=2), "application/json", "json"
|
|
elif format == "md":
|
|
lines = [f"# {title}", ""]
|
|
lines += [f"**[{fmt_ts(s['t0'])}]** {s['text']}" for s in segs]
|
|
body, mime, ext = "\n\n".join(lines) + "\n", "text/markdown", "md"
|
|
elif format == "srt":
|
|
blocks = [
|
|
f"{i}\n{fmt_ts(s['t0'], srt=True)} --> {fmt_ts(s['t1'], srt=True)}\n{s['text']}"
|
|
for i, s in enumerate(segs, 1)
|
|
]
|
|
body, mime, ext = "\n\n".join(blocks) + "\n", "application/x-subrip", "srt"
|
|
elif format == "txt":
|
|
body, mime, ext = "\n".join(s["text"] for s in segs) + "\n", "text/plain", "txt"
|
|
else:
|
|
raise HTTPException(400, "Format inconnu (txt, md, srt, json)")
|
|
|
|
return Response(
|
|
content=body,
|
|
media_type=f"{mime}; charset=utf-8",
|
|
headers={"Content-Disposition": f'attachment; filename="{safe}.{ext}"'},
|
|
)
|
|
|
|
|
|
class NoCacheStaticFiles(StaticFiles):
|
|
"""Force la revalidation des fichiers statiques après chaque mise à jour."""
|
|
|
|
async def get_response(self, path, scope):
|
|
resp = await super().get_response(path, scope)
|
|
resp.headers["Cache-Control"] = "no-cache"
|
|
return resp
|
|
|
|
|
|
app.mount("/", NoCacheStaticFiles(directory="static", html=True), name="static")
|