mirror of
https://github.com/R0m1k3/Socialflow.git
synced 2026-10-11 17:26:45 +02:00
Vu en production avec Qwen sur GPU : la plupart des phrases basculaient sur Gemini puis Edge (voix différente d'un sous-titre à l'autre). - Vérification de lecture : les nombres en lettres (« trente », « vingt-cinq pour cent ») et leur signe (« plus », « moins ») sont comparés comme les chiffres écrits par Whisper, au lieu de passer pour des mots sautés. - Prises emballées (14 s pour 34 caractères) écartées d'office ; côté qwen-tts, max_new_tokens borne la durée à « caractères / 6 + 3 » s. - Le moteur choisi est gardé tant qu'il lit au moins 70 % du texte (avec avertissement), au lieu de 85 % ; 3 tentatives Qwen. - Gemini : délai d'attente 45 s au lieu de 120 s, erreurs journalisées en repr. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Upu97wMmsRkBoj6iVM4rH6
273 lines
12 KiB
Python
273 lines
12 KiB
Python
"""Contrôles de la voix générée : texte réellement lu et silences.
|
|
|
|
Gemini lit parfois mal : mots sautés, fin tronquée, consigne de style lue à
|
|
voix haute. On compare donc ce que Whisper entend au texte attendu, et on
|
|
découpe la voix d'après son niveau sonore réel (mesuré toutes les 10 ms)
|
|
plutôt qu'aux bornes des mots estimées par Whisper, trop imprécises pour ne
|
|
pas rogner une syllabe.
|
|
"""
|
|
|
|
import asyncio
|
|
import difflib
|
|
import re
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
|
|
import numpy as np
|
|
|
|
from . import proc
|
|
from .align import Word, normalize
|
|
|
|
MIN_COVERAGE = 0.92
|
|
|
|
|
|
@dataclass
|
|
class ReadingCheck:
|
|
coverage: float # part des mots attendus effectivement entendus
|
|
extra: float # mots entendus en trop (consigne lue, ajouts), rapportés au texte
|
|
ending_ok: bool # la fin du texte est bien lue
|
|
missing: list[str] = field(default_factory=list) # mots attendus non entendus (diagnostic)
|
|
|
|
@property
|
|
def acceptable(self) -> bool:
|
|
# Comparaison lettre à lettre : une lecture complète atteint ~97 %
|
|
# (petites erreurs de Whisper) ; sous 92 %, des mots ont été sautés.
|
|
return self.coverage >= MIN_COVERAGE and self.extra <= 0.25 and self.ending_ok
|
|
|
|
def describe(self) -> str:
|
|
parts = [f"{self.coverage:.0%} des mots lus"]
|
|
if not self.ending_ok:
|
|
parts.append("fin tronquée")
|
|
if self.extra > 0.25:
|
|
parts.append("mots ajoutés")
|
|
if self.missing and self.coverage < MIN_COVERAGE:
|
|
parts.append("non entendus : " + " ".join(self.missing[:12]))
|
|
return ", ".join(parts)
|
|
|
|
|
|
def check_reading(expected_text: str, spoken: list[Word]) -> ReadingCheck:
|
|
"""Compare le texte attendu à ce qui a été entendu (Whisper), lettre à
|
|
lettre : les petites erreurs de reconnaissance (« petit » pour « petits »,
|
|
« week -end » découpé) ne comptent pas comme des mots manquants, alors
|
|
qu'un mot sauté, une fin tronquée ou une phrase ajoutée se voient."""
|
|
expected = "".join(comparable_tokens(expected_text.split()))
|
|
heard = "".join(comparable_tokens([w.text for w in spoken]))
|
|
if not expected:
|
|
return ReadingCheck(1.0, 0.0, True)
|
|
if not heard:
|
|
return ReadingCheck(0.0, 0.0, False)
|
|
|
|
matcher = difflib.SequenceMatcher(a=expected, b=heard, autojunk=False)
|
|
blocks = [b for b in matcher.get_matching_blocks() if b.size]
|
|
matched = sum(b.size for b in blocks)
|
|
# Fin lue : une bonne partie des dernières lettres du texte est reconnue
|
|
tail_start = max(0, len(expected) - 8)
|
|
tail_matched = sum(max(0, min(b.a + b.size, len(expected)) - max(b.a, tail_start)) for b in blocks)
|
|
return ReadingCheck(
|
|
coverage=matched / len(expected),
|
|
extra=max(0, len(heard) - matched) / len(expected),
|
|
ending_ok=tail_matched >= min(4, len(expected) - tail_start),
|
|
missing=_missing_words(expected_text, spoken),
|
|
)
|
|
|
|
|
|
# Unités écrites de plusieurs façons par Whisper (« 40 cm » pour « 40 centimètres »)
|
|
UNITS = {
|
|
"e", "eur", "euro", "euros", "centime", "centimes", "cm", "centimetre", "centimetres",
|
|
"mm", "millimetre", "millimetres", "m", "metre", "metres", "km", "kilometre", "kilometres",
|
|
"g", "gramme", "grammes", "kg", "kilo", "kilos", "kilogramme", "kilogrammes",
|
|
"l", "litre", "litres", "cl", "centilitre", "centilitres", "ml", "millilitre", "millilitres",
|
|
"pourcent", "pourcents", "x", "fois",
|
|
} # fmt: skip
|
|
|
|
|
|
# Nombres écrits en lettres : Whisper les écrit en chiffres (« trente » → « 30 »).
|
|
# « un »/« une » n'y sont pas : ce sont le plus souvent des articles.
|
|
NUMBER_WORDS = {
|
|
"zero", "deux", "trois", "quatre", "cinq", "six", "sept", "huit", "neuf", "dix",
|
|
"onze", "douze", "treize", "quatorze", "quinze", "seize", "vingt", "vingts",
|
|
"trente", "quarante", "cinquante", "soixante", "septante", "octante", "nonante",
|
|
"cent", "cents", "mille", "million", "millions", "milliard", "milliards",
|
|
} # fmt: skip
|
|
# Signe avant un nombre : « +30 » est lu « plus trente »
|
|
SIGNS = {"plus", "moins"}
|
|
|
|
|
|
# Nombres composés, écrits avec traits d'union puis normalisés (« trentecinq »)
|
|
_COMPOUND_NUMBER = re.compile(
|
|
"^(?:" + "|".join(sorted(NUMBER_WORDS | {"et", "un", "une"}, key=len, reverse=True)) + ")+$"
|
|
)
|
|
|
|
|
|
def _is_number(token: str) -> bool:
|
|
if any(c.isdigit() for c in token) or token in NUMBER_WORDS:
|
|
return True
|
|
return token not in {"un", "une", "et"} and bool(_COMPOUND_NUMBER.match(token))
|
|
|
|
|
|
def comparable_tokens(words: list[str]) -> list[str]:
|
|
"""Mots normalisés, sans les nombres (en chiffres ou en lettres), leur signe
|
|
ni l'unité qui les suit : Whisper écrit un prix ou une mesure à sa façon
|
|
(« 11 € 99 », « 11,99 euros », « 40 cm », « 30 » pour « trente »), ce qui
|
|
passait pour des mots sautés et relançait la génération."""
|
|
normalized = [t for t in (normalize(w) for w in words) if t]
|
|
tokens: list[str] = []
|
|
after_number = False
|
|
for index, token in enumerate(normalized):
|
|
if _is_number(token):
|
|
after_number = True
|
|
continue
|
|
following = normalized[index + 1] if index + 1 < len(normalized) else ""
|
|
if token in SIGNS and _is_number(following):
|
|
continue
|
|
if after_number and token == "pour" and following in {"cent", "cents"}:
|
|
continue # « trente pour cent » ↔ « 30 % »
|
|
if after_number and token in UNITS:
|
|
continue
|
|
after_number = False
|
|
tokens.append(token)
|
|
return tokens
|
|
|
|
|
|
def _missing_words(expected_text: str, spoken: list[Word]) -> list[str]:
|
|
"""Mots attendus sans équivalent proche dans ce qui a été entendu."""
|
|
heard = set(comparable_tokens([w.text for w in spoken]))
|
|
kept = set(comparable_tokens(expected_text.split()))
|
|
missing = []
|
|
for word in expected_text.split():
|
|
token = normalize(word)
|
|
if token in kept and not any(difflib.SequenceMatcher(a=token, b=h).ratio() >= 0.7 for h in heard):
|
|
missing.append(word.strip(".,!?;:…"))
|
|
return missing
|
|
|
|
|
|
# Enveloppe sonore : niveau de la voix toutes les 10 ms
|
|
FRAME = 0.01
|
|
# Est « audible » ce qui dépasse le niveau de crête moins 38 dB : un seuil
|
|
# relatif respecte les fins de phrases douces (« s » final, voix qui retombe),
|
|
# qu'un seuil absolu prenait pour du silence.
|
|
ACTIVE_RANGE_DB = 38.0
|
|
# Marges gardées autour de la parole
|
|
LEAD_PAD = 0.08
|
|
TAIL_PAD = 0.30
|
|
# Pause entre deux phrases : au moins 80 ms de silence, cherchée jusqu'à 1,2 s
|
|
# de la borne estimée par Whisper
|
|
MIN_PAUSE = 0.08
|
|
PAUSE_SEARCH = 1.2
|
|
|
|
|
|
@dataclass
|
|
class Envelope:
|
|
db: np.ndarray # niveau (dBFS) de chaque tranche de 10 ms
|
|
|
|
@property
|
|
def duration(self) -> float:
|
|
return len(self.db) * FRAME
|
|
|
|
@property
|
|
def threshold(self) -> float:
|
|
if not len(self.db):
|
|
return 0.0
|
|
return float(np.percentile(self.db, 95)) - ACTIVE_RANGE_DB
|
|
|
|
def _frames(self, start: float, end: float) -> tuple[int, int]:
|
|
first = max(0, int(start / FRAME))
|
|
last = min(len(self.db), int(np.ceil(end / FRAME)))
|
|
return first, max(first, last)
|
|
|
|
def quietest(self, start: float, end: float) -> float:
|
|
"""Milieu de la zone la plus calme d'un intervalle : la coupe tombe au
|
|
cœur de la pause, loin des deux phrases qu'elle sépare."""
|
|
first, last = self._frames(start, end)
|
|
if last - first < 1:
|
|
return (start + end) / 2
|
|
smooth = np.convolve(self.db, np.ones(5) / 5, mode="same")[first:last]
|
|
quiet = smooth <= smooth.min() + 3.0 # à 3 dB du minimum
|
|
best_start, best_length, run_start = 0, 0, None
|
|
for i, is_quiet in enumerate([*quiet, False]):
|
|
if is_quiet and run_start is None:
|
|
run_start = i
|
|
elif not is_quiet and run_start is not None:
|
|
if i - run_start > best_length:
|
|
best_start, best_length = run_start, i - run_start
|
|
run_start = None
|
|
return (first + best_start + best_length / 2) * FRAME
|
|
|
|
def first_sound(self, start: float, end: float) -> float | None:
|
|
first, last = self._frames(start, end)
|
|
active = np.nonzero(self.db[first:last] > self.threshold)[0]
|
|
return (first + int(active[0])) * FRAME if len(active) else None
|
|
|
|
def last_sound(self, start: float, end: float) -> float | None:
|
|
first, last = self._frames(start, end)
|
|
active = np.nonzero(self.db[first:last] > self.threshold)[0]
|
|
return (first + int(active[-1]) + 1) * FRAME if len(active) else None
|
|
|
|
def pauses(self, min_length: float = MIN_PAUSE) -> list[tuple[float, float]]:
|
|
"""Silences (sous le seuil d'audibilité) d'au moins min_length secondes."""
|
|
quiet = self.db <= self.threshold
|
|
runs, run_start = [], None
|
|
for i, is_quiet in enumerate([*quiet, False]):
|
|
if is_quiet and run_start is None:
|
|
run_start = i
|
|
elif not is_quiet and run_start is not None:
|
|
if (i - run_start) * FRAME >= min_length:
|
|
runs.append((run_start * FRAME, i * FRAME))
|
|
run_start = None
|
|
return runs
|
|
|
|
def speech_span(self, start: float, end: float) -> tuple[float, float]:
|
|
"""Parole contenue dans [start, end], avec ses marges, sans en sortir."""
|
|
onset, offset = self.first_sound(start, end), self.last_sound(start, end)
|
|
if onset is None or offset is None:
|
|
return start, end
|
|
return max(start, onset - LEAD_PAD), min(end, offset + TAIL_PAD)
|
|
|
|
|
|
def envelope_from_samples(samples: np.ndarray, rate: int) -> Envelope:
|
|
per_frame = int(rate * FRAME)
|
|
count = len(samples) // per_frame
|
|
if count == 0:
|
|
return Envelope(np.array([-120.0]))
|
|
frames = samples[: count * per_frame].reshape(count, per_frame).astype(np.float64)
|
|
rms = np.sqrt(np.mean(frames**2, axis=1))
|
|
return Envelope(20 * np.log10(np.maximum(rms, 1e-6)))
|
|
|
|
|
|
async def load_envelope(path: Path) -> Envelope:
|
|
"""Décode l'audio (mono 16 kHz) et mesure son niveau toutes les 10 ms."""
|
|
process = await asyncio.create_subprocess_exec(
|
|
"ffmpeg", "-hide_banner", "-loglevel", "error", "-i", str(path),
|
|
"-ac", "1", "-ar", "16000", "-f", "f32le", "-",
|
|
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
|
|
) # fmt: skip
|
|
raw, err = await process.communicate()
|
|
if process.returncode != 0:
|
|
raise proc.CommandError(["ffmpeg"], process.returncode, err.decode(errors="replace"))
|
|
return envelope_from_samples(np.frombuffer(raw, dtype=np.float32), 16000)
|
|
|
|
|
|
def sentence_cut(envelope: Envelope, previous_end: float, next_start: float, after: float = 0.0) -> float:
|
|
"""Coupe entre deux phrases, au cœur d'une vraie pause.
|
|
|
|
Les bornes Whisper sont imprécises, et fausses quand un mot n'a pas été
|
|
reconnu (un prix en fin de phrase : sa place est alors estimée) ; couper
|
|
à la borne estimée tombait parfois au milieu d'une phrase. On choisit donc
|
|
la pause mesurée la plus proche de la borne estimée, les longues pauses
|
|
(fin de phrase) étant préférées aux courtes (virgule)."""
|
|
low = max(after, min(previous_end, next_start) - 0.1)
|
|
high = max(low + 0.05, max(previous_end, next_start) + 0.2)
|
|
best: tuple[float, float, float] | None = None
|
|
for start, end in envelope.pauses():
|
|
if end <= after + 0.05 or start <= 0.0 or end >= envelope.duration:
|
|
continue # silences avant la première et après la dernière phrase exclus
|
|
distance = max(0.0, low - end, start - high)
|
|
if distance > PAUSE_SEARCH:
|
|
continue
|
|
score = distance - (end - start)
|
|
if best is None or score < best[0]:
|
|
best = (score, max(start, after), end)
|
|
if best:
|
|
return envelope.quietest(best[1], best[2])
|
|
return envelope.quietest(low, high)
|