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 @@