mirror of
https://github.com/R0m1k3/LiveFlow.git
synced 2026-10-11 17:27:19 +02:00
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:
4 files changed
+85
-61
No files matched your search
+44
-22
@@ -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
|
||||||
best_profile.update(embedding)
|
and best_score < self._threshold - self.NEW_SPEAKER_MARGIN
|
||||||
return best_profile.label
|
)
|
||||||
# Cas dégénéré : aucun profil et max atteint (ne devrait pas arriver)
|
if not can_create and best_profile is not None:
|
||||||
return "Locuteur 1"
|
# Zone ambiguë ou segment court : on rattache au plus proche
|
||||||
|
best_profile.update(embedding)
|
||||||
|
self._last = best_profile
|
||||||
|
return best_profile.label
|
||||||
|
|
||||||
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
@@ -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")
|
||||||
|
|||||||
@@ -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
@@ -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() {
|
||||||
|
|||||||
Reference in new issue
Block a user