mirror of
https://github.com/R0m1k3/LiveFlow.git
synced 2026-10-17 12:16:44 +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 asyncio
|
||||||
|
import audioop
|
||||||
import hashlib
|
import hashlib
|
||||||
import hmac
|
import hmac
|
||||||
import io
|
import io
|
||||||
@@ -153,6 +154,21 @@ def pcm_to_wav(pcm: bytes) -> bytes:
|
|||||||
return buf.getvalue()
|
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:
|
async def _post_transcription(data: dict, wav: bytes) -> httpx.Response:
|
||||||
return await http.post(
|
return await http.post(
|
||||||
f"{ASR_BASE_URL}/audio/transcriptions",
|
f"{ASR_BASE_URL}/audio/transcriptions",
|
||||||
@@ -215,10 +231,12 @@ async def ws_transcribe(ws: WebSocket):
|
|||||||
seg = await queue.get()
|
seg = await queue.get()
|
||||||
if seg is None:
|
if seg is None:
|
||||||
return
|
return
|
||||||
|
pcm, peak = boost_quiet_audio(seg.pcm)
|
||||||
print(f"[réunion {meeting_id}] segment {seg.t0:.1f}s → {seg.t1:.1f}s "
|
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:
|
try:
|
||||||
text = await transcribe(seg.pcm)
|
text = await transcribe(pcm)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
print(f"[réunion {meeting_id}] ERREUR ASR : {exc}", flush=True)
|
print(f"[réunion {meeting_id}] ERREUR ASR : {exc}", flush=True)
|
||||||
await ws.send_json({"type": "error", "message": f"Transcription échouée : {exc}"})
|
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,
|
timerInterval: null,
|
||||||
startedAt: null,
|
startedAt: null,
|
||||||
currentMeetingId: null,
|
currentMeetingId: null,
|
||||||
|
lastLoudAt: 0,
|
||||||
|
silentWarned: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
const BATCH_SAMPLES = 4096; // ~256 ms de PCM 16 kHz par message WebSocket
|
const BATCH_SAMPLES = 4096; // ~256 ms de PCM 16 kHz par message WebSocket
|
||||||
@@ -25,16 +27,36 @@ async function api(url, opts) {
|
|||||||
return resp;
|
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
|
// ----------------------------------------------------------- enregistrement
|
||||||
|
|
||||||
async function startRecording() {
|
async function startRecording() {
|
||||||
|
const constraints = { echoCancellation: true, noiseSuppression: true, autoGainControl: true };
|
||||||
|
const micId = localStorage.getItem('liveflow-mic');
|
||||||
|
if (micId) constraints.deviceId = { exact: micId };
|
||||||
try {
|
try {
|
||||||
state.mediaStream = await navigator.mediaDevices.getUserMedia({
|
state.mediaStream = await navigator.mediaDevices.getUserMedia({ audio: constraints });
|
||||||
audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true },
|
|
||||||
});
|
|
||||||
} catch (firstErr) {
|
} catch (firstErr) {
|
||||||
// Certains pilotes échouent avec les options de traitement audio :
|
// Micro choisi indisponible ou pilote qui refuse les options de
|
||||||
// on retente avec la contrainte minimale avant d'abandonner.
|
// traitement audio : on retente avec la contrainte minimale.
|
||||||
try {
|
try {
|
||||||
state.mediaStream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
state.mediaStream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -65,8 +87,13 @@ async function startRecording() {
|
|||||||
state.workletNode.port.onmessage = (e) => queuePcm(new Int16Array(e.data));
|
state.workletNode.port.onmessage = (e) => queuePcm(new Int16Array(e.data));
|
||||||
source.connect(state.workletNode);
|
source.connect(state.workletNode);
|
||||||
|
|
||||||
|
// les libellés des micros ne sont disponibles qu'une fois la permission accordée
|
||||||
|
listMics();
|
||||||
|
|
||||||
state.recording = true;
|
state.recording = true;
|
||||||
state.startedAt = Date.now();
|
state.startedAt = Date.now();
|
||||||
|
state.lastLoudAt = Date.now();
|
||||||
|
state.silentWarned = false;
|
||||||
state.timerInterval = setInterval(updateTimer, 500);
|
state.timerInterval = setInterval(updateTimer, 500);
|
||||||
$('record-btn').textContent = '■ Arrêter';
|
$('record-btn').textContent = '■ Arrêter';
|
||||||
$('record-btn').classList.add('recording');
|
$('record-btn').classList.add('recording');
|
||||||
@@ -90,6 +117,7 @@ function updateVu(samples) {
|
|||||||
if (v > peak) peak = v;
|
if (v > peak) peak = v;
|
||||||
}
|
}
|
||||||
$('vu-bar').style.width = Math.min(100, (peak / 32768) * 140) + '%';
|
$('vu-bar').style.width = Math.min(100, (peak / 32768) * 140) + '%';
|
||||||
|
if (peak > 1500) state.lastLoudAt = Date.now();
|
||||||
}
|
}
|
||||||
|
|
||||||
function flushPcm() {
|
function flushPcm() {
|
||||||
@@ -155,6 +183,17 @@ function updateTimer() {
|
|||||||
const s = Math.floor((Date.now() - state.startedAt) / 1000);
|
const s = Math.floor((Date.now() - state.startedAt) / 1000);
|
||||||
$('timer').textContent =
|
$('timer').textContent =
|
||||||
String(Math.floor(s / 60)).padStart(2, '0') + ':' + String(s % 60).padStart(2, '0');
|
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) {
|
function fmtTs(seconds) {
|
||||||
@@ -240,6 +279,8 @@ async function copyTranscript() {
|
|||||||
|
|
||||||
$('record-btn').onclick = () => (state.recording ? stopRecording() : startRecording());
|
$('record-btn').onclick = () => (state.recording ? stopRecording() : startRecording());
|
||||||
$('logout-btn').onclick = async () => { await fetch('/api/logout', { method: 'POST' }); location.href = '/login'; };
|
$('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;
|
$('copy-btn').onclick = copyTranscript;
|
||||||
$('delete-btn').onclick = deleteCurrentMeeting;
|
$('delete-btn').onclick = deleteCurrentMeeting;
|
||||||
loadMeetings();
|
loadMeetings();
|
||||||
@@ -23,6 +23,9 @@
|
|||||||
<main>
|
<main>
|
||||||
<section class="controls">
|
<section class="controls">
|
||||||
<input id="title" type="text" placeholder="Titre de la réunion (optionnel)">
|
<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>
|
<button id="record-btn" class="record">● Démarrer</button>
|
||||||
<span id="timer">00:00</span>
|
<span id="timer">00:00</span>
|
||||||
<div id="vu" title="Niveau du micro"><div id="vu-bar"></div></div>
|
<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 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 {
|
button, .controls a {
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
|
|||||||
Reference in new issue
Block a user