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