mirror of
https://github.com/R0m1k3/LiveFlow.git
synced 2026-10-17 20:26:58 +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,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);
|
||||
Reference in new issue
Block a user