Adaptation aux micros instables (Bluetooth) sans changer la config du poste

- Sélecteur de micro intégré à l'interface (persisté, deviceId exact)
- Amplification serveur des segments trop faibles (mode mains-libres BT)
- Alerte visible si aucun son n'est capté pendant 5 s
- Niveau crête de chaque segment dans les logs
- Cache-Control: no-cache sur les fichiers statiques (fini le vieux JS en cache)

https://claude.ai/code/session_01YHMp3EKzr4s6o8w1ygxuUe
This commit is contained in:
Claude committed 2026-06-12 13:12:31 +00:00
1 parent b802a3c95f
commit f92e6dd51f
4 files changed
+90 -8

No files matched your search

+30 -3
View File
@@ -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")
+46 -5
View File
@@ -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 = '<option value="">Micro par défaut</option>';
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();
+3
View File
@@ -23,6 +23,9 @@
<main>
<section class="controls">
<input id="title" type="text" placeholder="Titre de la réunion (optionnel)">
<select id="mic-select" title="Choix du micro">
<option value="">Micro par défaut</option>
</select>
<button id="record-btn" class="record">● Démarrer</button>
<span id="timer">00:00</span>
<div id="vu" title="Niveau du micro"><div id="vu-bar"></div></div>
+11
View File
@@ -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;