Micro : amplification serveur avant le VAD + diarisation stabilisée

Captation micro :
- Plus AUCUN test/bascule de micro automatique (choix manuel uniquement)
- Amplification adaptative (AGC) déplacée dans le segmenteur, AVANT la
  détection de parole : un micro faible (casque) était sous le seuil du VAD,
  aucune phrase n'était détectée. Validé : signal crête 1473 -> 16600, capté.
- Le client envoie l'audio brut (plus de gain navigateur qui se battait avec) ;
  vumètre affiché amplifié pour rester lisible

Diarisation (locuteurs éclatés en 3, 4, 5... pour une même personne) :
- Seuil abaissé (0.70 -> 0.35) + zone morte large : un nouveau locuteur n'est
  créé que si le score est nettement bas ET le segment assez long (>=1.2s)
- Centroïde plus stable (EMA alpha 0.15) + adhérence au dernier locuteur
- Validé : 1 personne reste 1 locuteur même très bruitée ; 2-3 voix séparées

https://claude.ai/code/session_01YHMp3EKzr4s6o8w1ygxuUe
This commit is contained in:
Claude committed 2026-06-13 09:30:14 +00:00
1 parent 8515315e0b
commit 0fa8455f8f
4 files changed
+83 -59

No files matched your search

+42 -20
View File
@@ -25,10 +25,14 @@ class _SpeakerProfile:
label: str label: str
embedding: np.ndarray # centroïde courant (moyenne mobile) embedding: np.ndarray # centroïde courant (moyenne mobile)
count: int = 0 # nombre d'observations count: int = 0 # nombre d'observations
_ema_alpha: float = field(default=0.3, repr=False) _ema_alpha: float = field(default=0.15, repr=False)
def update(self, new_emb: np.ndarray) -> None: def update(self, new_emb: np.ndarray) -> None:
"""Met à jour le centroïde via une moyenne mobile exponentielle.""" """Met à jour le centroïde via une moyenne mobile exponentielle.
Alpha faible : le centroïde reste stable et ne dérive pas vers les
variations d'un segment à l'autre (qui scinderaient un même locuteur).
"""
self.count += 1 self.count += 1
if self.count == 1: if self.count == 1:
self.embedding = new_emb.copy() self.embedding = new_emb.copy()
@@ -106,59 +110,77 @@ class SpeakerDiarizer:
profils de locuteurs. Le modèle d'embedding est partagé (EmbeddingModel). profils de locuteurs. Le modèle d'embedding est partagé (EmbeddingModel).
""" """
# Un nouveau locuteur n'est créé que si le meilleur score est NETTEMENT
# sous le seuil (zone morte) ET que le segment est assez long pour donner
# une empreinte fiable. Sinon le segment est rattaché au meilleur profil.
# Cela évite qu'un même locuteur soit éclaté en Locuteur 3, 4, 5...
NEW_SPEAKER_MARGIN = 0.20 # écart sous le seuil pour autoriser un nouveau
NEW_SPEAKER_MIN_S = 1.2 # durée mini d'un segment pour créer un locuteur
STICKY_MARGIN = 0.05 # adhérence au dernier locuteur si scores proches
def __init__( def __init__(
self, self,
model: EmbeddingModel, model: EmbeddingModel,
threshold: float = 0.70, threshold: float = 0.35,
max_speakers: int = 8, max_speakers: int = 8,
) -> None: ) -> None:
self._model = model self._model = model
self._threshold = threshold self._threshold = threshold
self._max_speakers = max_speakers self._max_speakers = max_speakers
self._profiles: list[_SpeakerProfile] = [] self._profiles: list[_SpeakerProfile] = []
self._last: _SpeakerProfile | None = None
def identify(self, pcm: bytes, sample_rate: int = 16000) -> str: def identify(self, pcm: bytes, sample_rate: int = 16000) -> str:
"""Identifie le locuteur d'un segment PCM. """Identifie le locuteur d'un segment PCM.
Returns: Returns:
Label du locuteur ("Locuteur 1", "Locuteur 2", etc.) Label du locuteur ("Locuteur 1", "Locuteur 2"...) ou chaîne vide
ou chaîne vide si le segment est trop court pour être analysé. si le segment est trop court pour être analysé.
""" """
try: try:
embedding = self._model.extract(pcm, sample_rate) embedding = self._model.extract(pcm, sample_rate)
except ValueError: except ValueError:
# Segment trop court — on ne peut pas identifier le locuteur
return "" return ""
# Comparer avec les profils existants duration_s = (len(pcm) // 2) / sample_rate
best_score = -1.0
best_profile: _SpeakerProfile | None = None
for profile in self._profiles: # Score de chaque profil existant
score = _cosine_similarity(embedding, profile.embedding) scores = [(_cosine_similarity(embedding, p.embedding), p) for p in self._profiles]
if score > best_score: best_score, best_profile = max(scores, default=(-1.0, None), key=lambda x: x[0])
best_score = score
best_profile = profile
# Adhérence : si le dernier locuteur est presque aussi proche que le
# meilleur, on le garde (réduit le clignotement entre deux profils).
if self._last is not None and best_profile is not self._last:
last_score = next((s for s, p in scores if p is self._last), -1.0)
if last_score >= best_score - self.STICKY_MARGIN:
best_score, best_profile = last_score, self._last
# Rattachement à un locuteur connu
if best_profile is not None and best_score >= self._threshold: if best_profile is not None and best_score >= self._threshold:
best_profile.update(embedding) best_profile.update(embedding)
self._last = best_profile
return best_profile.label return best_profile.label
# Nouveau locuteur (sauf si on a atteint le max) # Création d'un nouveau locuteur : conditions strictes
if len(self._profiles) >= self._max_speakers: can_create = (
# Forcer l'attribution au profil le plus proche len(self._profiles) < self._max_speakers
if best_profile is not None: and duration_s >= self.NEW_SPEAKER_MIN_S
and best_score < self._threshold - self.NEW_SPEAKER_MARGIN
)
if not can_create and best_profile is not None:
# Zone ambiguë ou segment court : on rattache au plus proche
best_profile.update(embedding) best_profile.update(embedding)
self._last = best_profile
return best_profile.label return best_profile.label
# Cas dégénéré : aucun profil et max atteint (ne devrait pas arriver)
return "Locuteur 1"
new_label = f"Locuteur {len(self._profiles) + 1}" new_label = f"Locuteur {len(self._profiles) + 1}"
new_profile = _SpeakerProfile(label=new_label, embedding=embedding) new_profile = _SpeakerProfile(label=new_label, embedding=embedding)
new_profile.update(embedding) new_profile.update(embedding)
self._profiles.append(new_profile) self._profiles.append(new_profile)
self._last = new_profile
return new_label return new_label
def reset(self) -> None: def reset(self) -> None:
"""Réinitialise les profils pour une nouvelle session.""" """Réinitialise les profils pour une nouvelle session."""
self._profiles.clear() self._profiles.clear()
self._last = None
+1 -1
View File
@@ -44,7 +44,7 @@ def clean_transcript(text: str) -> str:
# Diarisation (identification des locuteurs) : embeddings ECAPA-TDNN sur CPU # Diarisation (identification des locuteurs) : embeddings ECAPA-TDNN sur CPU
DIARIZATION = os.environ.get("DIARIZATION", "off").strip().lower() == "on" DIARIZATION = os.environ.get("DIARIZATION", "off").strip().lower() == "on"
DIARIZATION_THRESHOLD = float(os.environ.get("DIARIZATION_THRESHOLD", "0.70")) DIARIZATION_THRESHOLD = float(os.environ.get("DIARIZATION_THRESHOLD", "0.35"))
DIARIZATION_MAX_SPEAKERS = int(os.environ.get("DIARIZATION_MAX_SPEAKERS", "8")) DIARIZATION_MAX_SPEAKERS = int(os.environ.get("DIARIZATION_MAX_SPEAKERS", "8"))
LIVEFLOW_USER = os.environ.get("LIVEFLOW_USER", "admin") LIVEFLOW_USER = os.environ.get("LIVEFLOW_USER", "admin")
+32
View File
@@ -9,6 +9,8 @@ silence prolongé ou une durée maximale.
from collections import deque from collections import deque
from dataclasses import dataclass from dataclasses import dataclass
import audioop
import webrtcvad import webrtcvad
SAMPLE_RATE = 16000 SAMPLE_RATE = 16000
@@ -22,6 +24,15 @@ SILENCE_END_MS = 700 # silence qui clôt un segment
MIN_SPEECH_MS = 300 # en dessous, le segment est ignoré (bruit) MIN_SPEECH_MS = 300 # en dessous, le segment est ignoré (bruit)
MAX_SEGMENT_S = 25 # coupe forcée pour garder une latence raisonnable MAX_SEGMENT_S = 25 # coupe forcée pour garder une latence raisonnable
# Contrôle de gain automatique appliqué AVANT la détection de parole : sans lui,
# un micro faible (casque...) reste sous le seuil du VAD et aucune phrase n'est
# détectée. On amène le niveau crête vers une cible exploitable.
AGC_TARGET_PEAK = 11000 # niveau crête visé (échelle int16)
AGC_NOISE_FLOOR = 120 # en dessous : du silence, on n'amplifie pas
AGC_MAX_GAIN = 60.0 # amplification maximale
AGC_ATTACK = 0.5 # vitesse de montée du gain (0-1)
AGC_RELEASE = 0.2 # vitesse de descente du gain (0-1)
@dataclass @dataclass
class Segment: class Segment:
@@ -30,9 +41,29 @@ class Segment:
t1: float t1: float
class _AutoGain:
"""Amplification adaptative du flux pour fiabiliser la détection de parole."""
def __init__(self):
self.gain = 1.0
def process(self, frame: bytes) -> bytes:
peak = audioop.max(frame, 2)
if peak >= AGC_NOISE_FLOOR:
desired = min(AGC_MAX_GAIN, AGC_TARGET_PEAK / peak)
rate = AGC_ATTACK if desired > self.gain else AGC_RELEASE
self.gain += (desired - self.gain) * rate
self.gain = max(1.0, min(AGC_MAX_GAIN, self.gain))
if self.gain > 1.01:
return audioop.mul(frame, 2, self.gain)
return frame
class SpeechSegmenter: class SpeechSegmenter:
def __init__(self): def __init__(self):
self._vad = webrtcvad.Vad(VAD_AGGRESSIVENESS) self._vad = webrtcvad.Vad(VAD_AGGRESSIVENESS)
self._agc = _AutoGain()
self._pending = bytearray() self._pending = bytearray()
self._ring: deque[tuple[bytes, bool]] = deque(maxlen=PREROLL_FRAMES) self._ring: deque[tuple[bytes, bool]] = deque(maxlen=PREROLL_FRAMES)
self._frame_index = 0 self._frame_index = 0
@@ -49,6 +80,7 @@ class SpeechSegmenter:
while len(self._pending) >= FRAME_BYTES: while len(self._pending) >= FRAME_BYTES:
frame = bytes(self._pending[:FRAME_BYTES]) frame = bytes(self._pending[:FRAME_BYTES])
del self._pending[:FRAME_BYTES] del self._pending[:FRAME_BYTES]
frame = self._agc.process(frame) # amplifie avant le VAD
seg = self._process_frame(frame) seg = self._process_frame(frame)
if seg is not None: if seg is not None:
segments.append(seg) segments.append(seg)
+8 -38
View File
@@ -15,8 +15,6 @@ const state = {
heardSound: false, heardSound: false,
silentWarned: false, silentWarned: false,
sourceNode: null, sourceNode: null,
gainNode: null,
lastGainAt: 0,
paused: false, paused: false,
}; };
@@ -78,8 +76,7 @@ async function switchMic(deviceId) {
if (state.mediaStream) state.mediaStream.getTracks().forEach((t) => t.stop()); if (state.mediaStream) state.mediaStream.getTracks().forEach((t) => t.stop());
state.mediaStream = stream; state.mediaStream = stream;
state.sourceNode = state.audioContext.createMediaStreamSource(stream); state.sourceNode = state.audioContext.createMediaStreamSource(stream);
state.sourceNode.connect(state.gainNode); state.sourceNode.connect(state.workletNode);
state.gainNode.gain.value = savedGain();
state.lastLoudAt = Date.now(); state.lastLoudAt = Date.now();
return true; return true;
} }
@@ -125,12 +122,10 @@ async function startRecording() {
await state.audioContext.audioWorklet.addModule('worklet.js'); await state.audioContext.audioWorklet.addModule('worklet.js');
state.workletNode = new AudioWorkletNode(state.audioContext, 'pcm-downsampler'); state.workletNode = new AudioWorkletNode(state.audioContext, 'pcm-downsampler');
state.workletNode.port.onmessage = (e) => queuePcm(new Int16Array(e.data)); state.workletNode.port.onmessage = (e) => queuePcm(new Int16Array(e.data));
// gain automatique : compense les micros trop faibles (casques...) // L'amplification des micros faibles se fait côté serveur (AGC avant le
state.gainNode = state.audioContext.createGain(); // VAD) : le client envoie l'audio brut, sans gain qui se battrait avec.
state.gainNode.gain.value = savedGain(); // repart du gain mémorisé
state.gainNode.connect(state.workletNode);
state.sourceNode = state.audioContext.createMediaStreamSource(state.mediaStream); state.sourceNode = state.audioContext.createMediaStreamSource(state.mediaStream);
state.sourceNode.connect(state.gainNode); state.sourceNode.connect(state.workletNode);
// les libellés des micros ne sont disponibles qu'une fois la permission accordée // les libellés des micros ne sont disponibles qu'une fois la permission accordée
listMics(); listMics();
@@ -169,38 +164,13 @@ function updateVu(samples) {
const v = Math.abs(samples[i]); const v = Math.abs(samples[i]);
if (v > peak) peak = v; if (v > peak) peak = v;
} }
$('vu-bar').style.width = Math.min(100, (peak / 32768) * 140) + '%'; // Affichage amplifié (x8) : le serveur amplifie le signal réel, le vumètre
if (peak > 1500) { // reflète l'activité même avec un micro faible.
$('vu-bar').style.width = Math.min(100, (peak / 32768) * 140 * 8) + '%';
if (peak > 150) {
state.lastLoudAt = Date.now(); state.lastLoudAt = Date.now();
state.heardSound = true; state.heardSound = true;
} }
autoGain(peak);
}
// Gain automatique : monte progressivement le volume des micros faibles
// jusqu'à un niveau exploitable par la détection de parole, redescend en cas
// de saturation, et mémorise le gain trouvé pour les prochaines réunions.
function savedGain() {
const g = parseFloat(localStorage.getItem('liveflow-gain'));
return Number.isFinite(g) ? Math.min(80, Math.max(1, g)) : 1;
}
function autoGain(peak) {
if (!state.gainNode || state.paused) return;
const now = Date.now();
if (now - state.lastGainAt < 250) return;
state.lastGainAt = now;
const g = state.gainNode.gain.value;
let next = g;
if (peak > 27000) {
next = Math.max(1, g * 0.7); // proche saturation
} else if (peak > 60 && peak < 12000) {
next = Math.min(80, g * 1.3); // signal trop faible
}
if (next !== g) {
state.gainNode.gain.value = next;
localStorage.setItem('liveflow-gain', String(next));
}
} }
function flushPcm() { function flushPcm() {