Files
Claude 44daa88a49 Voix : plus d'accélération sur les SRT, moteur local Qwen3-TTS
SRT : la voix n'est plus jamais accélérée (atempo sans plafond, jusqu'à 2×
et plus, rendait la voix incompréhensible). Un morceau plus long que son
sous-titre déborde et décale les suivants ; les sous-titres suivent la voix,
la vidéo s'allonge et un avertissement signale le décalage.

Rythme : style par défaut « neutre », consignes Gemini sans « rythme
entraînant », débit Edge des styles dynamique/promo ramené à +0 %/+3 %.

Nouveau moteur « qwen » : service qwen-tts (Qwen3-TTS sur CPU, gratuit,
français, ton piloté par consigne, clonage de voix depuis qwen-tts/voices).
Même vérification Whisper que Gemini, généralisée ; repli qwen → gemini →
edge, et une lecture sous 85 % du texte est écartée (mots sautés/faux).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Upu97wMmsRkBoj6iVM4rH6
2026-09-29 09:39:47 +00:00

178 lines
7.3 KiB
Python

"""Catalogue des voix et des styles de lecture."""
from dataclasses import dataclass
@dataclass(frozen=True)
class Voice:
id: str
label: str
gender: str # "female" | "male"
# Voix natives de Gemini TTS (toutes multilingues, françaises comprises).
GEMINI_VOICES: tuple[Voice, ...] = (
Voice("Kore", "Kore — ferme", "female"),
Voice("Aoede", "Aoede — légère", "female"),
Voice("Leda", "Leda — jeune", "female"),
Voice("Zephyr", "Zephyr — lumineuse", "female"),
Voice("Callirrhoe", "Callirrhoe — décontractée", "female"),
Voice("Autonoe", "Autonoe — lumineuse", "female"),
Voice("Despina", "Despina — douce", "female"),
Voice("Erinome", "Erinome — claire", "female"),
Voice("Laomedeia", "Laomedeia — enjouée", "female"),
Voice("Achernar", "Achernar — tendre", "female"),
Voice("Gacrux", "Gacrux — mûre", "female"),
Voice("Pulcherrima", "Pulcherrima — affirmée", "female"),
Voice("Vindemiatrix", "Vindemiatrix — délicate", "female"),
Voice("Sulafat", "Sulafat — chaleureuse", "female"),
Voice("Charon", "Charon — informative", "male"),
Voice("Puck", "Puck — enjouée", "male"),
Voice("Fenrir", "Fenrir — enthousiaste", "male"),
Voice("Orus", "Orus — ferme", "male"),
Voice("Enceladus", "Enceladus — soufflée", "male"),
Voice("Iapetus", "Iapetus — claire", "male"),
Voice("Umbriel", "Umbriel — décontractée", "male"),
Voice("Algieba", "Algieba — veloutée", "male"),
Voice("Algenib", "Algenib — rocailleuse", "male"),
Voice("Rasalgethi", "Rasalgethi — informative", "male"),
Voice("Alnilam", "Alnilam — ferme", "male"),
Voice("Schedar", "Schedar — posée", "male"),
Voice("Achird", "Achird — amicale", "male"),
Voice("Zubenelgenubi", "Zubenelgenubi — naturelle", "male"),
Voice("Sadachbia", "Sadachbia — vive", "male"),
Voice("Sadaltager", "Sadaltager — experte", "male"),
)
EDGE_VOICES: tuple[Voice, ...] = (
Voice("fr-FR-VivienneMultilingualNeural", "Vivienne", "female"),
Voice("fr-FR-DeniseNeural", "Denise", "female"),
Voice("fr-FR-EloiseNeural", "Eloise", "female"),
Voice("fr-FR-RemyMultilingualNeural", "Rémy", "male"),
Voice("fr-FR-HenriNeural", "Henri", "male"),
)
# Voix prédéfinies de Qwen3-TTS (service local). Les voix clonées
# (« clone:nom ») sont listées par le service lui-même.
QWEN_VOICES: tuple[Voice, ...] = (
Voice("serena", "Serena — douce et chaleureuse", "female"),
Voice("vivian", "Vivian — vive et lumineuse", "female"),
Voice("sohee", "Sohee — expressive", "female"),
Voice("ono_anna", "Anna — espiègle", "female"),
Voice("aiden", "Aiden — solaire", "male"),
Voice("ryan", "Ryan — dynamique", "male"),
Voice("uncle_fu", "Fu — grave et posée", "male"),
Voice("dylan", "Dylan — jeune et naturelle", "male"),
Voice("eric", "Eric — légèrement voilée", "male"),
)
# Consignes de lecture ajoutées au texte envoyé à Gemini.
STYLES: dict[str, tuple[str, str]] = {
"neutral": ("Neutre", ""),
"dynamic": (
"Dynamique",
"Lis ce texte en français sur un ton dynamique et enthousiaste, comme une vidéo "
"courte sur les réseaux sociaux, à un débit naturel, sans accélérer, en articulant bien",
),
"warm": (
"Chaleureux",
"Lis ce texte en français sur un ton chaleureux, souriant et proche, comme si tu conseillais un ami",
),
"calm": (
"Calme",
"Lis ce texte en français sur un ton calme, posé et rassurant, sans te presser",
),
"promo": (
"Promo",
"Lis ce texte en français comme une annonce promotionnelle énergique, en "
"insistant sur les offres et les prix, sans accélérer, en articulant bien",
),
}
_GEMINI_BY_ID = {v.id.lower(): v for v in GEMINI_VOICES}
_EDGE_BY_ID = {v.id: v for v in EDGE_VOICES}
_QWEN_BY_ID = {v.id: v for v in QWEN_VOICES}
# Consignes de ton pour Qwen (le modèle comprend l'anglais mieux que le français)
QWEN_STYLES: dict[str, str] = {
"neutral": "Speak natural, native French with clear articulation and a relaxed, natural pace.",
"dynamic": "Speak native French in an upbeat, enthusiastic and smiling tone, like a social media video, "
"at a natural pace without rushing, clearly articulated.",
"warm": "Speak native French in a warm, friendly and caring tone, as if advising a friend, "
"at a relaxed pace.",
"calm": "Speak native French in a calm, soothing and reassuring tone, slowly and clearly.",
"promo": "Speak native French like an energetic promotional announcement, emphasizing offers and prices, "
"at a natural pace without rushing, clearly articulated.",
}
def resolve_gemini_voice(voice: str | None) -> Voice:
"""Voix Gemini à utiliser.
Accepte aussi les anciens identifiants « fr-FR-Standard-X » encore stockés
dans des jobs (B/D = voix masculine).
"""
if voice and voice.lower() in _GEMINI_BY_ID:
return _GEMINI_BY_ID[voice.lower()]
if voice and voice.endswith(("-B", "-D")) or voice_gender(voice) == "male":
return _GEMINI_BY_ID["charon"]
return _GEMINI_BY_ID["kore"]
def voice_gender(voice: str | None) -> str:
"""Genre d'une voix, quel que soit le moteur d'origine."""
if not voice:
return "female"
if voice.lower() in _GEMINI_BY_ID:
return _GEMINI_BY_ID[voice.lower()].gender
if voice in _EDGE_BY_ID:
return _EDGE_BY_ID[voice].gender
if voice.lower() in _QWEN_BY_ID:
return _QWEN_BY_ID[voice.lower()].gender
if voice == "male" or voice.endswith(("-B", "-D")):
return "male"
return "male" if any(name in voice for name in ("Remy", "Henri", "Paul")) else "female"
def resolve_edge_voice(voice: str | None) -> Voice:
"""Voix Edge à utiliser ; une voix Gemini est remplacée par une voix Edge du même genre."""
if voice in _EDGE_BY_ID:
return _EDGE_BY_ID[voice]
gender = voice_gender(voice)
return next(v for v in EDGE_VOICES if v.gender == gender)
def resolve_qwen_voice(voice: str | None) -> str:
"""Voix Qwen à utiliser : voix clonée ou prédéfinie ; une voix d'un autre
moteur est remplacée par une voix Qwen du même genre."""
if voice and voice.startswith("clone:"):
return voice
if voice and voice.lower() in _QWEN_BY_ID:
return voice.lower()
return "aiden" if voice_gender(voice) == "male" else "serena"
def qwen_instruction(style: str | None) -> str:
return QWEN_STYLES.get(style or "neutral", QWEN_STYLES["neutral"])
def edge_fallbacks(primary: Voice) -> list[Voice]:
"""Voix françaises de secours du même genre (jamais d'anglais)."""
same = [v for v in EDGE_VOICES if v.gender == primary.gender and v != primary]
return [primary, *same]
def style_instruction(style: str | None) -> str:
return STYLES.get(style or "neutral", STYLES["neutral"])[1]
def catalog(qwen_voices: list[dict] | None = None) -> dict:
"""Voix par moteur ; `qwen_voices` vient du service Qwen (None s'il ne répond pas)."""
return {
"qwen": qwen_voices or [v.__dict__ for v in QWEN_VOICES],
"qwen_available": qwen_voices is not None,
"gemini": [v.__dict__ for v in GEMINI_VOICES],
"edge": [v.__dict__ for v in EDGE_VOICES],
"styles": [{"id": k, "label": v[0]} for k, v in STYLES.items()],
}