Voice Casting : Attribution de voix TTS par personnage

# Parameters
BATCH_MODE = "true"

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.0 attesté par le manifeste : producteur 04-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) et mme_loiseau (absent du fallback) — 9 personnages sans narrateur — les tirades de la section 5 couvrent les deux jeux de clés (Mme Loiseau retombe sur le texte générique court : sa first_utterance n’est qu’un fragment de deux mots — « charmante compagne » — signalé INSUFFICIENT dans 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_isabella pour le narrateur est volontairement plus grave que af_sky pour 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]), donc am_michael (Comte, antagoniste froid) peut sonner trop neutre sur une réplique sarcastique. FishAudio S2-Pro en production résoudra ça via expressive_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 ok partout), 0 retry, 0 fallback — mais un ok de 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
Retour au sommet