mirror of
https://github.com/R0m1k3/LiveFlow.git
synced 2026-10-11 17:27:19 +02:00
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:
4 files changed
+90
-8
No files matched your search
+30
-3
@@ -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
@@ -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();
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user