mirror of
https://github.com/R0m1k3/LiveFlow.git
synced 2026-10-11 17:27:19 +02:00
Implémentation complète de LiveFlow (proposition 2, moteur Qwen3-ASR)
- docker-compose : Caddy (HTTPS local) + app FastAPI + Qwen3-ASR via vLLM (GPU) - Backend : WebSocket audio, segmentation VAD (WebRTC), appel ASR compatible OpenAI, stockage SQLite, exports TXT/MD/SRT/JSON - Frontend : capture micro (AudioWorklet, rééchantillonnage 16 kHz), transcription live, historique des réunions, copie et téléchargement - README : démarrage, prérequis GPU, changement de moteur ASR https://claude.ai/code/session_01YHMp3EKzr4s6o8w1ygxuUe
This commit is contained in:
13 files changed
+964
No files matched your search
@@ -0,0 +1,9 @@
|
||||
# Code langue ISO forcé pour la transcription ("fr", "en"...).
|
||||
# Laisser vide pour la détection automatique de la langue.
|
||||
ASR_LANGUAGE=
|
||||
|
||||
# Clé API transmise au moteur ASR (sans importance en local).
|
||||
ASR_API_KEY=sk-local
|
||||
|
||||
# Jeton Hugging Face, seulement si le téléchargement du modèle l'exige.
|
||||
HF_TOKEN=
|
||||
@@ -0,0 +1,4 @@
|
||||
.env
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.db
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
# Certificats auto-signés locaux : indispensable pour que le navigateur
|
||||
# (surtout sur téléphone) autorise l'accès au micro (getUserMedia).
|
||||
local_certs
|
||||
}
|
||||
|
||||
:443 {
|
||||
tls internal
|
||||
reverse_proxy app:8000
|
||||
}
|
||||
|
||||
:80 {
|
||||
redir https://{host}{uri} permanent
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
# 🎙️ LiveFlow
|
||||
|
||||
Transcription de réunions **en direct**, **100 % locale**, dans Docker.
|
||||
Capture du micro depuis un navigateur (PC ou téléphone), transcription par
|
||||
**Qwen3-ASR** sur GPU NVIDIA, affichage du texte au fil de l'eau, historique
|
||||
et export (TXT, Markdown, SRT, JSON).
|
||||
|
||||
Architecture détaillée : voir [ARCHITECTURE.md](ARCHITECTURE.md) (proposition 2 retenue).
|
||||
|
||||
```
|
||||
Navigateur (micro, HTTPS/WebSocket)
|
||||
→ Caddy (TLS local)
|
||||
→ app FastAPI (VAD + segments + SQLite + interface web)
|
||||
→ conteneur ASR Qwen3-ASR via vLLM (API compatible OpenAI)
|
||||
```
|
||||
|
||||
## Prérequis
|
||||
|
||||
- Docker + Docker Compose
|
||||
- GPU NVIDIA avec pilotes récents et le
|
||||
[NVIDIA Container Toolkit](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html)
|
||||
(`nvidia-ctk runtime configure --runtime=docker`)
|
||||
- ~6 Go de VRAM libres pour Qwen3-ASR-1.7B (ajuster `--gpu-memory-utilization`)
|
||||
|
||||
## Démarrage
|
||||
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
Au premier lancement, le conteneur `asr` télécharge le modèle depuis
|
||||
Hugging Face (quelques Go, mis en cache dans un volume). Suivre avec :
|
||||
|
||||
```bash
|
||||
docker compose logs -f asr
|
||||
```
|
||||
|
||||
Puis ouvrir **https://\<ip-du-serveur\>** depuis un PC ou un téléphone du
|
||||
réseau local, accepter le certificat auto-signé, autoriser le micro, et
|
||||
cliquer sur **Démarrer**.
|
||||
|
||||
> ⚠️ Le HTTPS est obligatoire : les navigateurs n'autorisent l'accès au micro
|
||||
> (`getUserMedia`) qu'en HTTPS (ou sur `localhost`). Caddy génère
|
||||
> automatiquement un certificat local auto-signé. Pour supprimer
|
||||
> l'avertissement du navigateur, vous pouvez installer la CA racine de Caddy
|
||||
> sur vos appareils : `docker compose cp caddy:/data/caddy/pki/authorities/local/root.crt .`
|
||||
|
||||
## Configuration
|
||||
|
||||
Variables d'environnement (fichier `.env` à la racine, voir `.env.example`) :
|
||||
|
||||
| Variable | Défaut | Rôle |
|
||||
|---|---|---|
|
||||
| `ASR_LANGUAGE` | *(vide)* | Code langue forcé (`fr`, `en`…) ; vide = détection auto |
|
||||
| `ASR_API_KEY` | `sk-local` | Clé envoyée au moteur ASR (inutile en local) |
|
||||
| `HF_TOKEN` | *(vide)* | Jeton Hugging Face si nécessaire au téléchargement |
|
||||
|
||||
## Changer de moteur de transcription
|
||||
|
||||
L'app ne parle au moteur que via l'API OpenAI (`/v1/audio/transcriptions`).
|
||||
Pour changer de moteur, remplacer le service `asr` dans `docker-compose.yml`
|
||||
et ajuster `ASR_MODEL`. Exemples :
|
||||
|
||||
```yaml
|
||||
# CPU uniquement — Parakeet-TDT-0.6B-v3 (rapide, 25 langues dont FR)
|
||||
asr:
|
||||
image: ghcr.io/groxaxo/parakeet-tdt-0.6b-v3-fastapi-openai:latest
|
||||
|
||||
# Écosystème Whisper — Speaches (faster-whisper, CPU/GPU)
|
||||
asr:
|
||||
image: ghcr.io/speaches-ai/speaches:latest-cuda
|
||||
```
|
||||
|
||||
## Récupérer une transcription
|
||||
|
||||
Depuis l'interface (boutons TXT / MD / SRT / JSON / Copier) ou en direct via l'API :
|
||||
|
||||
```bash
|
||||
curl -k https://<ip>/api/meetings # liste des réunions
|
||||
curl -k https://<ip>/api/meetings/1/export?format=txt # export texte
|
||||
```
|
||||
@@ -0,0 +1,11 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /srv
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
EXPOSE 8000
|
||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
+247
@@ -0,0 +1,247 @@
|
||||
import asyncio
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import wave
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import aiosqlite
|
||||
import httpx
|
||||
from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect
|
||||
from fastapi.responses import JSONResponse, Response
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
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()
|
||||
|
||||
db: aiosqlite.Connection | None = None
|
||||
http: httpx.AsyncClient | None = None
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
global db, http
|
||||
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
|
||||
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)
|
||||
|
||||
|
||||
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()
|
||||
|
||||
|
||||
async def transcribe(pcm: bytes) -> str:
|
||||
data = {"model": ASR_MODEL}
|
||||
if ASR_LANGUAGE:
|
||||
data["language"] = ASR_LANGUAGE
|
||||
resp = await http.post(
|
||||
f"{ASR_BASE_URL}/audio/transcriptions",
|
||||
headers={"Authorization": f"Bearer {ASR_API_KEY}"},
|
||||
data=data,
|
||||
files={"file": ("segment.wav", pcm_to_wav(pcm), "audio/wav")},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json().get("text", "").strip()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- WebSocket
|
||||
|
||||
@app.websocket("/ws")
|
||||
async def ws_transcribe(ws: WebSocket):
|
||||
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
|
||||
try:
|
||||
text = await transcribe(seg.pcm)
|
||||
except Exception as exc:
|
||||
await ws.send_json({"type": "error", "message": f"Transcription échouée : {exc}"})
|
||||
continue
|
||||
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}"'},
|
||||
)
|
||||
|
||||
|
||||
app.mount("/", StaticFiles(directory="static", html=True), name="static")
|
||||
@@ -0,0 +1,5 @@
|
||||
fastapi~=0.115
|
||||
uvicorn[standard]~=0.34
|
||||
httpx~=0.28
|
||||
aiosqlite~=0.21
|
||||
webrtcvad-wheels~=2.0
|
||||
@@ -0,0 +1,100 @@
|
||||
"""Découpage du flux micro en segments de parole via WebRTC VAD.
|
||||
|
||||
Le client envoie du PCM 16 bits mono 16 kHz. On analyse des trames de 30 ms :
|
||||
un segment démarre quand la parole domine la fenêtre récente (avec un
|
||||
pré-roll pour ne pas couper le début de phrase) et se termine après un
|
||||
silence prolongé ou une durée maximale.
|
||||
"""
|
||||
|
||||
from collections import deque
|
||||
from dataclasses import dataclass
|
||||
|
||||
import webrtcvad
|
||||
|
||||
SAMPLE_RATE = 16000
|
||||
FRAME_MS = 30
|
||||
FRAME_BYTES = SAMPLE_RATE * FRAME_MS // 1000 * 2 # 960 octets
|
||||
|
||||
VAD_AGGRESSIVENESS = 2
|
||||
PREROLL_FRAMES = 10 # 300 ms conservées avant le déclenchement
|
||||
TRIGGER_RATIO = 0.6 # part de trames "parole" du pré-roll pour démarrer
|
||||
SILENCE_END_MS = 700 # silence qui clôt un segment
|
||||
MIN_SPEECH_MS = 300 # en dessous, le segment est ignoré (bruit)
|
||||
MAX_SEGMENT_S = 25 # coupe forcée pour garder une latence raisonnable
|
||||
|
||||
|
||||
@dataclass
|
||||
class Segment:
|
||||
pcm: bytes
|
||||
t0: float # secondes depuis le début de la réunion
|
||||
t1: float
|
||||
|
||||
|
||||
class SpeechSegmenter:
|
||||
def __init__(self):
|
||||
self._vad = webrtcvad.Vad(VAD_AGGRESSIVENESS)
|
||||
self._pending = bytearray()
|
||||
self._ring: deque[tuple[bytes, bool]] = deque(maxlen=PREROLL_FRAMES)
|
||||
self._frame_index = 0
|
||||
self._triggered = False
|
||||
self._segment = bytearray()
|
||||
self._segment_start_frame = 0
|
||||
self._silence_frames = 0
|
||||
self._speech_frames = 0
|
||||
|
||||
def feed(self, data: bytes) -> list[Segment]:
|
||||
"""Ajoute de l'audio brut et renvoie les segments terminés."""
|
||||
self._pending.extend(data)
|
||||
segments = []
|
||||
while len(self._pending) >= FRAME_BYTES:
|
||||
frame = bytes(self._pending[:FRAME_BYTES])
|
||||
del self._pending[:FRAME_BYTES]
|
||||
seg = self._process_frame(frame)
|
||||
if seg is not None:
|
||||
segments.append(seg)
|
||||
return segments
|
||||
|
||||
def flush(self) -> Segment | None:
|
||||
"""Clôt le segment en cours (fin d'enregistrement)."""
|
||||
seg = self._finish_segment() if self._triggered else None
|
||||
self._ring.clear()
|
||||
self._pending.clear()
|
||||
return seg
|
||||
|
||||
def _process_frame(self, frame: bytes) -> Segment | None:
|
||||
is_speech = self._vad.is_speech(frame, SAMPLE_RATE)
|
||||
self._frame_index += 1
|
||||
|
||||
if not self._triggered:
|
||||
self._ring.append((frame, is_speech))
|
||||
voiced = sum(1 for _, s in self._ring if s)
|
||||
if len(self._ring) == self._ring.maxlen and voiced >= TRIGGER_RATIO * self._ring.maxlen:
|
||||
self._triggered = True
|
||||
self._segment_start_frame = self._frame_index - len(self._ring)
|
||||
self._segment = bytearray(b"".join(f for f, _ in self._ring))
|
||||
self._speech_frames = voiced
|
||||
self._silence_frames = 0
|
||||
self._ring.clear()
|
||||
return None
|
||||
|
||||
self._segment.extend(frame)
|
||||
if is_speech:
|
||||
self._speech_frames += 1
|
||||
self._silence_frames = 0
|
||||
else:
|
||||
self._silence_frames += 1
|
||||
|
||||
too_long = len(self._segment) >= MAX_SEGMENT_S * SAMPLE_RATE * 2
|
||||
ended = self._silence_frames * FRAME_MS >= SILENCE_END_MS
|
||||
if ended or too_long:
|
||||
return self._finish_segment()
|
||||
return None
|
||||
|
||||
def _finish_segment(self) -> Segment | None:
|
||||
self._triggered = False
|
||||
seg, self._segment = self._segment, bytearray()
|
||||
if self._speech_frames * FRAME_MS < MIN_SPEECH_MS:
|
||||
return None
|
||||
t0 = self._segment_start_frame * FRAME_MS / 1000
|
||||
t1 = self._frame_index * FRAME_MS / 1000
|
||||
return Segment(pcm=bytes(seg), t0=t0, t1=t1)
|
||||
@@ -0,0 +1,208 @@
|
||||
const $ = (id) => document.getElementById(id);
|
||||
|
||||
const state = {
|
||||
recording: false,
|
||||
ws: null,
|
||||
audioContext: null,
|
||||
workletNode: null,
|
||||
mediaStream: null,
|
||||
sendBuffer: [],
|
||||
sendBufferSamples: 0,
|
||||
timerInterval: null,
|
||||
startedAt: null,
|
||||
currentMeetingId: null,
|
||||
};
|
||||
|
||||
const BATCH_SAMPLES = 4096; // ~256 ms de PCM 16 kHz par message WebSocket
|
||||
|
||||
// ----------------------------------------------------------- enregistrement
|
||||
|
||||
async function startRecording() {
|
||||
try {
|
||||
state.mediaStream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true },
|
||||
});
|
||||
} catch (err) {
|
||||
alert("Accès au micro refusé ou indisponible.\nVérifiez que la page est bien servie en HTTPS.\n" + err);
|
||||
return;
|
||||
}
|
||||
|
||||
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
state.ws = new WebSocket(`${proto}://${location.host}/ws`);
|
||||
state.ws.onopen = () => state.ws.send(JSON.stringify({ type: 'start', title: $('title').value }));
|
||||
state.ws.onmessage = onServerMessage;
|
||||
state.ws.onclose = () => { if (state.recording) stopRecording(true); };
|
||||
|
||||
state.audioContext = new AudioContext();
|
||||
await state.audioContext.audioWorklet.addModule('worklet.js');
|
||||
const source = state.audioContext.createMediaStreamSource(state.mediaStream);
|
||||
state.workletNode = new AudioWorkletNode(state.audioContext, 'pcm-downsampler');
|
||||
state.workletNode.port.onmessage = (e) => queuePcm(new Int16Array(e.data));
|
||||
source.connect(state.workletNode);
|
||||
|
||||
state.recording = true;
|
||||
state.startedAt = Date.now();
|
||||
state.timerInterval = setInterval(updateTimer, 500);
|
||||
$('record-btn').textContent = '■ Arrêter';
|
||||
$('record-btn').classList.add('recording');
|
||||
$('title').disabled = true;
|
||||
setStatus('rec', 'Enregistrement…');
|
||||
clearTranscript();
|
||||
}
|
||||
|
||||
function queuePcm(samples) {
|
||||
if (!state.recording || !state.ws || state.ws.readyState !== WebSocket.OPEN) return;
|
||||
state.sendBuffer.push(samples);
|
||||
state.sendBufferSamples += samples.length;
|
||||
if (state.sendBufferSamples >= BATCH_SAMPLES) flushPcm();
|
||||
}
|
||||
|
||||
function flushPcm() {
|
||||
if (!state.sendBufferSamples) return;
|
||||
const out = new Int16Array(state.sendBufferSamples);
|
||||
let off = 0;
|
||||
for (const chunk of state.sendBuffer) { out.set(chunk, off); off += chunk.length; }
|
||||
state.sendBuffer = [];
|
||||
state.sendBufferSamples = 0;
|
||||
if (state.ws && state.ws.readyState === WebSocket.OPEN) state.ws.send(out.buffer);
|
||||
}
|
||||
|
||||
function stopRecording(abrupt = false) {
|
||||
state.recording = false;
|
||||
clearInterval(state.timerInterval);
|
||||
if (state.workletNode) state.workletNode.disconnect();
|
||||
if (state.mediaStream) state.mediaStream.getTracks().forEach((t) => t.stop());
|
||||
if (state.audioContext) state.audioContext.close();
|
||||
|
||||
if (!abrupt && state.ws && state.ws.readyState === WebSocket.OPEN) {
|
||||
flushPcm();
|
||||
state.ws.send(JSON.stringify({ type: 'stop' }));
|
||||
setStatus('busy', 'Finalisation…');
|
||||
} else {
|
||||
setStatus('idle', 'Prêt');
|
||||
}
|
||||
|
||||
$('record-btn').textContent = '● Démarrer';
|
||||
$('record-btn').classList.remove('recording');
|
||||
$('title').disabled = false;
|
||||
}
|
||||
|
||||
function onServerMessage(event) {
|
||||
const msg = JSON.parse(event.data);
|
||||
if (msg.type === 'ready') {
|
||||
state.currentMeetingId = msg.meeting_id;
|
||||
$('transcript-title').textContent = msg.title;
|
||||
showExportBar(msg.meeting_id);
|
||||
} else if (msg.type === 'segment') {
|
||||
appendSegment(msg);
|
||||
if (state.recording) setStatus('rec', 'Enregistrement…');
|
||||
} else if (msg.type === 'error') {
|
||||
setStatus('error', 'Erreur ASR');
|
||||
console.error(msg.message);
|
||||
} else if (msg.type === 'done') {
|
||||
setStatus('idle', 'Terminé ✓');
|
||||
state.ws.close();
|
||||
state.ws = null;
|
||||
loadMeetings();
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------- affichage
|
||||
|
||||
function setStatus(cls, text) {
|
||||
const el = $('status');
|
||||
el.className = 'badge ' + cls;
|
||||
el.textContent = text;
|
||||
}
|
||||
|
||||
function updateTimer() {
|
||||
const s = Math.floor((Date.now() - state.startedAt) / 1000);
|
||||
$('timer').textContent =
|
||||
String(Math.floor(s / 60)).padStart(2, '0') + ':' + String(s % 60).padStart(2, '0');
|
||||
}
|
||||
|
||||
function fmtTs(seconds) {
|
||||
const s = Math.floor(seconds);
|
||||
const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), sec = s % 60;
|
||||
return (h ? String(h).padStart(2, '0') + ':' : '') +
|
||||
String(m).padStart(2, '0') + ':' + String(sec).padStart(2, '0');
|
||||
}
|
||||
|
||||
function clearTranscript() {
|
||||
$('transcript').innerHTML = '';
|
||||
}
|
||||
|
||||
function appendSegment(seg) {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'segment';
|
||||
div.innerHTML = `<span class="ts">${fmtTs(seg.t0)}</span><span class="text"></span>`;
|
||||
div.querySelector('.text').textContent = seg.text;
|
||||
$('transcript').appendChild(div);
|
||||
$('transcript').scrollTop = $('transcript').scrollHeight;
|
||||
}
|
||||
|
||||
function showExportBar(meetingId) {
|
||||
$('export-bar').classList.remove('hidden');
|
||||
for (const fmt of ['txt', 'md', 'srt', 'json']) {
|
||||
$('export-' + fmt).href = `/api/meetings/${meetingId}/export?format=${fmt}`;
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------- réunions
|
||||
|
||||
async function loadMeetings() {
|
||||
const meetings = await (await fetch('/api/meetings')).json();
|
||||
const ul = $('meeting-list');
|
||||
ul.innerHTML = '';
|
||||
for (const m of meetings) {
|
||||
const li = document.createElement('li');
|
||||
const date = new Date(m.created_at).toLocaleString('fr-FR', { dateStyle: 'short', timeStyle: 'short' });
|
||||
li.innerHTML = `<span class="m-title"></span><span class="m-meta">${date} · ${fmtTs(m.duration)}</span>`;
|
||||
li.querySelector('.m-title').textContent = m.title;
|
||||
li.onclick = () => openMeeting(m.id);
|
||||
if (m.id === state.currentMeetingId) li.classList.add('active');
|
||||
ul.appendChild(li);
|
||||
}
|
||||
}
|
||||
|
||||
async function openMeeting(id) {
|
||||
if (state.recording) return;
|
||||
const meeting = await (await fetch(`/api/meetings/${id}`)).json();
|
||||
state.currentMeetingId = id;
|
||||
$('transcript-title').textContent = meeting.title;
|
||||
clearTranscript();
|
||||
if (meeting.segments.length === 0) {
|
||||
$('transcript').innerHTML = '<p class="placeholder">Aucun texte pour cette réunion.</p>';
|
||||
} else {
|
||||
meeting.segments.forEach(appendSegment);
|
||||
}
|
||||
showExportBar(id);
|
||||
loadMeetings();
|
||||
}
|
||||
|
||||
async function deleteCurrentMeeting() {
|
||||
if (!state.currentMeetingId || state.recording) return;
|
||||
if (!confirm('Supprimer définitivement cette réunion et sa transcription ?')) return;
|
||||
await fetch(`/api/meetings/${state.currentMeetingId}`, { method: 'DELETE' });
|
||||
state.currentMeetingId = null;
|
||||
$('transcript-title').textContent = 'Transcription';
|
||||
clearTranscript();
|
||||
$('transcript').innerHTML = '<p class="placeholder">Réunion supprimée.</p>';
|
||||
$('export-bar').classList.add('hidden');
|
||||
loadMeetings();
|
||||
}
|
||||
|
||||
async function copyTranscript() {
|
||||
const text = [...document.querySelectorAll('#transcript .segment .text')]
|
||||
.map((el) => el.textContent).join('\n');
|
||||
await navigator.clipboard.writeText(text);
|
||||
$('copy-btn').textContent = '✓ Copié';
|
||||
setTimeout(() => ($('copy-btn').textContent = '📋 Copier'), 1500);
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------- init
|
||||
|
||||
$('record-btn').onclick = () => (state.recording ? stopRecording() : startRecording());
|
||||
$('copy-btn').onclick = copyTranscript;
|
||||
$('delete-btn').onclick = deleteCurrentMeeting;
|
||||
loadMeetings();
|
||||
@@ -0,0 +1,49 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>LiveFlow — Transcription de réunions</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>🎙️ LiveFlow</h1>
|
||||
<span id="status" class="badge idle">Prêt</span>
|
||||
</header>
|
||||
|
||||
<div class="layout">
|
||||
<aside>
|
||||
<h2>Réunions</h2>
|
||||
<ul id="meeting-list"></ul>
|
||||
</aside>
|
||||
|
||||
<main>
|
||||
<section class="controls">
|
||||
<input id="title" type="text" placeholder="Titre de la réunion (optionnel)">
|
||||
<button id="record-btn" class="record">● Démarrer</button>
|
||||
<span id="timer">00:00</span>
|
||||
</section>
|
||||
|
||||
<section id="transcript-panel">
|
||||
<div class="panel-head">
|
||||
<h2 id="transcript-title">Transcription</h2>
|
||||
<div id="export-bar" class="hidden">
|
||||
<button id="copy-btn" title="Copier le texte">📋 Copier</button>
|
||||
<a id="export-txt" download>TXT</a>
|
||||
<a id="export-md" download>MD</a>
|
||||
<a id="export-srt" download>SRT</a>
|
||||
<a id="export-json" download>JSON</a>
|
||||
<button id="delete-btn" title="Supprimer la réunion">🗑️</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="transcript">
|
||||
<p class="placeholder">Appuyez sur « Démarrer » et parlez : le texte apparaît ici au fil de l'eau.</p>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,141 @@
|
||||
* { box-sizing: border-box; margin: 0; }
|
||||
|
||||
:root {
|
||||
--bg: #0f1117;
|
||||
--panel: #181b24;
|
||||
--panel-2: #1f2330;
|
||||
--text: #e8eaf0;
|
||||
--muted: #8b91a3;
|
||||
--accent: #4f7cff;
|
||||
--rec: #e5484d;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 14px 20px;
|
||||
background: var(--panel);
|
||||
border-bottom: 1px solid #262b3a;
|
||||
}
|
||||
|
||||
header h1 { font-size: 1.2rem; }
|
||||
|
||||
.badge {
|
||||
font-size: 0.78rem;
|
||||
padding: 3px 10px;
|
||||
border-radius: 999px;
|
||||
background: var(--panel-2);
|
||||
color: var(--muted);
|
||||
}
|
||||
.badge.rec { background: #3a181a; color: #ff8589; }
|
||||
.badge.busy { background: #2a2410; color: #ffd166; }
|
||||
.badge.error { background: #3a181a; color: #ff8589; }
|
||||
|
||||
.layout { display: flex; flex: 1; min-height: 0; }
|
||||
|
||||
aside {
|
||||
width: 260px;
|
||||
background: var(--panel);
|
||||
border-right: 1px solid #262b3a;
|
||||
padding: 16px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
aside h2 { font-size: 0.85rem; text-transform: uppercase; color: var(--muted); margin-bottom: 10px; }
|
||||
aside ul { list-style: none; padding: 0; }
|
||||
aside li {
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
aside li:hover { background: var(--panel-2); }
|
||||
aside li.active { background: #20294a; }
|
||||
.m-title { font-size: 0.9rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.m-meta { font-size: 0.74rem; color: var(--muted); }
|
||||
|
||||
main { flex: 1; display: flex; flex-direction: column; min-width: 0; padding: 20px; gap: 16px; }
|
||||
|
||||
.controls { display: flex; gap: 12px; align-items: center; }
|
||||
.controls input {
|
||||
flex: 1;
|
||||
background: var(--panel-2);
|
||||
border: 1px solid #2c3245;
|
||||
color: var(--text);
|
||||
padding: 10px 14px;
|
||||
border-radius: 10px;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.controls input:focus { outline: none; border-color: var(--accent); }
|
||||
|
||||
button, .controls a {
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
padding: 10px 18px;
|
||||
font-size: 0.95rem;
|
||||
cursor: pointer;
|
||||
background: var(--panel-2);
|
||||
color: var(--text);
|
||||
}
|
||||
button.record { background: var(--accent); font-weight: 600; min-width: 130px; }
|
||||
button.record.recording { background: var(--rec); }
|
||||
#timer { font-variant-numeric: tabular-nums; color: var(--muted); min-width: 48px; }
|
||||
|
||||
#transcript-panel {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--panel);
|
||||
border: 1px solid #262b3a;
|
||||
border-radius: 14px;
|
||||
min-height: 0;
|
||||
}
|
||||
.panel-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 14px 18px;
|
||||
border-bottom: 1px solid #262b3a;
|
||||
}
|
||||
.panel-head h2 { font-size: 1rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
#export-bar { display: flex; gap: 8px; align-items: center; }
|
||||
#export-bar a, #export-bar button {
|
||||
font-size: 0.8rem;
|
||||
padding: 6px 10px;
|
||||
border-radius: 8px;
|
||||
background: var(--panel-2);
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
}
|
||||
#export-bar.hidden { display: none; }
|
||||
|
||||
#transcript { flex: 1; overflow-y: auto; padding: 18px; }
|
||||
.placeholder { color: var(--muted); font-style: italic; }
|
||||
.segment { display: flex; gap: 12px; margin-bottom: 12px; line-height: 1.55; }
|
||||
.segment .ts {
|
||||
color: var(--muted);
|
||||
font-size: 0.78rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
padding-top: 3px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.layout { flex-direction: column; }
|
||||
aside { width: 100%; max-height: 30vh; border-right: none; border-bottom: 1px solid #262b3a; }
|
||||
.controls { flex-wrap: wrap; }
|
||||
.controls input { width: 100%; flex: none; }
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
// Rééchantillonne le micro (fréquence native du navigateur, ex. 48 kHz)
|
||||
// vers du PCM Int16 mono 16 kHz, attendu par le serveur.
|
||||
class PCMDownsampler extends AudioWorkletProcessor {
|
||||
constructor() {
|
||||
super();
|
||||
this.targetRate = 16000;
|
||||
this.ratio = sampleRate / this.targetRate;
|
||||
this.buffer = new Float32Array(0);
|
||||
this.readPos = 0;
|
||||
}
|
||||
|
||||
process(inputs) {
|
||||
const input = inputs[0] && inputs[0][0];
|
||||
if (!input) return true;
|
||||
|
||||
const buf = new Float32Array(this.buffer.length + input.length);
|
||||
buf.set(this.buffer);
|
||||
buf.set(input, this.buffer.length);
|
||||
|
||||
const outLen = Math.floor((buf.length - 1 - this.readPos) / this.ratio);
|
||||
if (outLen > 0) {
|
||||
const out = new Int16Array(outLen);
|
||||
let pos = this.readPos;
|
||||
for (let i = 0; i < outLen; i++) {
|
||||
const i0 = Math.floor(pos);
|
||||
const frac = pos - i0;
|
||||
const s = buf[i0] * (1 - frac) + buf[i0 + 1] * frac;
|
||||
out[i] = Math.max(-32768, Math.min(32767, Math.round(s * 32767)));
|
||||
pos += this.ratio;
|
||||
}
|
||||
this.readPos = pos;
|
||||
this.port.postMessage(out.buffer, [out.buffer]);
|
||||
}
|
||||
|
||||
const keep = Math.floor(this.readPos);
|
||||
this.buffer = buf.slice(keep);
|
||||
this.readPos -= keep;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
registerProcessor('pcm-downsampler', PCMDownsampler);
|
||||
@@ -0,0 +1,53 @@
|
||||
services:
|
||||
caddy:
|
||||
image: caddy:2-alpine
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
volumes:
|
||||
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
||||
- caddy-data:/data
|
||||
- caddy-config:/config
|
||||
depends_on:
|
||||
- app
|
||||
|
||||
app:
|
||||
build: ./app
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- ASR_BASE_URL=http://asr:8000/v1
|
||||
- ASR_MODEL=Qwen/Qwen3-ASR-1.7B
|
||||
- ASR_API_KEY=${ASR_API_KEY:-sk-local}
|
||||
# Code langue ISO ("fr", "en"...) ou vide pour la détection automatique
|
||||
- ASR_LANGUAGE=${ASR_LANGUAGE:-}
|
||||
- DB_PATH=/data/liveflow.db
|
||||
volumes:
|
||||
- liveflow-data:/data
|
||||
depends_on:
|
||||
- asr
|
||||
|
||||
asr:
|
||||
image: vllm/vllm-openai:latest
|
||||
restart: unless-stopped
|
||||
command: >-
|
||||
--model Qwen/Qwen3-ASR-1.7B
|
||||
--gpu-memory-utilization 0.70
|
||||
environment:
|
||||
- HUGGING_FACE_HUB_TOKEN=${HF_TOKEN:-}
|
||||
volumes:
|
||||
- hf-cache:/root/.cache/huggingface
|
||||
ipc: host
|
||||
deploy:
|
||||
resources:
|
||||
reservations:
|
||||
devices:
|
||||
- driver: nvidia
|
||||
count: 1
|
||||
capabilities: [gpu]
|
||||
|
||||
volumes:
|
||||
caddy-data:
|
||||
caddy-config:
|
||||
liveflow-data:
|
||||
hf-cache:
|
||||
Reference in new issue
Block a user