Merge pull request #120 from R0m1k3/claude/trusting-hawking-47qpzb

Voix : Qwen gardé, nombres en lettres reconnus, Gemini plus court
This commit is contained in:
LogiFlow authored and GitHub committed 2026-09-29 14:02:00 +02:00
commit 2c732c11b9
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("/")
# 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_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", "")
+37 -8
View File
@@ -9,6 +9,7 @@ pas rogner une syllabe.
import asyncio
import difflib
import re
from dataclasses import dataclass, field
from pathlib import Path
@@ -80,19 +81,47 @@ UNITS = {
} # 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 ni l'unité qui les suit : Whisper écrit
un prix ou une mesure à sa façon (« 11 € 99 », « 11,99 euros », « 40 cm »),
ce qui passait pour des mots sautés et relançait la génération."""
"""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 word in words:
token = normalize(word)
if not token:
continue
if any(c.isdigit() for c in token):
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
+17 -5
View File
@@ -89,9 +89,16 @@ async def synthesize(
return VoiceTrack(processed, duration, words, used_engine, used_voice, warnings)
# 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.
KEEP_IMPERFECT_COVERAGE = 0.85
# En dessous, une lecture imparfaite est écartée au profit du moteur suivant.
# Au-dessus, on garde la voix choisie (avec un avertissement) : changer de moteur
# 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(
@@ -112,12 +119,17 @@ async def _checked(
try:
raw, engine_voice = await generate(attempt)
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
continue
processed = workdir / f"voice_{name.lower()}_{attempt}.wav"
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)
check = quality.check_reading(text, spoken)
log.info("Lecture %s (tentative %d) : %s", name, attempt + 1, check.describe())
@@ -128,7 +140,7 @@ async def _checked(
break
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
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["seed"] = seed
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
response = await client.post(
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)
cut = sentence_cut(env, previous_end=1.7, next_start=1.75)
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")
monkeypatch.setattr(config, "QWEN_TTS_URL", "")
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
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]:
if voice.startswith("clone:") and CLONE_MODEL:
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
)
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"]
else:
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:
kwargs["instruct"] = instruct
wavs, rate = _load(MODEL).generate_custom_voice(**kwargs)