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:
Claude committed 2026-06-11 16:28:17 +00:00
1 parent 4fa5964988
commit a8517c3597
13 files changed
+964

No files matched your search

+11
View File
@@ -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
View File
@@ -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")
+5
View File
@@ -0,0 +1,5 @@
fastapi~=0.115
uvicorn[standard]~=0.34
httpx~=0.28
aiosqlite~=0.21
webrtcvad-wheels~=2.0
+100
View File
@@ -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)
+208
View File
@@ -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();
+49
View File
@@ -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>
+141
View File
@@ -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; }
}
+42
View File
@@ -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);