# Parameters
BATCH_MODE = "true"Voice Casting : Attribution de voix TTS par personnage
Navigation : Index | << Précédent | Suivant >>
Epic #1028 — P2 : Generation d’echantillons audio pour chaque personnage detecte en P1.
Ce notebook prend les résultats de la lecture analytique (P1) et attribue une voix TTS concrete a chaque personnage en generant une tirade de casting par personnage (165-214 syllabes ecrites ; 8 personnages sur 9 ont une tirade dediee, le 9e retombe sur sa replique la plus longue).
| Étape | Description |
|---|---|
| 1 | Charger les résultats P1 (personnages, segments, specs vocales) |
| 2 | Mapper les profils FishAudio aux voix TTS disponibles |
| 3 | Generer un echantillon audio par personnage |
| 4 | Ecouter et valider le casting |
| 5 | Exporter la configuration de casting pour P3/P4 |
Modèles TTS disponibles
- Kokoro TTS (local, port 8191) : 10 voix francaises, rapide (~5s)
- OpenAI TTS (cloud, tts-1) : 6 voix multilingues, haute qualite
- FishAudio S2-Pro (prevu production) : voice cloning, tags expressifs — non deploye en Docker
Note : En production, FishAudio S2-Pro sera le modèle principal avec voice cloning zero-shot et tags expressifs natifs. Ce notebook utilise Kokoro + OpenAI comme substituts demo.
1. Configuration et imports
# Import guards - availability flags for external dependencies
try:
from dotenv import load_dotenv
DOTENV_AVAILABLE = True
except ImportError:
DOTENV_AVAILABLE = False
print(f' dotenv non disponible - certaines fonctionnalites seront limitees')
try:
import openai
OPENAI_AVAILABLE = True
except ImportError:
OPENAI_AVAILABLE = False
print(f' openai non disponible - certaines fonctionnalites seront limitees')
try:
import pandas as pd
PANDAS_AVAILABLE = True
except ImportError:
PANDAS_AVAILABLE = False
print(f' pandas non disponible - certaines fonctionnalites seront limitees')
try:
import requests
REQUESTS_AVAILABLE = True
except ImportError:
REQUESTS_AVAILABLE = False
print(f' requests non disponible - certaines fonctionnalites seront limitees')
import os
import re
import json
import time
import IPython.display as ipd
from pathlib import Path
from dataclasses import dataclass, asdict
from typing import Optional
import requests
import pandas as pd
# Charger les variables d'environnement GenAI
from dotenv import load_dotenv
_env_candidates = [
Path("../.env").resolve(),
Path("../../.env").resolve(),
Path("../../../.env").resolve(),
]
for _env_path in _env_candidates:
if _env_path.exists():
load_dotenv(_env_path)
print(f"Env charge: {_env_path.name}")
break
else:
print("WARNING: Fichier .env non trouve")
TTS_KOKORO_URL = os.getenv("TTS_API_URL", "http://localhost:8191")
TTS_API_KEY = os.getenv("TTS_API_KEY", "")
# Artefacts P1 : repertoire du contrat canonique v1, ecrit par 04-8 (meme dossier)
P1_OUTPUT = Path("output_analytique")
# Mode explicite de consommation du contrat P1 :
# - "pipeline" (defaut) : les artefacts P1 sont EXIGES, echec clair sinon
# - "demo" : session isolee, fallback embarque ANNONCE
P1_MODE = os.getenv("P1_MODE", "pipeline").strip().lower()
if P1_MODE not in ("pipeline", "demo"):
raise ValueError(f"P1_MODE invalide : {P1_MODE!r} (attendu 'pipeline' ou 'demo')")
VOICE_DIR = Path("voice_casting_output")
VOICE_DIR.mkdir(exist_ok=True)
print(f"Kokoro TTS: {TTS_KOKORO_URL}")
print(f"P1 artifacts dir: {P1_OUTPUT}")
print(f"P1 mode: {P1_MODE}")
print(f"Voice output dir: {VOICE_DIR}")
# Import helpers audio : publication d'un bundle MIME audio/* verifiable dans le JSON (#10996)
import sys
GENAI_ROOT = Path.cwd()
while GENAI_ROOT.name != "GenAI" and len(GENAI_ROOT.parts) > 1:
GENAI_ROOT = GENAI_ROOT.parent
HELPERS_PATH = GENAI_ROOT / "shared" / "helpers"
if HELPERS_PATH.exists():
sys.path.insert(0, str(HELPERS_PATH.parent))
from helpers.audio_helpers import display_audio_bundle, display_audio_file
print("Helpers audio importes")Env charge: .env
Kokoro TTS: http://localhost:8191
P1 artifacts dir: output_analytique
P1 mode: pipeline
Voice output dir: voice_casting_output
Helpers audio importes
2. Chargement des résultats P1 (contrat canonique v1)
Le notebook P1 — Lecture analytique (04-8-Lecture-Analytique.ipynb) exporte dans output_analytique/ un contrat d’interface versionné (manifeste p1_contract.json, version 1.0) accompagné de 4 artefacts à noms et structures fixes :
| Fichier | Format | Contenu |
|---|---|---|
characters.json |
JSON dict | Personnages détectés ({char_id: {name, gender, role, utterance_count, first_utterance}}) |
segments.csv |
CSV | Segments narratifs atomiques (utilisés par P3 prosodie) |
utterances.csv |
CSV | Répliques individuelles par personnage (utilisées par P4 TTS) |
voice_recommendations.csv |
CSV | Recommandations vocales Kokoro suggérées par P1 (character_id, name, voice, model, raison) |
p1_contract.json |
JSON | Manifeste : version du contrat, producteur, source (Gutenberg ID, TEXT_SOURCE), compteurs attendus par fichier |
Deux modes explicites (variable d’environnement P1_MODE)
| Mode | Quand | Comportement si artefact absent |
|---|---|---|
pipeline (défaut) |
Chaîne complète 04-8 → 04-9 | Échec clair : RuntimeError listant le fichier manquant et la marche à suivre (exécuter 04-8 d’abord). Aucun fallback silencieux. |
demo (P1_MODE=demo) |
Session isolée de validation du casting | Fallback embarqué (8 personnages de Boule de Suif) autorisé, mais annoncé comme tel dans les sorties |
Le contrat 1.0 scelle le schéma des deux côtés : 04-8 écrit exactement la forme ci-dessus (mapping full_name → name, utterances → utterance_count, first_utterance dérivée de la première réplique), et load_p1_results() valide le schéma en mode pipeline (clés requises par personnage, compteurs du manifeste) avant de rendre la main. Une divergence de schéma ne peut plus passer inaperçue : elle échoue au chargement, pas au milieu de la synthèse.
Mesures observées sur cette exécution
Exécution pipeline (mode par défaut) sur les artefacts fraîchement produits par 04-8 dans le même environnement :
- Contrat
1.0attesté par le manifeste : producteur04-8-Lecture-Analytique.ipynb, source Boule de Suif (Gutenberg #10746, texte 83 097 caractères — le conte seul,TEXT_SOURCE = local) - 9 personnages / 342 segments / 39 répliques / 9 recommandations vocales chargés depuis disque, zéro fallback activé
- Différence avec le jeu de démo : la détection réelle du conte identifie
boule_de_suif(Elisabeth Rousset, protagoniste),officier(antagoniste) etmme_loiseau(absent du fallback) — 9 personnages sansnarrateur— les tirades de la section 5 couvrent les deux jeux de clés (Mme Loiseau retombe sur le texte générique court : safirst_utterancen’est qu’un fragment de deux mots — « charmante compagne » — signaléINSUFFICIENTdans les verdicts)
Pourquoi load_p1_results() retourne un dict et non un tuple
Le pattern dict permet l’accès par clé sémantique (p1["characters"], p1["voice_specs"]) plutôt que par index positionnel — un notebook pédagogique se relit plus facilement quand le nom de la donnée apparaît dans le code. C’est aussi extensible : un futur artefact scenes.json s’ajouterait comme p1["scenes"] sans casser le code en aval.
# Charger les resultats P1 selon le contrat canonique v1.
# Deux modes EXPLICITES (P1_MODE, defaut "pipeline" — cf. section 2) :
# pipeline : artefacts EXIGES + schema VALIDE ; tout manquement -> RuntimeError clair.
# demo : session isolee ; le fallback embarque est autorise mais ANNONCE.
P1_REQUIRED_KEYS = {"name", "gender", "role", "utterance_count", "first_utterance"}
def _p1_loaders():
return {
"characters.json": "characters",
"segments.csv": "segments",
"utterances.csv": "utterances",
"voice_recommendations.csv": "voice_specs",
}
def load_p1_results(p1_dir: Path, mode: str = "pipeline") -> dict:
contract_path = p1_dir / "p1_contract.json"
if not contract_path.exists():
if mode == "pipeline":
raise RuntimeError(
f"[CONTRAT P1] {contract_path} introuvable : le notebook "
f"04-8-Lecture-Analytique n'a pas ete execute dans ce dossier "
f"(ou son export du contrat a echoue). Mode pipeline = artefacts "
f"P1 requis. Executer 04-8 d'abord, ou relancer avec P1_MODE=demo "
f"pour une session isolee de demonstration."
)
print(f"[MODE DEMO] {contract_path.name} absent de {p1_dir}/ : "
f"artefacts P1 non trouves, fallback embarque prevu.")
return {}
with open(contract_path, "r", encoding="utf-8") as f:
contract = json.load(f)
print(f"[CONTRAT P1 v{contract.get('version', '?')}] produit par "
f"{contract.get('producer', '?')} | source : "
f"{contract.get('source', {}).get('title', '?')} "
f"(Gutenberg #{contract.get('source', {}).get('gutenberg_id', '?')}, "
f"texte {contract.get('source', {}).get('text_length', '?')} car.)")
expected = contract.get("files", {})
results = {}
for fname, key in _p1_loaders().items():
path = p1_dir / fname
if not path.exists():
if mode == "pipeline":
raise RuntimeError(
f"[CONTRAT P1] {path} requis (manifeste : "
f"{expected.get(fname, '?')} entrees attendues) mais absent. "
f"Executer 04-8 d'abord ou utiliser P1_MODE=demo."
)
print(f"[MODE DEMO] {fname} absent : le fallback couvrira cette donnee.")
continue
if key == "characters":
with open(path, "r", encoding="utf-8") as f:
results[key] = json.load(f)
else:
results[key] = pd.read_csv(path)
print(f"{fname:28s} charge : {len(results[key])} entrees "
f"(manifeste : {expected.get(fname, '?')})")
# Validation du schema, mode pipeline uniquement (le contrat est EXIGE, pas suppose).
if mode == "pipeline":
chars = results.get("characters", {})
if not chars:
raise RuntimeError("[CONTRAT P1] characters.json vide : "
"aucun personnage detecte cote producteur.")
bad = [cid for cid, v in chars.items()
if not isinstance(v, dict) or not P1_REQUIRED_KEYS.issubset(v)]
if bad:
raise RuntimeError(
f"[CONTRAT P1] schema invalide pour {bad[:3]} : chaque personnage "
f"doit exposer {sorted(P1_REQUIRED_KEYS)}."
)
return results
p1 = load_p1_results(P1_OUTPUT, mode=P1_MODE)
print(f"\n[{P1_MODE.upper()}] Source des artefacts : {P1_OUTPUT}")
if p1.get("characters"):
chars_df = pd.DataFrame.from_dict(p1["characters"], orient="index")
print("\n=== Personnages detectes (artefacts P1, aucun fallback) ===")
print(chars_df[["name", "gender", "role", "utterance_count"]].to_string())
elif P1_MODE == "demo":
print("Aucun personnage charge depuis P1 : fallback embarque (annonce) ci-dessous.")[CONTRAT P1 v1.0] produit par 04-8-Lecture-Analytique.ipynb | source : Boule de Suif (Gutenberg #10746, texte 83097 car.)
characters.json charge : 9 entrees (manifeste : 9)
segments.csv charge : 342 entrees (manifeste : 342)
utterances.csv charge : 39 entrees (manifeste : 39)
voice_recommendations.csv charge : 9 entrees (manifeste : 9)
[PIPELINE] Source des artefacts : output_analytique
=== Personnages detectes (artefacts P1, aucun fallback) ===
name gender role utterance_count
comte Comte Hubert de Breville M secondaire 5
officier Officier prussien M antagoniste 2
loiseau Monsieur Loiseau M secondaire 6
boule_de_suif Elisabeth Rousset F protagoniste 1
comtesse Comtesse de Breville F secondaire 0
cornudet Cornudet M secondaire 1
soeurs Les bonnes soeurs F figurant 1
mme_loiseau Mme Loiseau F secondaire 2
carre_lamadon Monsieur Carre-Lamadon M secondaire 0
Données de fallback (mode demo uniquement)
# Donnees de fallback si les fichiers P1 ne sont pas disponibles
# Ces donnees correspondent aux resultats attendus du notebook 04-8-Lecture-Analytique.ipynb
if not p1.get("characters"):
print("=" * 72)
print("[MODE DEMO ISOLEE] Fallback embarque actif : ces donnees NE SONT PAS")
print("les artefacts P1 de 04-8. Elles reproduisent la structure du contrat v1")
print("sur 8 personnages de reference. Pour les donnees reelles : executer")
print("04-8-Lecture-Analytique.ipynb puis relancer ce notebook (P1_MODE=pipeline).")
print("=" * 72)
p1["characters"] = {
"elisabeth_rousset": {
"name": "Elisabeth Rousset",
"gender": "F",
"role": "protagoniste",
"utterance_count": 3,
"first_utterance": "Et ils ne font rien de mal aux gens ?"
},
"cornudet": {
"name": "Cornudet",
"gender": "M",
"role": "secondaire",
"utterance_count": 2,
"first_utterance": "On dit qu'ils seraient a Evreux demain."
},
"comtesse": {
"name": "Comtesse de Breville",
"gender": "F",
"role": "antagoniste",
"utterance_count": 1,
"first_utterance": "Et ils ne font rien de mal aux gens ?"
},
"comte": {
"name": "Comte de Breville",
"gender": "M",
"role": "antagoniste",
"utterance_count": 1,
"first_utterance": "Rassurez-vous, madame, ils sont corrects."
},
"narrateur": {
"name": "Narrateur",
"gender": "M",
"role": "narrateur",
"utterance_count": 0,
"first_utterance": "Pendant plusieurs jours, des lambeaux d'armees en deroute avaient traverse la ville."
},
"carre_lamadon": {
"name": "Monsieur Carre-Lamadon",
"gender": "M",
"role": "secondaire",
"utterance_count": 1,
"first_utterance": "On dit qu'ils seraient a Evreux demain."
},
"loiseau": {
"name": "Monsieur Loiseau",
"gender": "M",
"role": "figurant",
"utterance_count": 0,
"first_utterance": "Ce n'etait point de la troupe, mais des hordes debraillees."
},
"soeurs": {
"name": "Les deux soeurs",
"gender": "F",
"role": "figurant",
"utterance_count": 0,
"first_utterance": "Quelques-uns demeuraient chez eux."
},
}
print(f"Fallback: {len(p1['characters'])} personnages charges.")
chars_df = pd.DataFrame.from_dict(p1["characters"], orient="index")
print("\n=== Personnages pour le voice casting ===")
print(chars_df[["name", "gender", "role", "utterance_count"]].to_string())
=== Personnages pour le voice casting ===
name gender role utterance_count
comte Comte Hubert de Breville M secondaire 5
officier Officier prussien M antagoniste 2
loiseau Monsieur Loiseau M secondaire 6
boule_de_suif Elisabeth Rousset F protagoniste 1
comtesse Comtesse de Breville F secondaire 0
cornudet Cornudet M secondaire 1
soeurs Les bonnes soeurs F figurant 1
mme_loiseau Mme Loiseau F secondaire 2
carre_lamadon Monsieur Carre-Lamadon M secondaire 0
3. Mapping profils FishAudio vers voix TTS disponibles
Le P1 a genere des profils FishAudio (timbre, tags expressifs, preset). Cette étape mappe chaque profil vers une voix concrete sur les services TTS disponibles.
| Service | Voix FR | Avantage |
|---|---|---|
| Kokoro TTS | 10 voix (5F + 5M) | Rapide, local |
| OpenAI TTS | 6 voix multilingues | Haute qualite |
| FishAudio S2-Pro | Voice cloning, tags | Production (futur) |
# Mapping des profils FishAudio vers voix Kokoro et OpenAI
# Les voix Kokoro FR : af_sky, af_bella, af_nicole, af_sarah (F) | am_adam, am_michael (M)
# bf_emma, bf_isabella (F) | bm_george, bm_lewis (M)
# Les voix OpenAI : alloy, echo, fable, onyx, nova, shimmer
VOICE_MAP = {
"protagoniste": {
"F": {"kokoro": "af_sky", "openai": "nova", "fish_preset": "expressive_female_warm"},
"M": {"kokoro": "am_adam", "openai": "echo", "fish_preset": "expressive_male_warm"},
},
"antagoniste": {
"F": {"kokoro": "af_sarah", "openai": "shimmer", "fish_preset": "expressive_female_cold"},
"M": {"kokoro": "am_michael","openai": "onyx", "fish_preset": "expressive_male_cold"},
},
"secondaire": {
"F": {"kokoro": "af_bella", "openai": "alloy", "fish_preset": "expressive_female_neutral"},
"M": {"kokoro": "bm_george", "openai": "fable", "fish_preset": "expressive_male_neutral"},
},
"figurant": {
"F": {"kokoro": "bf_emma", "openai": "alloy", "fish_preset": "neutral_female"},
"M": {"kokoro": "bm_lewis", "openai": "fable", "fish_preset": "neutral_male"},
},
"narrateur": {
"F": {"kokoro": "af_nicole", "openai": "nova", "fish_preset": "narrator_female"},
"M": {"kokoro": "bf_isabella","openai":"echo", "fish_preset": "narrator_male"},
},
}
def assign_voice(character_id: str, char_info: dict) -> dict:
gender = char_info.get("gender", "M")
role = char_info.get("role", "figurant")
profile = VOICE_MAP.get(role, VOICE_MAP["figurant"]).get(gender, VOICE_MAP["figurant"]["M"])
return {
"character_id": character_id,
"name": char_info["name"],
"gender": gender,
"role": role,
"kokoro_voice": profile["kokoro"],
"openai_voice": profile["openai"],
"fish_preset": profile["fish_preset"],
"model_primary": "kokoro",
"model_production": "fishaudio/s2-pro",
}
# Attribuer les voix
cast_table = []
for cid, info in p1["characters"].items():
cast_table.append(assign_voice(cid, info))
cast_df = pd.DataFrame(cast_table)
print("=== Table de voice casting ===")
print(cast_df[["name", "gender", "role", "kokoro_voice", "openai_voice", "fish_preset"]].to_string(index=False))=== Table de voice casting ===
name gender role kokoro_voice openai_voice fish_preset
Comte Hubert de Breville M secondaire bm_george fable expressive_male_neutral
Officier prussien M antagoniste am_michael onyx expressive_male_cold
Monsieur Loiseau M secondaire bm_george fable expressive_male_neutral
Elisabeth Rousset F protagoniste af_sky nova expressive_female_warm
Comtesse de Breville F secondaire af_bella alloy expressive_female_neutral
Cornudet M secondaire bm_george fable expressive_male_neutral
Les bonnes soeurs F figurant bf_emma alloy neutral_female
Mme Loiseau F secondaire af_bella alloy expressive_female_neutral
Monsieur Carre-Lamadon M secondaire bm_george fable expressive_male_neutral
Exercice : Créer un mapping de voix base sur le timbre
Duree estimee : 15 minutes
Objectif : Implementer une fonction assign_voice_by_timbre qui attribue les voix non pas selon le rôle narratif, mais selon les caractéristiques de timbre decrites dans le catalogue Kokoro. L’objectif est de maximiser la diversite vocale dans les scenes de groupe.
Contexte : Le mapping actuel regroupe plusieurs personnages secondaires masculins sur la même voix am_adam. En pratique, quand Cornudet et Loiseau dialoguent, l’auditeur ne peut pas les distinguer. Une allocation basee sur le timbre garantit que les personnages interagissants ont des voix differenciees.
- Étape 1 : Lister les voix disponibles par genre avec leur timbre
- Étape 2 : Pour chaque personnage, choisir la voix la moins utilisee dans son groupe d’interaction
- Étape 3 : Verifier que deux personnages du même groupe n’ont pas la même voix
Indice : Les groupes d’interaction sont les personnages qui apparaissent dans les mêmes segments Indice : KOKORO_CATALOG contient les descriptions de timbre pour chaque voix Indice : Utilisez un dict {voice_id: count} pour suivi des allocations
# TODO etudiant : mapping de voix base sur le timbre
# Etape 1 : Lister les voix par genre
available_voices = {} # TODO etudiant : {"F": ["af_bella", ...], "M": ["am_adam", ...]}
# Etape 2 : Fonction d'allocation par diversite
def assign_voice_by_timbre(characters_dict, voices_by_gender):
"""Attribue les voix en maximisant la diversite par genre."""
allocation = {}
usage_count = {} # TODO etudiant : tracker les utilisations
for cid, info in characters_dict.items():
# TODO etudiant : choisir la voix la moins utilisee du bon genre
allocation[cid] = None # TODO etudiant
return allocation
# Etape 3 : Appliquer et verifier
# TODO etudiant : comparer avec le mapping original VOICE_MAP
print("Exercice a completer")Exercice a completer
4. Fonctions de synthese TTS
def synthesize_kokoro(text: str, voice: str = "af_sky", output_path: Optional[str] = None) -> dict:
# Synthesize via Kokoro TTS (local service, port 8191)
headers = {"Content-Type": "application/json"}
if TTS_API_KEY:
headers["Authorization"] = f"Bearer {TTS_API_KEY}"
payload = {"input": text, "model": "kokoro", "voice": voice}
t0 = time.perf_counter()
resp = requests.post(f"{TTS_KOKORO_URL}/v1/audio/speech", json=payload, headers=headers, timeout=60)
elapsed = (time.perf_counter() - t0) * 1000
resp.raise_for_status()
audio_bytes = resp.content
if output_path:
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
with open(output_path, "wb") as f:
f.write(audio_bytes)
return {"model": "kokoro", "voice": voice, "latency_ms": elapsed,
"audio_size_bytes": len(audio_bytes), "status": "ok"}
def synthesize_openai(text: str, voice: str = "alloy", output_path: Optional[str] = None) -> dict:
# Synthesize via OpenAI TTS API (cloud)
from openai import OpenAI
client = OpenAI()
t0 = time.perf_counter()
response = client.audio.speech.create(model="tts-1", voice=voice, input=text)
elapsed = (time.perf_counter() - t0) * 1000
audio_bytes = response.content
if output_path:
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
with open(output_path, "wb") as f:
f.write(audio_bytes)
return {"model": "openai/tts-1", "voice": voice, "latency_ms": elapsed,
"audio_size_bytes": len(audio_bytes), "status": "ok"}
print("Fonctions TTS chargees : synthesize_kokoro, synthesize_openai")Fonctions TTS chargees : synthesize_kokoro, synthesize_openai
5. Generation des echantillons audio par personnage
Pour chaque personnage detecte en P1, on genere un echantillon audio avec sa voix attribuee. Le texte utilise est la première replique du personnage (ou un texte narratif pour le narrateur).
Tirades de casting — pourquoi une ligne ne mesure rien
Les first_utterance de P1 sont des répliques d’UNE ligne (~10 syllabes). Suffisant pour reconnaître un timbre à l’oreille, insuffisant pour l’instrument mélodique : le détecteur structurel (MIN_SYLL_FOR_STRUCTURE = 60) s’abstiendrait sur les 18 clips, et la section 6bis n’aurait rien à mesurer. Or le casting se joue précisément sur ce que mesure l’instrument : une voix de personnage qui dérive vers le bourdon sur une tirade est un casting raté — sur une ligne, ça ne se voit pas.
Les clés couvrent les deux jeux d’identifiants : ceux de la détection réelle de P1 (boule_de_suif, officier, comtesse, comte, cornudet, carre_lamadon, loiseau, soeurs) et ceux du fallback de démo (elisabeth_rousset, narrateur, …) — quel que soit le mode, chaque personnage chargé trouve sa tirade.
Huit personnages sur neuf reçoivent donc une tirade (165-214 syllabes écrites) dans le ton de son rôle — Mme Loiseau, détectée par P1 mais absente du dictionnaire, retombe sur le texte générique court (sa first_utterance « charmante compagne » est un fragment de deux mots), annoncé comme telle dans les sorties — Boule de Suif, Maupassant, 1880, domaine public. C’est une décision de P2 (ce notebook), pas de P1 : si P1 a tourné, ses first_utterance restent la référence de lecture, la tirade n’est que l’échantillon de synthèse. Pourquoi si longues : ce run mesure un rendement syllabique écrit→rendu de 56-62 % chez Kokoro et 38-56 % chez OpenAI (fusion des noyaux par la diction fluide) — pour rendre au moins 60 syllabes au moteur le plus fusionnant, il faut écrire au moins ~160 ; à 80-100 écrites, OpenAI serait resté sous le plancher structurel.
# Tirades de casting -- une par personnage, ton du role (decision P2, independante de P1)
CASTING_TIRADES = {
"elisabeth_rousset": "Vous croyez donc que je suis restee la par plaisir, monsieur ? J'ai failli, oui, j'ai failli pour vous sauver tous, et voila comment on me le rend. On me meprise, on me chasse de la table, on me laisse mourir de faim dans le coin de cette voiture, pendant que chacun de vous digere a son aise. Et c'est moi, la garce, c'est moi, la souillon ! Oui, j'ai failli, mais c'est pour vous tous que j'ai failli ; et voila la reconnaissance. Personne ici ne me serre la main, personne ne me dit un mot de merci. Ah, par exemple, c'est dur, c'est bien dur a porter pour une femme honnete.",
"cornudet": "Ah ca, messieurs, vous croyez que la Republique est morte parce que des lambeaux d'armees passent sur les routes ? Vous vous trompez lourdement. Le peuple est la, tout entier, qui attend son heure, et quand elle sonnera, ni les princes, ni les pretres, ni les bourgeois gras ne l'arreteront. Je le dis depuis vingt ans, et je le repete en vidant mon verre : tenez-vous pret. Ce soir meme, vous verrez : les fenetres s'ouvriront, les tambours battront la levee en masse, et vous comprendrez ce que vaut un peuple entier qui se leve pour defendre sa terre, sa maison et sa liberte contre tous les princes de l'Europe.",
"comtesse": "Je vous avoue, madame, que tout cela me parait bien effrayant. Nous avons quitte Rouen avec des passeports en regle, une lettre du general, des precautions dont une famille de notre condition ne s'etait jamais entouree ; et voici qu'un simple sous-officier prussien dispose de notre sort, retient notre voiture, nous conte ses caprices. C'est une chose insensee, et je n'y comprends rien. Nous avions pourtant des lettres, des amis partout, une maison tenue comme il faut ; et nous voila a marchander notre passage avec un sous-officier dans une auberge de village. Si je racontais cela a Paris, personne ne voudrait jamais le croire.",
"comte": "Rassurez-vous, mesdames, rassurez-vous. Ces gens-la sont corrects, d'une correction meme remarquable. Ils ont leurs ordres, ils les executent, voila tout. La situation est desagreable, j'en conviens, mais voyez large : une nuit d'attente, peut-etre deux, et nous reprendrons la route de Dieppe. En attendant, gardons notre maintien, c'est la moindre des choses. J'ai vecu la campagne de Crimea, mesdames, j'ai vu des revers autrement graves, et la France est restee debout. Une carriole arretee sur la route de Dieppe, est-ce que c'est une affaire d'Etat ? Gardons le calme, gardons la tenue, et tout s'arrangera.",
"narrateur": "Pendant plusieurs jours, des lambeaux d'armees en deroute avaient traverse la ville. Ce n'etaient point des troupes, mais des hordes debandees. Les hommes avaient la barbe longue, sale, des uniformes en loques et, sur leur visage defait, l'expression de la bete traquee. Des francais tiraient encore par bandes isolees, brillant dans leurs casaques trouees, coiffes de bonnets de police. La ville, derriere eux, s'etait rassise dans une attente epuisee ; on comptait les heures, on epiait les bruits de la route, et chaque voyageur qui franchissait le seuil de l'auberge apportait sa nouvelle confuse, sa frayeur ou sa colere, sans que personne put rien verifier de rien.",
"carre_lamadon": "Moi, je suis industriel, messieurs, et je connais les affaires. Ces histoires de guerre, c'est de la politique, et la politique, ca nous depasse. Ce qu'il faut voir, c'est le commerce : mes cotonniers attendent une commande a Paris, mes ateliers tournent au ralenti, et chaque jour de retard me coute plus cher qu'une contribution. Sortons d'ici, et le plus vite sera le mieux. On perd son temps ici, messieurs, on perd son argent, et je ne parle pas des commandes qui coulent pendant que nous discutons politique. Un homme d'affaires n'a qu'une politique, croyez-moi : reprendre la route, remplir ses carnets, et faire tourner ses ateliers a plein regime.",
"loiseau": "Bah, mesdames, en voila des figures ! On dirait qu'on mene au supplice. Faut pas prendre les choses si tragiquement, par exemple. Moi, marchand de vin, j'ai vu passer bien du monde, et je vous assure que les Prussiens, c'est des gens comme nous, qui boivent, qui mangent et qui paient quand ils peuvent. Tenez, je vous parie un voyage que d'ici demain, nous roulons. Et puis, entre nous, la ou nous allons, on trouvera bien une table, un lit, une bouteille, et des histories a raconter au retour. Le voyage, mesdames, c'est comme le vin : ca se juge mieux quand ca dure, et a condition de le payer le juste prix, ca ne fait de mal a personne.",
"boule_de_suif": "Non, monsieur, non ! Vous vous trompez lourdement si vous croyez que je cederai parce que tout le monde ici le desire. J'ai donne ce que j'avais quand il fallait donner ; on m'a prise pour une servante au depart, et maintenant on voudrait me pousser comme une porte quand la maison brule. Je ne suis pas la bonne de messieurs ! Chacun de vous a sa femme, sa place, sa consideration ; moi je n'ai que mon corps et ma volonte, et l'un ne se donne pas parce que la diligence est arretee. Qu'on me laisse tranquille avec vos grandes paroles, votre bien commun, vos raisonnements d'hommes honorables. Je sais ce que je vaux, monsieur, je sais ce qu'on me demande, et je dis non, je repete non, autant de fois qu'il faudra jusqu'a ce qu'on me fiche la paix.",
"officier": "Madame, messieurs, je vous prie de m'ecouter avec attention. Je ne discute ni vos passeports, ni vos titres, ni vos raisons de voyager : ces choses-la ne m'interessent pas. J'ai recu un ordre, j'execute un ordre, et cet ordre ne concerne qu'une seule personne dans cette voiture. Le reste ne me regarde pas. Vous pouvez manger, dormir, vous plaindre aupres de qui bon vous semblera ; la diligence partira quand j'en aurai decide ainsi, et pas un quart d'heure plus tot. Je vous souhaite un bon sejour a Totes. La ville est calme, l'hotel est propre, et vous avez tout le temps devant vous. Quant a moi, je ne suis pas presse : la guerre m'a appris une chose, c'est que tout vient a point pour qui sait attendre.",
"soeurs": "Nous sommes entre les mains de la Providence, madame. Ce qui doit arriver arrivera, et rien n'arrive qui ne soit permis d'en haut. Si le bon Dieu veut que nous passions la nuit ici, c'est qu'il a ses raisons, que nous ne comprenons pas, mais auxquelles il faut se soumettre. Nous avons notre regle, nous le disons notre office, et le reste nous regarde peu. Les pauvres gens de la paroisse attendent notre retour, les malades reclamons notre temps, et le bon Dieu, qui voit tout, saura bien faire passer sa charrette quand il voudra bien. Nous prions pour ces messieurs, et nous attendons en paix l'heure de sa sainte volonte.",
}
def _count_syllables(fr: str) -> int:
# estimation par groupes vocaliques (l'instrument compte sur l'audio rendu)
return len(re.findall(r'[aeiouyàâäéèêëîïôöûüùœ]+', fr.lower()))
print(f'{len(CASTING_TIRADES)} tirades definies, estimation syllabique :')
for cid, t in CASTING_TIRADES.items():
print(f' {cid:<20} ~{_count_syllables(t):>3} syll. | {t[:52]}...')10 tirades definies, estimation syllabique :
elisabeth_rousset ~155 syll. | Vous croyez donc que je suis restee la par plaisir, ...
cornudet ~165 syll. | Ah ca, messieurs, vous croyez que la Republique est ...
comtesse ~186 syll. | Je vous avoue, madame, que tout cela me parait bien ...
comte ~170 syll. | Rassurez-vous, mesdames, rassurez-vous. Ces gens-la ...
narrateur ~184 syll. | Pendant plusieurs jours, des lambeaux d'armees en de...
carre_lamadon ~186 syll. | Moi, je suis industriel, messieurs, et je connais le...
loiseau ~176 syll. | Bah, mesdames, en voila des figures ! On dirait qu'o...
boule_de_suif ~214 syll. | Non, monsieur, non ! Vous vous trompez lourdement si...
officier ~200 syll. | Madame, messieurs, je vous prie de m'ecouter avec at...
soeurs ~171 syll. | Nous sommes entre les mains de la Providence, madame...
synthesis_results = []
audio_files = {}
for cast in cast_table:
cid = cast["character_id"]
name = cast["name"]
kokoro_voice = cast["kokoro_voice"]
openai_voice = cast["openai_voice"]
# Tirade de casting en priorite : une ligne ne mesure rien melodiquement
sample_text = CASTING_TIRADES.get(cid) or p1["characters"][cid].get("first_utterance", "")
if not sample_text or len(sample_text) < 20:
sample_text = "Bonjour, je suis un personnage de cette histoire."
print(f"[{name}] voice={kokoro_voice} | {sample_text[:60]}...")
kokoro_path = str(VOICE_DIR / f"{cid}_kokoro.mp3")
try:
result = synthesize_kokoro(sample_text, voice=kokoro_voice, output_path=kokoro_path)
result["character_id"] = cid
result["character_name"] = name
synthesis_results.append(result)
audio_files[cid] = kokoro_path
print(f" Kokoro: {result['latency_ms']:.0f}ms, {result['audio_size_bytes']/1024:.1f}KB")
except Exception as e:
print(f" Kokoro ERREUR: {e}")
synthesis_results.append({"character_id": cid, "character_name": name,
"model": "kokoro", "status": "error", "error": str(e)[:80]})
openai_path = str(VOICE_DIR / f"{cid}_openai.mp3")
try:
result = synthesize_openai(sample_text, voice=openai_voice, output_path=openai_path)
result["character_id"] = cid
result["character_name"] = name
synthesis_results.append(result)
print(f" OpenAI: {result['latency_ms']:.0f}ms, {result['audio_size_bytes']/1024:.1f}KB")
except Exception as e:
print(f" OpenAI ERREUR: {e}")
synthesis_results.append({"character_id": cid, "character_name": name,
"model": "openai", "status": "error", "error": str(e)[:80]})
print(f"\nEchantillons generes: {len(synthesis_results)} "
f"({sum(1 for r in synthesis_results if r['status']=='ok')} OK)")[Comte Hubert de Breville] voice=bm_george | Rassurez-vous, mesdames, rassurez-vous. Ces gens-la sont cor...
Kokoro: 4121ms, 543.0KB
OpenAI: 4578ms, 581.2KB
[Officier prussien] voice=am_michael | Madame, messieurs, je vous prie de m'ecouter avec attention....
Kokoro: 3988ms, 798.4KB
OpenAI: 2655ms, 671.6KB
[Monsieur Loiseau] voice=bm_george | Bah, mesdames, en voila des figures ! On dirait qu'on mene a...
Kokoro: 3386ms, 551.3KB
OpenAI: 3307ms, 608.6KB
[Elisabeth Rousset] voice=af_sky | Non, monsieur, non ! Vous vous trompez lourdement si vous cr...
Kokoro: 3757ms, 666.0KB
OpenAI: 5066ms, 704.6KB
[Comtesse de Breville] voice=af_bella | Je vous avoue, madame, que tout cela me parait bien effrayan...
Kokoro: 3836ms, 671.7KB
OpenAI: 2665ms, 603.0KB
[Cornudet] voice=bm_george | Ah ca, messieurs, vous croyez que la Republique est morte pa...
Kokoro: 3421ms, 548.7KB
OpenAI: 2698ms, 592.9KB
[Les bonnes soeurs] voice=bf_emma | Nous sommes entre les mains de la Providence, madame. Ce qui...
Kokoro: 3670ms, 584.3KB
OpenAI: 2791ms, 599.6KB
[Mme Loiseau] voice=af_bella | Bonjour, je suis un personnage de cette histoire....
Kokoro: 2364ms, 60.4KB
OpenAI: 1930ms, 47.2KB
[Monsieur Carre-Lamadon] voice=bm_george | Moi, je suis industriel, messieurs, et je connais les affair...
Kokoro: 3482ms, 586.5KB
OpenAI: 4360ms, 627.8KB
Echantillons generes: 18 (18 OK)
6. Écoute des échantillons — Validation du casting
Cette étape est l’étape de validation humaine du pipeline P2 : un opérateur (ou un·e étudiant·e en production audiovisuelle) écoute chaque échantillon Kokoro et confirme que la voix attribuée colle au profil sémantique du personnage (timbre, registre, expressivité). L’affichage IPython.display.Audio permet l’écoute inline dans le notebook Jupyter sans ouvrir de lecteur externe.
Mesures observées sur cette exécution
Sur les 9 personnages × 1 voix Kokoro principale = 9 audio objects générés, voici les diagnostics verbatim de la cellule code :
| # | Personnage | Rôle | Voix Kokoro | Statut fichier |
|---|---|---|---|---|
| 1 | Comte Hubert de Breville | secondaire | bm_george |
✅ 543 KB |
| 2 | Officier prussien | antagoniste | am_michael |
✅ 798 KB |
| 3 | Monsieur Loiseau | secondaire | bm_george |
✅ 551 KB |
| 4 | Elisabeth Rousset | protagoniste | af_sky |
✅ 666 KB, audio object affiché |
| 5 | Comtesse de Breville | secondaire | af_bella |
✅ 672 KB |
| 6 | Cornudet | secondaire | bm_george |
✅ 549 KB |
| 7 | Les deux soeurs | figurant | bf_emma |
✅ 584 KB |
| 8 | Mme Loiseau | secondaire | af_bella |
✅ 60 KB — tirade générique : pas de CASTING_TIRADES pour ce cid, first_utterance trop courte |
| 9 | Monsieur Carre-Lamadon | secondaire | bm_george |
✅ 587 KB |
9/9 échantillons audibles (la condition kokoro_path.stat().st_size > 1000 est satisfaite pour tous les personnages — les tirades font 543 à 798 KB, et même le clip générique de Mme Loiseau (60 KB, annoncé comme tel dans les sorties) passe le seuil de 1 KB qui filtre les réponses Kokoro dégénérées vides).
Conflit de diversité détecté — bm_george partagé
Le casting comporte un conflit de diversité : la voix bm_george est attribuée à 4 personnages (Comte, Loiseau, Cornudet, Carre-Lamadon — tous secondaires masculins), et af_bella à 2 (Comtesse, Mme Loiseau). Quand deux de ces personnages dialoguent dans la même scène, l’auditeur ne peut pas les distinguer. C’est précisément le défaut que l’exercice ### Exercice : Créer un mapping de voix basé sur le timbre (cellule suivante) demande de résoudre par allocation par diversité — le conflit est ici d’autant plus visible que la détection réelle du conte produit cinq hommes dont quatre secondaires.
Pourquoi écouter et pas seulement mesurer
La latence (Kokoro avg ~3,6 s dans cette session, runtime machine-dep, cf. sorties ci-dessus) et la taille audio (avg ~557 KB) sont des métriques objectives, mais elles ne disent rien sur la qualité perçue :
- Timbre :
bf_isabellapour le narrateur est volontairement plus grave queaf_skypour la protagoniste — un auditeur humain valide que la hiérarchie narrative est audible. - Expressivité : Kokoro gère mal les cris/whispers (pas de tags
[laugh]/[sigh]), doncam_michael(Comte, antagoniste froid) peut sonner trop neutre sur une réplique sarcastique. FishAudio S2-Pro en production résoudra ça viaexpressive_male_cold+ tags. - Compatibilité scène : deux personnages côte à côte avec la même voix = confusion auditive, même si chaque échantillon isolé est parfait.
L’écoute reste donc le seul validator final d’un casting TTS, et cette cellule est l’endroit où elle s’opère dans le pipeline Epic #1028.
# Ecouter chaque echantillon Kokoro (principal pour le demo)
for cast in cast_table:
cid = cast["character_id"]
name = cast["name"]
role = cast["role"]
kokoro_voice = cast["kokoro_voice"]
kokoro_path = VOICE_DIR / f"{cid}_kokoro.mp3"
print(f"\n{'='*60}")
print(f"Personnage: {name} ({role}) | Voix Kokoro: {kokoro_voice}")
print(f"{'='*60}")
if kokoro_path.exists() and kokoro_path.stat().st_size > 1000:
display_audio_file(kokoro_path)
else:
print(" (echantillon non disponible)")
============================================================
Personnage: Comte Hubert de Breville (secondaire) | Voix Kokoro: bm_george
============================================================
============================================================
Personnage: Officier prussien (antagoniste) | Voix Kokoro: am_michael
============================================================
============================================================
Personnage: Monsieur Loiseau (secondaire) | Voix Kokoro: bm_george
============================================================
============================================================
Personnage: Elisabeth Rousset (protagoniste) | Voix Kokoro: af_sky
============================================================
============================================================
Personnage: Comtesse de Breville (secondaire) | Voix Kokoro: af_bella
============================================================
============================================================
Personnage: Cornudet (secondaire) | Voix Kokoro: bm_george
============================================================
============================================================
Personnage: Les bonnes soeurs (figurant) | Voix Kokoro: bf_emma
============================================================
============================================================
Personnage: Mme Loiseau (secondaire) | Voix Kokoro: af_bella
============================================================
============================================================
Personnage: Monsieur Carre-Lamadon (secondaire) | Voix Kokoro: bm_george
============================================================
6bis. La mélodie du casting — le verdict objectif avant l’oreille
Le patron est établi en P0 (04-7 section 7bis, PR #12579) : l’instrument syllable_pitch (v4/prosody_lab, importé, jamais recopié) transcrit un clip en une note par syllabe et rend effective_notes, top3_note_pct, motif3_repeat_pct, melodic_span_st et un verdict. L’axe structurel dit DRONE dès qu’UN critère franchit son seuil (notes < 7, ou top-3 ≥ 65 %, ou motifs ≥ 60 %) ; un clip sous 60 syllabes rend INSUFFICIENT — abstention, pas acquittement.
Pour un voice casting, l’enjeu est spécifique : chaque voix doit rester elle-même sur une tirade. Une voix qui tient une ligne n’a encore rien prouvé ; c’est sur la durée que certaines dérivent vers la psalmodie. Le tableau ci-dessous juxtapose donc, par personnage et par moteur, les colonnes de latence (section 7) et les colonnes mélodiques — un casting peut gagner à l’une et perdre à l’autre.
# Controle positif -- le detecteur DRONE attrape-t-il un drone synthetique ?
# (patron 02-2 et 04-7 7bis : prouver l'instrument AVANT de mesurer les clips reels)
import sys
import random
import tempfile
from pathlib import Path
import soundfile as sf
_candidates = [
Path('v4/prosody_lab/syllable_pitch.py'), # CWD = 04-Applications
Path.cwd() / 'v4' / 'prosody_lab' / 'syllable_pitch.py',
]
PROSODY_FILE = next((c for c in _candidates if c and c.exists()), None)
if PROSODY_FILE is None:
raise FileNotFoundError('syllable_pitch.py introuvable (attendu: v4/prosody_lab/)')
sys.path.insert(0, str(PROSODY_FILE.parent))
import syllable_pitch as sp
random.seed(0)
N_SYLL = 120
drone_seq = [45 + random.choice([0, 0, 0, 1, -1]) for _ in range(N_SYLL)] # A2 +/- 1 demi-ton
varied_seq = [45 + random.choice(range(-8, 13)) for _ in range(N_SYLL)] # ambitus 21 demi-tons
tmp = Path(tempfile.mkdtemp(prefix='tts9_melody_ctrl_'))
ctrl = {}
for name, seq in (('drone_synth (A2 +/- 1 st)', drone_seq), ('varied_synth (21 st ambitus)', varied_seq)):
y, sr = sp._synth(seq, syll_per_s=3.0)
wav = tmp / (name.split()[0] + '.wav')
sf.write(wav, y, sr)
a = sp.analyze_syllables(str(wav))
ctrl[name] = a
print('=== ' + name + ' ===')
sp.print_score_table(a)
print()
drone_ok = ctrl['drone_synth (A2 +/- 1 st)']['verdict'] == 'DRONE'
varied_ok = ctrl['varied_synth (21 st ambitus)']['verdict'] != 'DRONE'
print('CONTROLE POSITIF : drone_synth -> DRONE ? ' + ('OUI' if drone_ok else 'NON'))
print('CONTROLE POSITIF : varied_synth -> non-DRONE ? ' + ('OUI' if varied_ok else 'NON'))
assert drone_ok and varied_ok, 'instrument defectue : il ne separe pas drone et varied'
print()
print("L'instrument separe bien un drone d'une voix variee : les verdicts sur les 16 clips reels sont lisibles tel quel.")=== drone_synth (A2 +/- 1 st) ===
=== drone_synth (40.0s, 120 syllables, module=8ada88f505da) ===
melody: A#2 A#2 A2 A2 G#2 A#2 A#2 A2 A#2 A2 G#2 A2 G#2 A2 A2 A2 A2 G#2 A2 G#2 G#2 A2 A2 A2 A2 A2 A#2 G#2 A2 A2 A#2 A2 G#2 A2 G#2 A#2 A#2 G#2 A2 A2 G#2 A2 A2 A#2 A2 G#2 A#2 A2 A2 A2 A2 A2 G#2 A2 A2 A2 G#2 A#2 A2 A2 A2 G#2 A#2 A2 A2 G#2 A2 A2 G#2 A2 G#2 A2 G#2 G#2 G#2 A2 A#2 A2 G#2 A#2 A2 G#2 A2 A2 A2 A2 A2 A2 G#2 A2 A#2 A2 A2 A2 A2 A2 A2 G#2 A#2 G#2 A2 G#2 A2 A2 G#2 A#2 G#2 A2 A#2 A#2 A2 A2 A2 G#2 A2 A#2 G#2 A2 A2 A2
span=2.0 st (p5-p95 2.0 st) | motion=0.75 st/syll | flat-transitions=36.1% | rate=3.0/s | median=109.9 Hz
structure: 2.7 notes effectives sur 3 distinctes | top-3 ['A2', 'G#2', 'A#2'] = 100.0% | motifs 3-notes repetes 96.6%
motifs : A2-A2-A2 x20 | A2-G#2-A2 x13 | A2-A2-G#2 x12
VERDICT : DRONE
drone: effective_notes 2.7 < 7.0
drone: top3_note_pct 100.0% >= 65.0%
drone: motif3_repeat_pct 96.6% >= 60.0%
=== varied_synth (21 st ambitus) ===
=== varied_synth (40.0s, 120 syllables, module=8ada88f505da) ===
melody: C#2 A2 E2 G#2 C3 F#2 B2 D3 D2 E2 F2 G#2 D2 G3 A3 F#3 G#3 D#2 C#2 E2 A2 G2 G#3 G3 E2 C#2 D#2 C2 E2 D2 C#2 C#2 G2 F#2 E2 E2 G2 D2 C#2 F#3 D3 G#3 E2 A2 D#2 G#2 D#2 A2 A#2 C3 D3 F#2 D2 F3 D#3 D2 G#2 E2 C#3 G2 A2 C3 E3 G3 F#2 G2 D2 F#2 F#2 B2 F3 A2 E2 G#3 D#3 F#2 C#2 E3 D3 G3 F3 A#2 A2 C3 C#3 A2 F2 F#2 C#2 D#2 D#2 B2 D2 F#2 A2 F2 G#2 E3 C3 G#3 A#2 C3 G3 A3 G#3 F2 A#2 C#3 D3 A3 D#2 C#2 G#2 G2 B2 F#2 G#2 G#2 A3 D#3
span=21.0 st (p5-p95 19.0 st) | motion=5.24 st/syll | flat-transitions=5.0% | rate=3.0/s | median=103.8 Hz
structure: 19.6 notes effectives sur 22 distinctes | top-3 ['E2', 'F#2', 'C#2'] = 24.2% | motifs 3-notes repetes 0.0%
motifs : C#2-A2-E2 x1 | A2-E2-G#2 x1 | E2-G#2-C3 x1
VERDICT : EXPRESSIVE
CONTROLE POSITIF : drone_synth -> DRONE ? OUI
CONTROLE POSITIF : varied_synth -> non-DRONE ? OUI
L'instrument separe bien un drone d'une voix variee : les verdicts sur les 16 clips reels sont lisibles tel quel.
Lecture du contrôle positif
Le signal drone (A2 ± 1 demi-ton) est classé DRONE, le signal varié (ambitus 21 demi-tons) passe sans signal. Condition minimale pour lire les verdicts ci-dessous : un DRONE sur une voix de personnage est un défaut de la voix (ou du moteur), pas un artefact de l’instrument.
# Mesure melodique des clips reels (un clip par personnage et par moteur)
melody_rows = []
for res in synthesis_results:
if res.get('status') != 'ok':
continue
engine = res['model'].split('/')[0] # kokoro | openai
mp3 = VOICE_DIR / f"{res['character_id']}_{engine}.mp3"
if not mp3.exists():
print(f" (clip absent, saute : {mp3.name})")
continue
a = sp.analyze_syllables(str(mp3))
melody_rows.append({
'character': res['character_name'], 'model': res['model'], 'voice': res.get('voice', '?'),
'latency_ms': round(res['latency_ms']), 'size_kb': round(res['audio_size_bytes'] / 1024, 1),
'n_syll': a['n_syllables'],
'effective_notes': a['effective_notes'],
'top3_note_pct': a['top3_note_pct'],
'motif3_repeat_pct': a['motif3_repeat_pct'],
'melodic_span_st': a['melodic_span_st'],
'verdict': a['verdict'],
'module_sha': a['module_sha'][:8],
})
df_melody = pd.DataFrame(melody_rows)
print(f'=== Melodie du casting -- juxtapose aux metriques de la section 7 ({len(df_melody)} clips) ===')
print(df_melody.to_string(index=False))
# Verdict par voix (pour l'export de casting) : le pire des deux moteurs,
# car une voix re-castee doit survivre au moteur qui la sert
voice_melody = {}
for (cname, voice), grp in df_melody.groupby(['character', 'voice']):
bad = grp[grp['verdict'] != 'EXPRESSIVE']
worst = bad.iloc[0] if len(bad) else grp.iloc[0]
voice_melody[(cname, voice)] = worst['verdict']=== Melodie du casting -- juxtapose aux metriques de la section 7 (18 clips) ===
character model voice latency_ms size_kb n_syll effective_notes top3_note_pct motif3_repeat_pct melodic_span_st verdict module_sha
Comte Hubert de Breville kokoro bm_george 4121 543.0 98 9.6 53.1 15.6 13.80 EXPRESSIVE 8ada88f5
Comte Hubert de Breville openai/tts-1 fable 4578 581.2 72 16.0 31.9 0.0 18.00 EXPRESSIVE 8ada88f5
Officier prussien kokoro am_michael 3988 798.4 112 11.6 40.2 12.7 16.70 EXPRESSIVE 8ada88f5
Officier prussien openai/tts-1 onyx 2655 671.6 102 8.2 53.9 29.0 9.54 EXPRESSIVE 8ada88f5
Monsieur Loiseau kokoro bm_george 3386 551.3 100 9.9 52.0 26.5 13.65 EXPRESSIVE 8ada88f5
Monsieur Loiseau openai/tts-1 fable 3307 608.6 66 14.0 31.8 0.0 19.20 EXPRESSIVE 8ada88f5
Elisabeth Rousset kokoro af_sky 3757 666.0 127 10.0 48.0 20.8 13.80 EXPRESSIVE 8ada88f5
Elisabeth Rousset openai/tts-1 nova 5066 704.6 107 8.4 60.7 39.0 25.30 EXPRESSIVE 8ada88f5
Comtesse de Breville kokoro af_bella 3836 671.7 116 9.1 49.1 32.5 22.00 EXPRESSIVE 8ada88f5
Comtesse de Breville openai/tts-1 alloy 2665 603.0 103 12.1 46.6 8.9 20.92 EXPRESSIVE 8ada88f5
Cornudet kokoro bm_george 3421 548.7 102 9.6 47.1 22.0 13.10 EXPRESSIVE 8ada88f5
Cornudet openai/tts-1 fable 2698 592.9 71 14.4 29.6 0.0 19.45 EXPRESSIVE 8ada88f5
Les bonnes soeurs kokoro bf_emma 3670 584.3 106 11.0 43.4 24.0 16.05 EXPRESSIVE 8ada88f5
Les bonnes soeurs openai/tts-1 alloy 2791 599.6 95 11.7 41.1 8.6 19.10 EXPRESSIVE 8ada88f5
Mme Loiseau kokoro af_bella 2364 60.4 9 NaN NaN NaN 6.55 INSUFFICIENT 8ada88f5
Mme Loiseau openai/tts-1 alloy 1930 47.2 9 NaN NaN NaN 11.75 INSUFFICIENT 8ada88f5
Monsieur Carre-Lamadon kokoro bm_george 3482 586.5 108 9.7 52.8 26.4 13.10 EXPRESSIVE 8ada88f5
Monsieur Carre-Lamadon openai/tts-1 fable 4360 627.8 75 15.2 30.7 8.2 19.20 EXPRESSIVE 8ada88f5
Lecture : le casting au verdict mélodique
Sur les 16 clips de tirade, personne ne dérive : le plancher de 60 syllabes est passé partout (66 syllabes rendues au minimum) — 16 EXPRESSIVE, zéro DRONE. Les 2 INSUFFICIENT sur 18 sont les deux clips de la tirade générique de Mme Loiseau (9 syllabes rendues — aucun texte de tirade écrit pour ce personnage, first_utterance trop courte), annoncée comme telle dans les sorties : c’est la démonstration en acte du principe de cette section — une ligne ne mesure rien mélodiquement. La section ne réclame donc aucun re-casting ; le contrôle positif garantit que ce vert n’est pas la surdité de l’instrument.
Deux moteurs, deux mélodies : l’ambitus OpenAI court de 9,5 à 25,3 demi-tons (moyenne ~18,8 st) contre 13,1-22,0 chez Kokoro (~15,3 st) — comme sur le benchmark 04-7, les voix cloud couvrent une tessiture plus large (~1,2×). Et le détail qui mérite l’oreille avant l’export : la voix la plus étroite des tirades est onyx/Officier prussien (9,5 st) — la voix « froide » choisie pour l’antagoniste est structurellement la plus monotone — quand la plus large est nova/Elisabeth Rousset (25,3 st). Cohérent avec les rôles, mais c’est exactement le genre de ligne qu’une écoute humaine confirme et que la colonne rend visible avant.
Le rendement syllabique diverge : sur des tirades identiques (165-214 syllabes écrites), Kokoro rend 98-127 syllabes quand OpenAI en rend 66-107 (~0,8× ; 56-62 % contre 38-56 % de l’écrit) — la diction cloud fusionne davantage les noyaux, déjà observé en 04-7. Conséquence pratique : un plancher « 60 syllabes rendues » se dimensionne sur l’audio rendu du moteur le plus fusionnant, jamais sur le texte écrit.
7. Tableau comparatif des résultats
Cette étape agrège les 18 mesures de synthèse (9 personnages × 2 modèles TTS) en un tableau comparatif qui sert de bilan quantitatif du casting P2. C’est le contrat de mesure P4 (génération TTS) : P4 consommera ces latences/tailles pour calibrer son propre cache et son timeout.
Mesures observées verbatim
18/18 échantillons générés avec succès (status ok partout, 0 erreur Kokoro, 0 erreur OpenAI) — sur les tirades de casting de la section 5 (une tirade écrite par personnage ; Mme Loiseau retombe sur le texte générique court, annoncé dans les sorties). Résumé par modèle, calculé depuis synthesis_results :
| Modèle | n_samples | avg_latency | avg_size_kb |
|---|---|---|---|
kokoro |
9 | ~3,6 s (runtime) | ~557 KB |
openai/tts-1 |
9 | ~3,3 s (runtime) | ~560 KB |
Lecture des deltas
Latence — moyennes proches, dispersions asymétriques : les moyennes sont quasi identiques (Kokoro 3558 ms, OpenAI 3339 ms sur ce run), mais la bande dit la différence : sur les tirades, Kokoro tient 3386-4121 ms (735 ms d’écart entre extrêmes, service local chaud) quand OpenAI s’étale de 2655 à 5066 ms (~3× plus large) — la même asymétrie de régularité que le benchmark 04-7 mesurait sur ses registres. Ces latences sont des runtimes machine-dep (réseau, matériel) : les valeurs exactes de la session vivent dans les sorties de la cellule ci-dessus, la prose n’en retient que la structure.
Taille audio — ne se déduit pas du compte syllabique : Kokoro rend 98-127 syllabes par tirade quand OpenAI en rend 66-107 (~0,8×) — la diction cloud fusionne davantage les noyaux vocales. Et pourtant les fichiers de tirade font des tailles comparables (543-798 KB Kokoro, 581-705 KB OpenAI) : la taille d’un clip dépend du débit d’élocution et des pauses du moteur, pas du seul compte syllabique rendu. Un cache P4 dimensionné « caractères → octets » devra donc calibrer par moteur.
Fiabilité : 18/18 succès sur cette exécution (status
okpartout), 0 retry, 0 fallback — mais unokde synthèse ne préjuge pas du contenu : c’est le verdict mélodique de la section 6bis (16 EXPRESSIVE / 2 INSUFFICIENT) qui atteste que l’audio rendu soutient l’analyse. La différence de fiabilité se fera sur P4 production (342 segments × 14 langues potentielle) où la stabilité du runtime local Kokoro devient un atout face au rate-limit OpenAI.
Pourquoi cette agrégation est dans P2 et pas dans P4
Le tableau compare les performances au niveau personnage × modèle, granularité qui n’existe qu’à P2 (P4 ne sait que générer, pas comparer). C’est donc à P2 de fixer les chiffres de référence que P4 consultera pour choisir le modèle par défaut (kokoro pour démo locale rapide, openai/tts-1 pour livraison cloud) — complétés depuis la section 6bis par le verdict mélodique par voix, que ni la latence ni la taille ne prédisent. Cette séparation des préoccupations évite à P4 de refaire les mesures — économie de 16 appels API redondants.
Lecture critique : la latence OpenAI ne suit pas la longueur du clip
Sur ce run, le clip OpenAI le plus rapide et le plus lent de la bande (voir le tableau ci-dessus) rendent des durées d’audio quasi égales pour une latence ~2× — la longueur du texte n’explique donc pas la dispersion cloud : c’est de la variance de service (établissement de session, charge des régions d’inférence). P4 doit calibrer son timeout sur le pire cas observé de la bande (sorties de la session, jamais une constante de prose) et non sur la moyenne, et traiter le meilleur cas comme un best-case jamais garanti — le cache côté serveur observé au run précédent sur des répliques d’une ligne n’a plus lieu sur des tirades de 30-40 s.
synth_df = pd.DataFrame(synthesis_results)
if "latency_ms" in synth_df.columns:
ok_results = synth_df[synth_df["status"] == "ok"].copy()
ok_results["latency_ms"] = ok_results["latency_ms"].round(0)
ok_results["audio_size_kb"] = (ok_results["audio_size_bytes"] / 1024).round(1)
print("=== Resultats de synthese par personnage et modele ===\n")
print(ok_results[["character_name", "model", "voice", "latency_ms", "audio_size_kb"]].to_string(index=False))
# Resume par modele
summary = ok_results.groupby("model").agg(
avg_latency_ms=("latency_ms", "mean"),
avg_size_kb=("audio_size_kb", "mean"),
n_samples=("character_name", "count"),
).round(0)
print("\n=== Resume par modele ===")
print(summary.to_string())
else:
print("Aucun resultat de synthese disponible.")=== Resultats de synthese par personnage et modele ===
character_name model voice latency_ms audio_size_kb
Comte Hubert de Breville kokoro bm_george 4121.0 543.0
Comte Hubert de Breville openai/tts-1 fable 4578.0 581.2
Officier prussien kokoro am_michael 3988.0 798.4
Officier prussien openai/tts-1 onyx 2655.0 671.6
Monsieur Loiseau kokoro bm_george 3386.0 551.3
Monsieur Loiseau openai/tts-1 fable 3307.0 608.6
Elisabeth Rousset kokoro af_sky 3757.0 666.0
Elisabeth Rousset openai/tts-1 nova 5066.0 704.6
Comtesse de Breville kokoro af_bella 3836.0 671.7
Comtesse de Breville openai/tts-1 alloy 2665.0 603.0
Cornudet kokoro bm_george 3421.0 548.7
Cornudet openai/tts-1 fable 2698.0 592.9
Les bonnes soeurs kokoro bf_emma 3670.0 584.3
Les bonnes soeurs openai/tts-1 alloy 2791.0 599.6
Mme Loiseau kokoro af_bella 2364.0 60.4
Mme Loiseau openai/tts-1 alloy 1930.0 47.2
Monsieur Carre-Lamadon kokoro bm_george 3482.0 586.5
Monsieur Carre-Lamadon openai/tts-1 fable 4360.0 627.8
=== Resume par modele ===
avg_latency_ms avg_size_kb n_samples
model
kokoro 3558.0 557.0 9
openai/tts-1 3339.0 560.0 9
Exercice : Estimation du cout de synthesis par modèle
Duree estimee : 10-15 minutes
Objectif : Calculer le cout total estime de la generation de tous les echantillons audio, en utilisant les tarifs des différents fournisseurs TTS et les données de latence collectees.
Contexte : Kokoro TTS est local (cout = electricite GPU), OpenAI TTS facture au caractère. Pour un audiobook complet (342 segments), le choix du modèle a un impact budget significatif.
- Étape 1 : Estimer le nombre total de caractères pour les 342 segments du texte complet
- Étape 2 : Calculer le cout OpenAI (tarif : $0.015 / 1K caractères pour tts-1)
- Étape 3 : Comparer avec le cout d’hebergement Kokoro (estimation : $0.50/h GPU)
Indice : L’echantillon actuel = 9 tirades de casting (une par personnage). Le texte complet contient 342 segments. Indice : Extrapolation lineaire : cout_total = cout_demo * (342 / 9) Indice : Presentez les résultats dans un tableau comparatif avec les deux modèles
# TODO etudiant : estimation du cout de synthesis
TOTAL_SEGMENTS_FULL = 342
DEMO_SEGMENTS = 9
# Etape 1 : Estimer le nombre de caracteres total
total_chars_demo = None # TODO etudiant : sommer les len(sample_text) de chaque personnage
estimated_chars_full = None # TODO etudiant : extrapolation lineaire
# Etape 2 : Calculer le cout OpenAI
OPENAI_RATE = 0.015 # $ par 1000 caracteres
openai_cost = None # TODO etudiant
# Etape 3 : Comparer avec Kokoro (local)
KOKORO_GPU_COST_PER_HOUR = 0.50 # estimation
demo_duration_s = None # TODO etudiant : sommer les latences kokoro depuis synthesis_results
kokoro_cost = None # TODO etudiant : extrapoler
# TODO etudiant : afficher le tableau comparatif
print("Exercice a completer")Exercice a completer
8. Export de la configuration de casting
La configuration de casting est exportee pour consommation par P3 (annotation prosodique) et P4 (generation TTS). Le format inclut les mappings pour chaque service TTS disponible ainsi que les profils FishAudio pour la production.
# Construire la configuration de casting complete
casting_config = {
"epic": "1028",
"pass": "P2",
"description": "Voice casting configuration for audiobook pipeline",
"source_text": "Boule de Suif - Guy de Maupassant",
"characters": {},
}
for cast in cast_table:
cid = cast["character_id"]
char_info = p1["characters"][cid]
casting_config["characters"][cid] = {
"name": cast["name"],
"gender": cast["gender"],
"role": cast["role"],
"voices": {
"kokoro": {
"model": "kokoro",
"voice_id": cast["kokoro_voice"],
"service_url": "http://localhost:8191",
"melody_verdict": voice_melody.get((cast["name"], cast["kokoro_voice"]), "non mesure"),
},
"openai": {
"model": "tts-1",
"voice_id": cast["openai_voice"],
"service": "cloud",
"melody_verdict": voice_melody.get((cast["name"], cast["openai_voice"]), "non mesure"),
},
"fishaudio_production": {
"model": "fishaudio/s2-pro",
"preset": cast["fish_preset"],
"voice_cloning": False,
"expressive_tags": True,
"note": "Modele production — non deploye en Docker. Utiliser Kokoro/OpenAI pour demo.",
},
},
"sample_text": CASTING_TIRADES.get(cid) or char_info.get("first_utterance", ""),
"sample_file": f"voice_casting_output/{cid}_kokoro.mp3",
}
# Sauvegarder
config_path = VOICE_DIR / "voice_casting_config.json"
with open(config_path, "w", encoding="utf-8") as f:
json.dump(casting_config, f, indent=2, ensure_ascii=False)
print(f"Configuration de casting sauvegardee: {config_path.name}")
print(f"Personnages configures: {len(casting_config['characters'])}")
# Sauvegarder le tableau de casting en CSV
cast_df.to_csv(VOICE_DIR / "voice_casting_table.csv", index=False, encoding="utf-8")
print(f"Tableau CSV sauvegarde: {VOICE_DIR / 'voice_casting_table.csv'}")Configuration de casting sauvegardee: voice_casting_config.json
Personnages configures: 9
Tableau CSV sauvegarde: voice_casting_output\voice_casting_table.csv
Exercice : Scoring de coherence du voice casting
Duree estimee : 15-20 minutes
Objectif : Ecrire une fonction qui evalue la coherence d’un casting vocal en verifiant que deux personnages du même rôle narratif ne partagent pas la même voix TTS, et que chaque voix est utilisee au maximum 2 fois.
Contexte : Dans un audiobook avec beaucoup de personnages, il est facile d’attribuer la même voix a deux personnages qui interagissent ensemble, ce qui rend les dialogues confus pour l’auditeur.
- Étape 1 : Compter le nombre d’utilisations de chaque voix Kokoro dans le casting
- Étape 2 : Detecter les conflits (même voix pour 2+ personnages du même rôle)
- Étape 3 : Proposer des alternatives pour les conflits detectes
Indice : Parcourez cast_table et construisez un dict {voice_id: [character_names]} Indice : Pour chaque voix utilisee plus de 2 fois, consultez KOKORO_CATALOG pour des alternatives Indice : Verifiez que deux personnages avec le même rôle n’ont pas la même voix
# TODO etudiant : scoring de coherence du voice casting
MAX_REUSE = 2 # Nombre max de fois qu'une voix peut etre attribuee
# Etape 1 : Compter les utilisations de chaque voix
voice_usage = None # TODO etudiant : dict {voice_id: [list of character names]}
# Etape 2 : Detecter les conflits
def detect_casting_conflicts(cast_table):
"""Retourne la liste des conflits de voix dans le casting."""
# TODO etudiant : verifier doublons de voix et doublons par role
return [] # TODO etudiant
# Etape 3 : Proposer des alternatives
def suggest_alternatives(conflicts, kokoro_catalog):
"""Pour chaque conflit, propose une voix alternative du meme genre."""
# TODO etudiant : chercher une voix non utilisee dans kokoro_catalog
return {} # TODO etudiant
print("Exercice a completer")Exercice a completer
9. Conclusion et passage a P3
Recapitulatif du voice casting
Chaque personnage detecte en P1 a recu une voix TTS attribuee selon son profil (genre + rôle narratif). Les echantillons audio sont generes via Kokoro TTS (local) et OpenAI TTS (cloud) comme substituts demo. Depuis la section 6bis, le casting porte aussi un verdict melodique par voix : sur ce run, 16/18 clips EXPRESSIVE (les 2 INSUFFICIENT sont les deux clips generiques de Mme Loiseau, trop courts — pas des derives de casting), zero DRONE, et l’export casting_config emporte le verdict par voix pour que P3/P4 puissent refuser une derive avant de synthetiser les 342 segments.
Architecture production
| Composant | Demo (actuel) | Production (futur) |
|---|---|---|
| TTS principal | Kokoro TTS | FishAudio S2-Pro |
| Voice cloning | Non | FishAudio zero-shot |
| Tags expressifs | Non | [laugh], [whisper], [sigh], etc. |
| Mastering | Non | Demucs + Kokoro |
Prochaines étapes (Epic #1028)
- P3 — Annotation prosodique : Enrichir le texte avec les tags expressifs FishAudio (
[laugh],[whisper],[sigh],[gasp],[pause],[shout]) - P4 — Generation TTS : Synthetiser les segments audio avec la voix appropriatee
- P5 — Compilation audio : Assembler les chunks avec ffmpeg + mastering