diff --git a/app/main.py b/app/main.py index 0c0a978..74c5915 100644 --- a/app/main.py +++ b/app/main.py @@ -1,4 +1,5 @@ import asyncio +import audioop import hashlib import hmac import io @@ -153,6 +154,21 @@ def pcm_to_wav(pcm: bytes) -> bytes: return buf.getvalue() +QUIET_PEAK = 9000 # niveau crête en dessous duquel on amplifie (échelle int16) + + +def boost_quiet_audio(pcm: bytes) -> tuple[bytes, int]: + """Amplifie les segments trop faibles (micros Bluetooth mains-libres...). + + Retourne (audio, niveau crête d'origine). + """ + peak = audioop.max(pcm, 2) + if 0 < peak < QUIET_PEAK: + factor = min(6.0, 26000 / peak) + return audioop.mul(pcm, 2, factor), peak + return pcm, peak + + async def _post_transcription(data: dict, wav: bytes) -> httpx.Response: return await http.post( f"{ASR_BASE_URL}/audio/transcriptions", @@ -215,10 +231,12 @@ async def ws_transcribe(ws: WebSocket): seg = await queue.get() if seg is None: return + pcm, peak = boost_quiet_audio(seg.pcm) print(f"[réunion {meeting_id}] segment {seg.t0:.1f}s → {seg.t1:.1f}s " - f"({len(seg.pcm) / 2 / SAMPLE_RATE:.1f}s d'audio), transcription...", flush=True) + f"({len(seg.pcm) / 2 / SAMPLE_RATE:.1f}s d'audio, niveau crête {peak}" + f"{', amplifié' if pcm is not seg.pcm else ''}), transcription...", flush=True) try: - text = await transcribe(seg.pcm) + text = await transcribe(pcm) except Exception as exc: print(f"[réunion {meeting_id}] ERREUR ASR : {exc}", flush=True) await ws.send_json({"type": "error", "message": f"Transcription échouée : {exc}"}) @@ -350,4 +368,13 @@ async def export_meeting(meeting_id: int, format: str = "txt"): ) -app.mount("/", StaticFiles(directory="static", html=True), name="static") +class NoCacheStaticFiles(StaticFiles): + """Force la revalidation des fichiers statiques après chaque mise à jour.""" + + async def get_response(self, path, scope): + resp = await super().get_response(path, scope) + resp.headers["Cache-Control"] = "no-cache" + return resp + + +app.mount("/", NoCacheStaticFiles(directory="static", html=True), name="static") diff --git a/app/static/app.js b/app/static/app.js index c40b95e..461001d 100644 --- a/app/static/app.js +++ b/app/static/app.js @@ -11,6 +11,8 @@ const state = { timerInterval: null, startedAt: null, currentMeetingId: null, + lastLoudAt: 0, + silentWarned: false, }; const BATCH_SAMPLES = 4096; // ~256 ms de PCM 16 kHz par message WebSocket @@ -25,16 +27,36 @@ async function api(url, opts) { return resp; } +// -------------------------------------------------------------------- micro + +async function listMics() { + try { + const devices = await navigator.mediaDevices.enumerateDevices(); + const mics = devices.filter((d) => d.kind === 'audioinput' && d.deviceId); + const sel = $('mic-select'); + const saved = localStorage.getItem('liveflow-mic') || ''; + sel.innerHTML = ''; + for (const m of mics) { + const opt = document.createElement('option'); + opt.value = m.deviceId; + opt.textContent = m.label || `Micro ${sel.length}`; + if (m.deviceId === saved) opt.selected = true; + sel.appendChild(opt); + } + } catch (e) { /* énumération indisponible : on garde "Micro par défaut" */ } +} + // ----------------------------------------------------------- enregistrement async function startRecording() { + const constraints = { echoCancellation: true, noiseSuppression: true, autoGainControl: true }; + const micId = localStorage.getItem('liveflow-mic'); + if (micId) constraints.deviceId = { exact: micId }; try { - state.mediaStream = await navigator.mediaDevices.getUserMedia({ - audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true }, - }); + state.mediaStream = await navigator.mediaDevices.getUserMedia({ audio: constraints }); } catch (firstErr) { - // Certains pilotes échouent avec les options de traitement audio : - // on retente avec la contrainte minimale avant d'abandonner. + // Micro choisi indisponible ou pilote qui refuse les options de + // traitement audio : on retente avec la contrainte minimale. try { state.mediaStream = await navigator.mediaDevices.getUserMedia({ audio: true }); } catch (err) { @@ -65,8 +87,13 @@ async function startRecording() { state.workletNode.port.onmessage = (e) => queuePcm(new Int16Array(e.data)); source.connect(state.workletNode); + // les libellés des micros ne sont disponibles qu'une fois la permission accordée + listMics(); + state.recording = true; state.startedAt = Date.now(); + state.lastLoudAt = Date.now(); + state.silentWarned = false; state.timerInterval = setInterval(updateTimer, 500); $('record-btn').textContent = '■ Arrêter'; $('record-btn').classList.add('recording'); @@ -90,6 +117,7 @@ function updateVu(samples) { if (v > peak) peak = v; } $('vu-bar').style.width = Math.min(100, (peak / 32768) * 140) + '%'; + if (peak > 1500) state.lastLoudAt = Date.now(); } function flushPcm() { @@ -155,6 +183,17 @@ 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'); + + // alerte si le micro ne capte plus rien depuis 5 s + if (!state.recording) return; + const silent = Date.now() - state.lastLoudAt > 5000; + if (silent && !state.silentWarned) { + state.silentWarned = true; + setStatus('error', 'Aucun son capté — changez de micro dans la liste'); + } else if (!silent && state.silentWarned) { + state.silentWarned = false; + setStatus('rec', 'Enregistrement…'); + } } function fmtTs(seconds) { @@ -240,6 +279,8 @@ async function copyTranscript() { $('record-btn').onclick = () => (state.recording ? stopRecording() : startRecording()); $('logout-btn').onclick = async () => { await fetch('/api/logout', { method: 'POST' }); location.href = '/login'; }; +$('mic-select').onchange = () => localStorage.setItem('liveflow-mic', $('mic-select').value); +listMics(); $('copy-btn').onclick = copyTranscript; $('delete-btn').onclick = deleteCurrentMeeting; loadMeetings(); diff --git a/app/static/index.html b/app/static/index.html index 5f04228..c7462ee 100644 --- a/app/static/index.html +++ b/app/static/index.html @@ -23,6 +23,9 @@
+ 00:00
diff --git a/app/static/style.css b/app/static/style.css index ec4dcae..0b4774a 100644 --- a/app/static/style.css +++ b/app/static/style.css @@ -87,6 +87,17 @@ main { flex: 1; display: flex; flex-direction: column; min-width: 0; padding: 20 } .controls input:focus { outline: none; border-color: var(--accent); } +.controls select { + background: var(--panel-2); + border: 1px solid #2c3245; + color: var(--text); + padding: 10px 12px; + border-radius: 10px; + font-size: 0.88rem; + max-width: 200px; +} +.controls select:focus { outline: none; border-color: var(--accent); } + button, .controls a { border: none; border-radius: 10px;