Files
LiveFlow/app/segmenter.py
T
Claude 6f4d15f133 Découpage : ne plus couper les mots en début/fin de phrase
Maintenant que l'amplification ne sert qu'à la détection (l'audio transcrit
reste propre), on peut rendre le VAD bien plus généreux aux bords sans coût
sur la qualité :
- pré-enregistrement 300 ms -> 750 ms (capture l'attaque de la 1re syllabe)
- VAD plus sensible (agressivité 2 -> 1), déclenchement plus rapide (0.6 -> 0.35)
- silence de fin 700 ms -> 950 ms (capture les fins de mots)
Validé : lead-in 0.5 s + trailing 1.1 s capturés, phrases distinctes non fusionnées.

https://claude.ai/code/session_01YHMp3EKzr4s6o8w1ygxuUe
2026-06-13 15:54:09 +00:00

140 lines
5.4 KiB
Python

"""Découpage du flux micro en segments de parole via WebRTC VAD.
Le client envoie du PCM 16 bits mono 16 kHz. On analyse des trames de 30 ms :
un segment démarre quand la parole domine la fenêtre récente (avec un
pré-roll pour ne pas couper le début de phrase) et se termine après un
silence prolongé ou une durée maximale.
"""
from collections import deque
from dataclasses import dataclass
import audioop
import webrtcvad
SAMPLE_RATE = 16000
FRAME_MS = 30
FRAME_BYTES = SAMPLE_RATE * FRAME_MS // 1000 * 2 # 960 octets
VAD_AGGRESSIVENESS = 1 # plus sensible : capte les attaques/fins douces
PREROLL_FRAMES = 25 # 750 ms gardées avant le déclenchement (début de phrase)
TRIGGER_RATIO = 0.35 # déclenche vite, sur peu de trames "parole"
SILENCE_END_MS = 950 # silence qui clôt un segment (capte les fins de mots)
MIN_SPEECH_MS = 240 # en dessous, le segment est ignoré (bruit)
MAX_SEGMENT_S = 15 # 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
class Segment:
pcm: bytes
t0: float # secondes depuis le début de la réunion
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:
# Toute erreur ici ne doit JAMAIS interrompre le découpage : on
# renvoie la trame d'origine en cas de souci.
try:
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) # audioop sature, ne déborde pas
except Exception:
return frame
return frame
class SpeechSegmenter:
def __init__(self):
self._vad = webrtcvad.Vad(VAD_AGGRESSIVENESS)
self._agc = _AutoGain()
self._pending = bytearray()
self._ring: deque[tuple[bytes, bool]] = deque(maxlen=PREROLL_FRAMES)
self._frame_index = 0
self._triggered = False
self._segment = bytearray()
self._segment_start_frame = 0
self._silence_frames = 0
self._speech_frames = 0
def feed(self, data: bytes) -> list[Segment]:
"""Ajoute de l'audio brut et renvoie les segments terminés."""
self._pending.extend(data)
segments = []
while len(self._pending) >= FRAME_BYTES:
raw = bytes(self._pending[:FRAME_BYTES])
del self._pending[:FRAME_BYTES]
# Le VAD travaille sur l'audio amplifié (détection fiable même micro
# faible) ; le segment stocke l'audio BRUT (transcription propre).
amplified = self._agc.process(raw)
seg = self._process_frame(raw, amplified)
if seg is not None:
segments.append(seg)
return segments
def flush(self) -> Segment | None:
"""Clôt le segment en cours (fin d'enregistrement)."""
seg = self._finish_segment() if self._triggered else None
self._ring.clear()
self._pending.clear()
return seg
def _process_frame(self, raw: bytes, amplified: bytes) -> Segment | None:
is_speech = self._vad.is_speech(amplified, SAMPLE_RATE)
self._frame_index += 1
if not self._triggered:
self._ring.append((raw, is_speech))
voiced = sum(1 for _, s in self._ring if s)
if len(self._ring) == self._ring.maxlen and voiced >= TRIGGER_RATIO * self._ring.maxlen:
self._triggered = True
self._segment_start_frame = self._frame_index - len(self._ring)
self._segment = bytearray(b"".join(f for f, _ in self._ring))
self._speech_frames = voiced
self._silence_frames = 0
self._ring.clear()
return None
self._segment.extend(raw)
if is_speech:
self._speech_frames += 1
self._silence_frames = 0
else:
self._silence_frames += 1
too_long = len(self._segment) >= MAX_SEGMENT_S * SAMPLE_RATE * 2
ended = self._silence_frames * FRAME_MS >= SILENCE_END_MS
if ended or too_long:
return self._finish_segment()
return None
def _finish_segment(self) -> Segment | None:
self._triggered = False
seg, self._segment = self._segment, bytearray()
if self._speech_frames * FRAME_MS < MIN_SPEECH_MS:
return None
t0 = self._segment_start_frame * FRAME_MS / 1000
t1 = self._frame_index * FRAME_MS / 1000
return Segment(pcm=bytes(seg), t0=t0, t1=t1)