From 52fda48e5f10170d43282ccdcc797a04ee807ff6 Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Fri, 12 Jun 2026 16:53:24 +0200 Subject: [PATCH] feat: gestion de pause/reprise de reunion et correction du bug de sigles chinois (ASR francais par defaut) --- .env.example | 4 ++-- app/main.py | 11 +++++++-- app/static/app.js | 54 ++++++++++++++++++++++++++++++++++++++++++- app/static/index.html | 1 + app/static/style.css | 12 +++++++++- docker-compose.yml | 2 +- task.md | 6 ++++- 7 files changed, 82 insertions(+), 8 deletions(-) diff --git a/.env.example b/.env.example index da8ccc8..4671fe6 100644 --- a/.env.example +++ b/.env.example @@ -3,8 +3,8 @@ LIVEFLOW_HOST=192.168.1.16 # Code langue ISO forcé pour la transcription ("fr", "en"...). -# Laisser vide pour la détection automatique de la langue. -ASR_LANGUAGE= +# Par défaut : "fr" (français). Mettre "auto" pour la détection automatique. +ASR_LANGUAGE=fr # Clé API transmise au moteur ASR (sans importance en local). ASR_API_KEY=sk-local diff --git a/app/main.py b/app/main.py index b0ba89b..ca641cb 100644 --- a/app/main.py +++ b/app/main.py @@ -18,7 +18,7 @@ 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() +ASR_LANGUAGE = os.environ.get("ASR_LANGUAGE", "").strip() or "fr" DIARIZATION = os.environ.get("DIARIZATION", "off").strip().lower() == "on" DIARIZATION_THRESHOLD = float(os.environ.get("DIARIZATION_THRESHOLD", "0.70")) DIARIZATION_MAX_SPEAKERS = int(os.environ.get("DIARIZATION_MAX_SPEAKERS", "8")) @@ -197,7 +197,8 @@ async def ws_transcribe(ws: WebSocket): queue.put_nowait(seg) elif msg.get("text"): control = json.loads(msg["text"]) - if control.get("type") == "stop": + cmd = control.get("type") + if cmd == "stop": if (last := segmenter.flush()) is not None: queue.put_nowait(last) queue.put_nowait(None) @@ -205,6 +206,12 @@ async def ws_transcribe(ws: WebSocket): worker_task = None await ws.send_json({"type": "done", "meeting_id": meeting_id}) break + elif cmd == "pause": + # Mettre en pause : forcer la finalisation du segment audio en cours + if (last := segmenter.flush()) is not None: + queue.put_nowait(last) + elif cmd == "resume": + pass except WebSocketDisconnect: pass finally: diff --git a/app/static/app.js b/app/static/app.js index b9d47fd..bab32fb 100644 --- a/app/static/app.js +++ b/app/static/app.js @@ -35,6 +35,7 @@ if (window.isSecureContext === false) { const state = { recording: false, + paused: false, ws: null, audioContext: null, workletNode: null, @@ -43,6 +44,8 @@ const state = { sendBufferSamples: 0, timerInterval: null, startedAt: null, + elapsedMs: 0, + lastTimerUpdate: null, currentMeetingId: null, }; @@ -141,12 +144,21 @@ async function startRecording() { // 4. Mettre à jour l'interface state.recording = true; + state.paused = false; state.startedAt = Date.now(); + state.elapsedMs = 0; + state.lastTimerUpdate = Date.now(); state.timerInterval = setInterval(updateTimer, 500); + if ($('record-btn')) { $('record-btn').textContent = '■ Arrêter'; $('record-btn').classList.add('recording'); } + if ($('pause-btn')) { + $('pause-btn').classList.remove('hidden'); + $('pause-btn').classList.remove('paused'); + $('pause-btn').textContent = '⏸ Pause'; + } if ($('title')) $('title').disabled = true; setStatus('rec', 'Enregistrement…'); clearTranscript(); @@ -208,6 +220,10 @@ function stopRecording(abrupt = false) { $('record-btn').textContent = '● Démarrer'; $('record-btn').classList.remove('recording'); } + if ($('pause-btn')) { + $('pause-btn').classList.add('hidden'); + } + state.paused = false; if ($('title')) $('title').disabled = false; const bar = $('volume-bar'); @@ -232,6 +248,7 @@ function updateVolumeIndicator(samples) { function queuePcm(samples) { if (!state.recording || !state.ws || state.ws.readyState !== WebSocket.OPEN) return; + if (state.paused) return; updateVolumeIndicator(samples); state.sendBuffer.push(samples); state.sendBufferSamples += samples.length; @@ -277,7 +294,11 @@ function setStatus(cls, text) { } function updateTimer() { - const s = Math.floor((Date.now() - state.startedAt) / 1000); + if (state.paused) return; + const now = Date.now(); + state.elapsedMs += now - state.lastTimerUpdate; + state.lastTimerUpdate = now; + const s = Math.floor(state.elapsedMs / 1000); $('timer').textContent = String(Math.floor(s / 60)).padStart(2, '0') + ':' + String(s % 60).padStart(2, '0'); } @@ -372,9 +393,40 @@ async function copyTranscript() { setTimeout(() => ($('copy-btn').textContent = '📋 Copier'), 1500); } +function togglePause() { + if (!state.recording || !state.ws || state.ws.readyState !== WebSocket.OPEN) return; + + const btn = $('pause-btn'); + if (!state.paused) { + // Mettre en pause + state.paused = true; + state.elapsedMs += Date.now() - state.lastTimerUpdate; + try { state.ws.send(JSON.stringify({ type: 'pause' })); } catch (e) {} + setStatus('busy', 'Pause'); // Indicateur visuel "Pause" + if (btn) { + btn.textContent = '▶ Reprendre'; + btn.classList.add('paused'); + } + // Remettre la barre de volume à 0% pendant la pause + const bar = $('volume-bar'); + if (bar) bar.style.width = '0%'; + } else { + // Reprendre + state.paused = false; + state.lastTimerUpdate = Date.now(); + try { state.ws.send(JSON.stringify({ type: 'resume' })); } catch (e) {} + setStatus('rec', 'Enregistrement…'); + if (btn) { + btn.textContent = '⏸ Pause'; + btn.classList.remove('paused'); + } + } +} + // --------------------------------------------------------------------- init $('record-btn').onclick = () => (state.recording ? stopRecording() : startRecording()); +if ($('pause-btn')) $('pause-btn').onclick = togglePause; $('copy-btn').onclick = copyTranscript; $('delete-btn').onclick = deleteCurrentMeeting; loadMeetings(); diff --git a/app/static/index.html b/app/static/index.html index aa9232a..1f17c63 100644 --- a/app/static/index.html +++ b/app/static/index.html @@ -26,6 +26,7 @@ + 00:00
diff --git a/app/static/style.css b/app/static/style.css index c827243..be1512d 100644 --- a/app/static/style.css +++ b/app/static/style.css @@ -206,7 +206,17 @@ button.record.recording { background: var(--rec); } border-radius: 2px; transition: width 0.1s ease; } - +/* Bouton Pause */ +button.pause { + background: #2a2410; + color: #ffd166; + font-weight: 600; + min-width: 110px; +} +button.pause.paused { + background: var(--accent); + color: var(--text); +} @media (max-width: 720px) { diff --git a/docker-compose.yml b/docker-compose.yml index 1f43df3..4677cd1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,7 +11,7 @@ services: - 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:-} + - ASR_LANGUAGE=${ASR_LANGUAGE:-fr} - DB_PATH=/data/liveflow.db # Diarisation des locuteurs (off par défaut pour rétrocompatibilité) - DIARIZATION=${DIARIZATION:-off} diff --git a/task.md b/task.md index 3cc29a4..92104b6 100644 --- a/task.md +++ b/task.md @@ -22,7 +22,11 @@ Vérification de la cohérence de tous les fichiers (backend et frontend) et tes - [x] Rendre configurable le nombre maximal de locuteurs (DIARIZATION_MAX_SPEAKERS, défaut 8) dans main.py, .env.example et docker-compose.yml - [x] Créer l'afficheur de volume micro en temps réel (visualiseur audio) dans index.html, style.css et app.js - [x] Mettre en place la capture et l'affichage des erreurs JS non gérées à l'écran pour faciliter le diagnostic - +- [x] Modifier `app/static/index.html` pour intégrer le bouton de pause +- [x] Modifier `app/static/style.css` pour styliser le bouton de pause +- [x] Modifier `app/static/app.js` pour gérer l'état de pause (timer, flux audio et boutons) +- [x] Modifier `app/main.py` pour traiter les commandes WebSocket `pause` et `resume` (flush VAD) +- [x] Résoudre le problème d'apparition des sigles chinois en forçant le français (ASR_LANGUAGE=fr) par défaut dans le code, le docker-compose et l'environnement. ## Progress Log - Création du module `diarizer.py` pour l'extraction des embeddings et le clustering.