diff --git a/ffmpeg-service/app/config.py b/ffmpeg-service/app/config.py index aead2a7..71081bf 100644 --- a/ffmpeg-service/app/config.py +++ b/ffmpeg-service/app/config.py @@ -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", "") diff --git a/ffmpeg-service/app/quality.py b/ffmpeg-service/app/quality.py index f5944ce..50032e1 100644 --- a/ffmpeg-service/app/quality.py +++ b/ffmpeg-service/app/quality.py @@ -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 diff --git a/ffmpeg-service/app/tts/__init__.py b/ffmpeg-service/app/tts/__init__.py index e36222f..b8f7078 100644 --- a/ffmpeg-service/app/tts/__init__.py +++ b/ffmpeg-service/app/tts/__init__.py @@ -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 diff --git a/ffmpeg-service/app/tts/gemini.py b/ffmpeg-service/app/tts/gemini.py index 21d0cc5..bf843ca 100644 --- a/ffmpeg-service/app/tts/gemini.py +++ b/ffmpeg-service/app/tts/gemini.py @@ -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", diff --git a/ffmpeg-service/tests/test_quality.py b/ffmpeg-service/tests/test_quality.py index ebd4fef..2df54a7 100644 --- a/ffmpeg-service/tests/test_quality.py +++ b/ffmpeg-service/tests/test_quality.py @@ -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 diff --git a/ffmpeg-service/tests/test_voices.py b/ffmpeg-service/tests/test_voices.py index 2a7d8de..d3317f8 100644 --- a/ffmpeg-service/tests/test_voices.py +++ b/ffmpeg-service/tests/test_voices.py @@ -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 diff --git a/qwen-tts/main.py b/qwen-tts/main.py index dd33f05..24af312 100644 --- a/qwen-tts/main.py +++ b/qwen-tts/main.py @@ -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)