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:
Claude committed 2026-09-29 12:01:42 +00:00
1 parent 90a9b6be4d
commit 338546662f
7 files changed
+93 -17

No files matched your search

+1 -1
View File
@@ -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", "")
+37 -8
View File
@@ -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
+17 -5
View File
@@ -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
+2 -1
View File
@@ -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",
+15
View File
@@ -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
+9
View File
@@ -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
View File
@@ -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)