mirror of
https://github.com/R0m1k3/Socialflow.git
synced 2026-10-11 17:26:45 +02:00
Voix : Qwen gardé, nombres en lettres reconnus, Gemini plus court
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
This commit is contained in:
7 files changed
+93
-17
No files matched your search
@@ -42,5 +42,5 @@ SUBTITLE_FONT = os.environ.get("SUBTITLE_FONT", "Montserrat")
|
|||||||
QWEN_TTS_URL = os.environ.get("QWEN_TTS_URL", "").rstrip("/")
|
QWEN_TTS_URL = os.environ.get("QWEN_TTS_URL", "").rstrip("/")
|
||||||
# Sur CPU, la génération prend plusieurs fois la durée de la voix
|
# Sur CPU, la génération prend plusieurs fois la durée de la voix
|
||||||
QWEN_TTS_TIMEOUT = float(os.environ.get("QWEN_TTS_TIMEOUT", "900"))
|
QWEN_TTS_TIMEOUT = float(os.environ.get("QWEN_TTS_TIMEOUT", "900"))
|
||||||
QWEN_TTS_ATTEMPTS = int(os.environ.get("QWEN_TTS_ATTEMPTS", "2"))
|
QWEN_TTS_ATTEMPTS = int(os.environ.get("QWEN_TTS_ATTEMPTS", "3"))
|
||||||
QWEN_TTS_API_KEY = os.environ.get("QWEN_TTS_API_KEY", "")
|
QWEN_TTS_API_KEY = os.environ.get("QWEN_TTS_API_KEY", "")
|
||||||
@@ -9,6 +9,7 @@ pas rogner une syllabe.
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import difflib
|
import difflib
|
||||||
|
import re
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -80,19 +81,47 @@ UNITS = {
|
|||||||
} # fmt: skip
|
} # 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]:
|
def comparable_tokens(words: list[str]) -> list[str]:
|
||||||
"""Mots normalisés, sans les nombres ni l'unité qui les suit : Whisper écrit
|
"""Mots normalisés, sans les nombres (en chiffres ou en lettres), leur signe
|
||||||
un prix ou une mesure à sa façon (« 11 € 99 », « 11,99 euros », « 40 cm »),
|
ni l'unité qui les suit : Whisper écrit un prix ou une mesure à sa façon
|
||||||
ce qui passait pour des mots sautés et relançait la génération."""
|
(« 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] = []
|
tokens: list[str] = []
|
||||||
after_number = False
|
after_number = False
|
||||||
for word in words:
|
for index, token in enumerate(normalized):
|
||||||
token = normalize(word)
|
if _is_number(token):
|
||||||
if not token:
|
|
||||||
continue
|
|
||||||
if any(c.isdigit() for c in token):
|
|
||||||
after_number = True
|
after_number = True
|
||||||
continue
|
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:
|
if after_number and token in UNITS:
|
||||||
continue
|
continue
|
||||||
after_number = False
|
after_number = False
|
||||||
|
|||||||
@@ -89,9 +89,16 @@ async def synthesize(
|
|||||||
return VoiceTrack(processed, duration, words, used_engine, used_voice, warnings)
|
return VoiceTrack(processed, duration, words, used_engine, used_voice, warnings)
|
||||||
|
|
||||||
|
|
||||||
# En dessous, une lecture imparfaite est écartée au profit du moteur suivant :
|
# En dessous, une lecture imparfaite est écartée au profit du moteur suivant.
|
||||||
# mieux vaut une autre voix qu'une phrase aux mots sautés ou inventés.
|
# Au-dessus, on garde la voix choisie (avec un avertissement) : changer de moteur
|
||||||
KEEP_IMPERFECT_COVERAGE = 0.85
|
# pour un mot douteux donnait une voix différente d'une phrase à l'autre.
|
||||||
|
KEEP_IMPERFECT_COVERAGE = 0.7
|
||||||
|
|
||||||
|
|
||||||
|
def is_runaway(duration: float, text: str) -> bool:
|
||||||
|
"""Prise emballée : le modèle a continué à produire du son bien après la fin
|
||||||
|
du texte (vu avec Qwen : 14 s pour une phrase de 34 caractères)."""
|
||||||
|
return duration > max(3.0, len(text) / 6) + 1.5
|
||||||
|
|
||||||
|
|
||||||
async def _checked(
|
async def _checked(
|
||||||
@@ -112,12 +119,17 @@ async def _checked(
|
|||||||
try:
|
try:
|
||||||
raw, engine_voice = await generate(attempt)
|
raw, engine_voice = await generate(attempt)
|
||||||
except Exception as error: # noqa: BLE001 — nouvelle tentative, puis moteur suivant
|
except Exception as error: # noqa: BLE001 — nouvelle tentative, puis moteur suivant
|
||||||
log.warning("%s TTS en échec (tentative %d) : %s", name, attempt + 1, error)
|
log.warning("%s TTS en échec (tentative %d) : %r", name, attempt + 1, error)
|
||||||
last_error = error
|
last_error = error
|
||||||
continue
|
continue
|
||||||
|
|
||||||
processed = workdir / f"voice_{name.lower()}_{attempt}.wav"
|
processed = workdir / f"voice_{name.lower()}_{attempt}.wav"
|
||||||
await audio.process_voice(raw, processed)
|
await audio.process_voice(raw, processed)
|
||||||
|
duration = (await proc.probe(processed)).duration
|
||||||
|
if is_runaway(duration, text):
|
||||||
|
log.warning("%s : prise emballée (%.1f s pour %d car.), écartée", name, duration, len(text))
|
||||||
|
last_error = RuntimeError(f"voix de {duration:.0f} s pour {len(text)} caractères")
|
||||||
|
continue
|
||||||
spoken = await align.transcribe(processed)
|
spoken = await align.transcribe(processed)
|
||||||
check = quality.check_reading(text, spoken)
|
check = quality.check_reading(text, spoken)
|
||||||
log.info("Lecture %s (tentative %d) : %s", name, attempt + 1, check.describe())
|
log.info("Lecture %s (tentative %d) : %s", name, attempt + 1, check.describe())
|
||||||
@@ -128,7 +140,7 @@ async def _checked(
|
|||||||
break
|
break
|
||||||
|
|
||||||
if best is None:
|
if best is None:
|
||||||
warnings.append(f"{name} indisponible ({last_error}) : voix de secours utilisée.")
|
warnings.append(f"{name} : aucune voix utilisable ({last_error!r}) : voix de secours utilisée.")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
check, processed, spoken, engine_voice = best
|
check, processed, spoken, engine_voice = best
|
||||||
|
|||||||
@@ -44,7 +44,8 @@ async def _request(model: str, prompt: str, voice: str, api_key: str, seed: int)
|
|||||||
generation["temperature"] = config.GEMINI_TTS_TEMPERATURE
|
generation["temperature"] = config.GEMINI_TTS_TEMPERATURE
|
||||||
generation["seed"] = seed
|
generation["seed"] = seed
|
||||||
payload = {"contents": [{"parts": [{"text": prompt}]}], "generationConfig": generation}
|
payload = {"contents": [{"parts": [{"text": prompt}]}], "generationConfig": generation}
|
||||||
async with httpx.AsyncClient(timeout=120) as client:
|
# 45 s : une génération normale prend 2 à 5 s ; au-delà, Gemini ne répondra plus
|
||||||
|
async with httpx.AsyncClient(timeout=45) as client:
|
||||||
# Clé en en-tête : dans l'URL, elle finissait dans les journaux
|
# Clé en en-tête : dans l'URL, elle finissait dans les journaux
|
||||||
response = await client.post(
|
response = await client.post(
|
||||||
f"{API_ROOT}/{model}:generateContent",
|
f"{API_ROOT}/{model}:generateContent",
|
||||||
|
|||||||
@@ -103,3 +103,18 @@ def test_cut_prefers_the_sentence_pause_over_a_comma():
|
|||||||
# Borne estimée entre la virgule (1,3 s) et la fin de phrase (2,22 s)
|
# Borne estimée entre la virgule (1,3 s) et la fin de phrase (2,22 s)
|
||||||
cut = sentence_cut(env, previous_end=1.7, next_start=1.75)
|
cut = sentence_cut(env, previous_end=1.7, next_start=1.75)
|
||||||
assert 2.22 <= cut <= 2.67
|
assert 2.22 <= cut <= 2.67
|
||||||
|
|
||||||
|
|
||||||
|
def test_numbers_written_in_letters_are_not_missing_words():
|
||||||
|
expected = "Plus trente secondes chrono, le compteur s'affole : moins vingt-cinq pour cent !"
|
||||||
|
heard = _heard("+30 secondes chrono, le compteur s'affole : -25% !")
|
||||||
|
check = check_reading(expected, heard)
|
||||||
|
assert check.acceptable, check
|
||||||
|
assert check.missing == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_skipped_word_next_to_a_number_in_letters_is_still_detected():
|
||||||
|
expected = "Trente secondes chrono, le compteur s'affole vraiment !"
|
||||||
|
heard = _heard("30 secondes, le compteur s'affole vraiment !")
|
||||||
|
check = check_reading(expected, heard)
|
||||||
|
assert "chrono" in check.missing
|
||||||
@@ -48,3 +48,12 @@ def test_qwen_target_prefers_app_settings(monkeypatch):
|
|||||||
assert Target.resolve(None, None) == Target("http://qwen-tts:8001", "env-key")
|
assert Target.resolve(None, None) == Target("http://qwen-tts:8001", "env-key")
|
||||||
monkeypatch.setattr(config, "QWEN_TTS_URL", "")
|
monkeypatch.setattr(config, "QWEN_TTS_URL", "")
|
||||||
assert Target.resolve("", None) is None
|
assert Target.resolve("", None) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_runaway_take_is_detected():
|
||||||
|
from app.tts import is_runaway
|
||||||
|
|
||||||
|
text = "Christelle fonce dans les rayons !" # 34 caractères
|
||||||
|
assert not is_runaway(3.0, text)
|
||||||
|
assert not is_runaway(6.5, text) # lecture lente, mais plausible
|
||||||
|
assert is_runaway(14.2, text) # vu en production : le modèle continue sans fin
|
||||||
+12
-2
@@ -108,6 +108,13 @@ def clones() -> dict[str, dict]:
|
|||||||
return found
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def max_tokens(text: str) -> int:
|
||||||
|
"""Plafond de codes audio (12 par seconde de son) : une phrase ne peut plus
|
||||||
|
durer plus de « caractères / 6 + 3 » secondes. Sans lui, le modèle continuait
|
||||||
|
parfois bien après la fin du texte (14 s pour 34 caractères)."""
|
||||||
|
return int(12 * (len(text) / 6 + 3))
|
||||||
|
|
||||||
|
|
||||||
def _generate(text: str, voice: str, instruct: str, language: str) -> tuple[bytes, str]:
|
def _generate(text: str, voice: str, instruct: str, language: str) -> tuple[bytes, str]:
|
||||||
if voice.startswith("clone:") and CLONE_MODEL:
|
if voice.startswith("clone:") and CLONE_MODEL:
|
||||||
name = voice.removeprefix("clone:")
|
name = voice.removeprefix("clone:")
|
||||||
@@ -120,12 +127,15 @@ def _generate(text: str, voice: str, instruct: str, language: str) -> tuple[byte
|
|||||||
ref_audio=str(clone["wav"]), ref_text=clone["text"], x_vector_only_mode=False
|
ref_audio=str(clone["wav"]), ref_text=clone["text"], x_vector_only_mode=False
|
||||||
)
|
)
|
||||||
wavs, rate = model.generate_voice_clone(
|
wavs, rate = model.generate_voice_clone(
|
||||||
text=text, language=language, voice_clone_prompt=_clone_prompts[name]
|
text=text,
|
||||||
|
language=language,
|
||||||
|
voice_clone_prompt=_clone_prompts[name],
|
||||||
|
max_new_tokens=max_tokens(text),
|
||||||
)
|
)
|
||||||
used = clone["id"]
|
used = clone["id"]
|
||||||
else:
|
else:
|
||||||
speaker = voice.lower() if voice.lower() in PRESETS else DEFAULT_PRESET
|
speaker = voice.lower() if voice.lower() in PRESETS else DEFAULT_PRESET
|
||||||
kwargs = {"text": text, "language": language, "speaker": speaker}
|
kwargs = {"text": text, "language": language, "speaker": speaker, "max_new_tokens": max_tokens(text)}
|
||||||
if instruct:
|
if instruct:
|
||||||
kwargs["instruct"] = instruct
|
kwargs["instruct"] = instruct
|
||||||
wavs, rate = _load(MODEL).generate_custom_voice(**kwargs)
|
wavs, rate = _load(MODEL).generate_custom_voice(**kwargs)
|
||||||
|
|||||||
Reference in new issue
Block a user