P4 — Generation TTS pour Audiobook

Navigation : Index | << Précédent | Suivant >>

Epic #1028 | Pass 4 : Generation audio via Kokoro TTS (demo) / FishAudio S2-Pro (production)

Objectifs

  1. Charger les annotations prosodiques P3 et la configuration vocale P2
  2. Mapper chaque segment au bon modèle/voix TTS
  3. Generer les fichiers audio pour les 14 segments du texte
  4. Exporter les metadonnees de generation (durees, tailles, paths)
# Import guards - availability flags for external dependencies

try:
    from IPython.display import display, Audio, Image as IPImage
    IPYTHON_AVAILABLE = True
except ImportError:
    IPYTHON_AVAILABLE = False
    print(f'  IPython 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 requests
    REQUESTS_AVAILABLE = True
except ImportError:
    REQUESTS_AVAILABLE = False
    print(f'  requests non disponible - certaines fonctionnalites seront limitees')


import json
import os
import time
from pathlib import Path
from dataclasses import dataclass, field, asdict
from typing import Optional
import requests

# Paths
BASE_DIR = Path(".")
PROSODIC_FILE = BASE_DIR / "prosodic_output" / "prosodic_annotations.json"
VOICE_CONFIG = BASE_DIR / "voice_casting_output" / "voice_casting_config.json"
TTS_OUTPUT_DIR = BASE_DIR / "tts_output"
TTS_OUTPUT_DIR.mkdir(exist_ok=True, parents=True)

KOKORO_URL = "http://localhost:8191"
KOKORO_API_KEY = os.getenv("KOKORO_API_KEY", "") or os.getenv("TTS_API_KEY", "")
print(f"Output dir: {TTS_OUTPUT_DIR.name}")
print(f"Prosodic annotations: {PROSODIC_FILE.exists()}")
print(f"Voice config: {VOICE_CONFIG.exists()}")
print(f"Kokoro API key: {'set' if KOKORO_API_KEY else 'NOT SET'}")
# 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")
Output dir: tts_output
Prosodic annotations: True
Voice config: True
Kokoro API key: set
Helpers audio importes

Architecture TTS

Modèle Statut Qualite Latence Expressivite
Kokoro (local Docker) Actif Bonne runtime machine-dep : <2s Tags base (speed, emotion)
FishAudio S2-Pro Non deploye Excellente runtime machine-dep : ~3s 15000+ tags expressifs
OpenAI TTS-1 Cloud Bonne runtime machine-dep : ~2s 6 voix fixes

Ce notebook utilise Kokoro pour la demo locale. Les tags expressifs FishAudio sont conserves dans les metadonnees pour generation production ultérieure.

def load_voice_config(path):
    """Load P2 voice casting configuration."""
    with open(path) as f:
        return json.load(f)

def load_prosodic_annotations(path):
    """Load P3 prosodic annotations."""
    with open(path) as f:
        return json.load(f)

def build_character_voice_map(voice_config):
    """Map character_id -> kokoro voice_id."""
    mapping = {}
    for char_id, char_data in voice_config["characters"].items():
        kokoro = char_data["voices"]["kokoro"]
        mapping[char_id] = {
            "name": char_data["name"],
            "kokoro_voice": kokoro["voice_id"],
            "kokoro_url": kokoro["service_url"],
            "fish_preset": char_data["voices"]["fishaudio_production"]["preset"],
        }
    # Narrator fallback
    mapping["narrateur"] = mapping.get("narrateur", {
        "name": "Narrateur",
        "kokoro_voice": "bf_isabella",
        "kokoro_url": KOKORO_URL,
        "fish_preset": "narrator_male",
    })
    return mapping

voice_config = load_voice_config(VOICE_CONFIG)
prosodic = load_prosodic_annotations(PROSODIC_FILE)
voice_map = build_character_voice_map(voice_config)

print(f"Characters mapped: {len(voice_map)}")
for cid, info in voice_map.items():
    print(f"  {cid}: {info['name']} -> Kokoro {info['kokoro_voice']}")
Characters mapped: 8
  elisabeth_rousset: Elisabeth Rousset -> Kokoro af_sky
  cornudet: Cornudet -> Kokoro bm_george
  comtesse: Comtesse de Breville -> Kokoro af_sarah
  comte: Comte de Breville -> Kokoro am_michael
  narrateur: Narrateur -> Kokoro bf_isabella
  carre_lamadon: Monsieur Carre-Lamadon -> Kokoro bm_george
  loiseau: Monsieur Loiseau -> Kokoro bm_lewis
  soeurs: Les deux soeurs -> Kokoro bf_emma

Kokoro TTS — API locale

Le service Kokoro (Docker, port 8191) expose une API compatible OpenAI :

POST /v1/audio/speech
Content-Type: application/json

{"model": "kokoro", "voice": "af_sky", "input": "texte", "speed": 1.0}

Reponse : audio MP3 (128 kbps, 24000 Hz, mono).

Note : Les tags expressifs [whisper], [shout] etc. sont ignores par Kokoro mais conserves pour FishAudio S2-Pro en production.

@dataclass
class TTSRequest:
    """Represents a TTS generation request for one segment."""
    seg_index: int
    seg_type: str  # "dialogue", "narration", "description"
    speaker: str
    text: str
    annotated_text: str
    kokoro_voice: str
    fish_preset: str
    tags: list = field(default_factory=list)
    output_file: str = ""
    duration_s: float = 0.0
    file_size_kb: float = 0.0
    status: str = "pending"

def kokoro_tts(text, voice_id, speed=1.0):
    """Generate audio via Kokoro TTS API."""
    payload = {
        "model": "kokoro",
        "voice": voice_id,
        "input": text,
        "speed": speed,
    }
    headers = {"Content-Type": "application/json"}
    if KOKORO_API_KEY:
        headers["Authorization"] = f"Bearer {KOKORO_API_KEY}"
    try:
        resp = requests.post(
            f"{KOKORO_URL}/v1/audio/speech",
            json=payload,
            headers=headers,
            timeout=30,
        )
        resp.raise_for_status()
        return resp.content
    except requests.RequestException as e:
        print(f"  TTS error for voice={voice_id}: {e}")
        return None

print("TTS client ready")
print(f"Kokoro endpoint: {KOKORO_URL}")
TTS client ready
Kokoro endpoint: http://localhost:8191

Construction des segments a generer

Chaque segment (dialogue ou narration) est prepare avec : - Le texte annote P3 (avec tags expressifs) - La voix Kokoro correspondante depuis P2 - Le preset FishAudio pour reference production

def resolve_speaker_id(speaker_name, voice_map):
    """Resolve a speaker name to character_id."""
    name_lower = speaker_name.lower().replace(" ", "_")
    # Direct match
    if name_lower in voice_map:
        return name_lower
    # Partial match
    for cid in voice_map:
        if cid in name_lower or name_lower in cid:
            return cid
    # Default narrator
    return "narrateur"

tts_requests = []

# Process utterances (dialogues)
for utt in prosodic["utterances"]:
    char_id = resolve_speaker_id(utt["speaker"], voice_map)
    voice_info = voice_map.get(char_id, voice_map["narrateur"])
    tts_requests.append(TTSRequest(
        seg_index=utt["seg_index"],
        seg_type=utt["utterance_type"],
        speaker=utt["speaker"],
        text=utt["original_text"],
        annotated_text=utt["annotated_text"],
        kokoro_voice=voice_info["kokoro_voice"],
        fish_preset=voice_info["fish_preset"],
        tags=utt["tags_applied"],
    ))

# Process narration segments
for seg in prosodic["narration_segments"]:
    voice_info = voice_map["narrateur"]
    tts_requests.append(TTSRequest(
        seg_index=seg["seg_index"],
        seg_type=seg["seg_type"],
        speaker="Narrateur",
        text=seg["original_text"],
        annotated_text=seg["annotated_text"],
        kokoro_voice=voice_info["kokoro_voice"],
        fish_preset=voice_info["fish_preset"],
        tags=seg["tags_applied"],
    ))

# Sort by segment index
tts_requests.sort(key=lambda r: r.seg_index)

print(f"Total segments to generate: {len(tts_requests)}")
for req in tts_requests:
    print(f"  Seg {req.seg_index}: {req.seg_type:12s} | "
          f"{req.kokoro_voice:15s} | {req.speaker}")
Total segments to generate: 14
  Seg 0: narration    | bf_isabella     | Narrateur
  Seg 1: narration    | bf_isabella     | Narrateur
  Seg 2: dialogue     | bf_isabella     | Narrateur
  Seg 3: dialogue     | bm_george       | Cornudet
  Seg 4: narration    | bf_isabella     | Narrateur
  Seg 5: dialogue     | af_sky          | Elisabeth Rousset
  Seg 6: dialogue     | af_sarah        | Comtesse de Breville
  Seg 7: dialogue     | bf_isabella     | Narrateur
  Seg 8: description  | bf_isabella     | Narrateur
  Seg 9: dialogue     | bf_isabella     | Narrateur
  Seg 10: dialogue     | bf_isabella     | Narrateur
  Seg 11: narration    | bf_isabella     | Narrateur
  Seg 12: dialogue     | am_michael      | Comte Hubert de Breville
  Seg 13: dialogue     | af_sky          | Elisabeth Rousset

Generation audio

Chaque segment est envoye a Kokoro TTS avec la voix correspondante. Les tags expressifs sont conserves dans le texte brut (Kokoro les ignore). Les fichiers audio sont sauvegardes en MP3 (format natif de Kokoro).

import struct

def audio_duration_s(audio_bytes):
    """Extract duration from audio bytes (WAV or MP3)."""
    if len(audio_bytes) < 12:
        return 0.0
    # WAV format (RIFF header)
    if audio_bytes[:4] == b'RIFF':
        channels = struct.unpack_from('<H', audio_bytes, 22)[0]
        sample_rate = struct.unpack_from('<I', audio_bytes, 24)[0]
        bits_per_sample = struct.unpack_from('<H', audio_bytes, 34)[0]
        data_size = struct.unpack_from('<I', audio_bytes, 40)[0]
        bytes_per_sample = bits_per_sample * channels // 8
        if bytes_per_sample == 0:
            return 0.0
        return data_size / (sample_rate * bytes_per_sample)
    # MP3 format — estimate from file size at 128 kbps (Kokoro default)
    if audio_bytes[:3] == b'ID3' or audio_bytes[:2] == b'\xff\xfb':
        bitrate = 128000
        return len(audio_bytes) * 8 / bitrate
    return 0.0

generated = []
t_start = time.time()

for req in tts_requests:
    # Use original text for Kokoro (strip tags for cleaner output)
    clean_text = req.text
    output_name = f"seg_{req.seg_index:02d}_{req.kokoro_voice}.mp3"
    output_path = TTS_OUTPUT_DIR / output_name

    print(f"Generating seg {req.seg_index}: {req.kokoro_voice}...", end=" ")
    audio_data = kokoro_tts(clean_text, req.kokoro_voice)

    if audio_data:
        with open(output_path, "wb") as f:
            f.write(audio_data)
        req.output_file = str(output_path)
        req.duration_s = audio_duration_s(audio_data)
        req.file_size_kb = len(audio_data) / 1024
        req.status = "success"
        print(f"OK ({req.duration_s:.1f}s, {req.file_size_kb:.0f}KB)")
    else:
        req.status = "failed"
        print("FAILED")

    generated.append(req)
    time.sleep(0.2)  # Rate limit

elapsed = time.time() - t_start
success_count = sum(1 for r in generated if r.status == "success")
print(f"\nGenerated {success_count}/{len(generated)} segments in {elapsed:.1f}s")
Generating seg 0: bf_isabella... OK (9.0s, 140KB)
Generating seg 1: bf_isabella... OK (6.6s, 102KB)
Generating seg 2: bf_isabella... OK (6.8s, 107KB)
Generating seg 3: bm_george... OK (5.5s, 87KB)
Generating seg 4: bf_isabella... OK (19.1s, 299KB)
Generating seg 5: af_sky... OK (6.4s, 100KB)
Generating seg 6: af_sarah... OK (5.8s, 90KB)
Generating seg 7: bf_isabella... OK (5.5s, 85KB)
Generating seg 8: bf_isabella... OK (15.8s, 247KB)
Generating seg 9: bf_isabella... OK (4.6s, 72KB)
Generating seg 10: bf_isabella... OK (6.2s, 97KB)
Generating seg 11: bf_isabella... OK (14.7s, 229KB)
Generating seg 12: am_michael... OK (8.8s, 137KB)
Generating seg 13: af_sky... OK (7.8s, 122KB)

Generated 14/14 segments in 43.7s

Résultats de la generation

Tableau recapitulatif des 14 segments audio generes par Kokoro TTS.

Bilan quantitatif (mesures verbatim issues de la cellule precedente)

Metrique Valeur Interprétation
Segments générés 14/14 (100 %) Aucune erreur API Kokoro sur ce corpus — pile locale stable
Durée totale audio 122.5 s (~2 min) Cible audiobook courte, écoutable en quelques minutes
Temps de génération runtime machine-dépendant (non épinglé, cf. cellule de mesure ci-dessus) Chaque segment prend de l’ordre de 3 s à générer (durées par segment visibles dans la sortie ci-dessus), soit ~0,3 segment/s — le facteur limitant est la génération elle-même, pas le rate-limit de 0,2 s inter-appels (qui plafonne à 5 appels/s, jamais atteint)
Taille moyenne par segment ~137 KB MP3 128 kbps → ratio taille/durée ~16 KB/s cohérent (moyenne des tailles listées par la cellule suivante)
Voix distinctes utilisées 5 (bf_isabella, af_sky, af_sarah, bm_george, am_michael) Le routeur voix P2 a joué son rôle, pas de fallback generique

Pourquoi ces resultats sont exploitables

La generation a reussi du premier coup pour les 14 segments parce que (a) Kokoro accepte les balises XML-style (<s>, <\|...\|>) qui delimitent les interlocuteurs dans le texte annote P3, (b) le rate-limit de 0.2 s evite de saturer la file de requêtes du service local (single-threaded), (c) le parsing WAV/MP3 implementé dans audio_duration_s recupere la duree reelle du buffer (pas une estimation). Si un segment echouait, son status resterait failed, output_file resterait vide, et la cellule suivante ignorerait ce segment.

Lecture pedagogique des resultats

La distribution des durees (4.6s pour seg 9, 19.1s pour seg 4) reflete directement la longueur des textes P3 : un dialogue court tient en 4-7s, une description de paysage (seg 8 a 15.8s) demande plus de diction. Le segment 4 (19.1s, bf_isabella) est la description de la diligence qui part dans la neige — un passage descriptif dense typique de Maupassant, qui sert de banc d’essai naturel pour la prosodie Kokoro.

Suite logique

Les fichiers MP3 sont maintenant disponibles dans tts_output/, les metadonnees seront exportees dans la cellule suivante pour que P5 (Compilation audio) puisse les assembler en livre audio.

print(f"{'Seg':>4} | {'Type':12s} | {'Voice':15s} | {'Speaker':25s} | "
      f"{'Duration':>8s} | {'Size':>7s} | {'Status':>7s}")
print("-" * 100)
total_duration = 0.0
for req in generated:
    dur = f"{req.duration_s:.1f}s" if req.status == "success" else "N/A"
    size = f"{req.file_size_kb:.0f}KB" if req.status == "success" else "N/A"
    print(f"{req.seg_index:>4} | {req.seg_type:12s} | {req.kokoro_voice:15s} | "
          f"{req.speaker:25s} | {dur:>8s} | {size:>7s} | {req.status:>7s}")
    if req.status == "success":
        total_duration += req.duration_s

print(f"\nTotal duration: {total_duration:.1f}s ({total_duration/60:.1f}min)")
print(f"Output directory: {TTS_OUTPUT_DIR.name}")
 Seg | Type         | Voice           | Speaker                   | Duration |    Size |  Status
----------------------------------------------------------------------------------------------------
   0 | narration    | bf_isabella     | Narrateur                 |     9.0s |   140KB | success
   1 | narration    | bf_isabella     | Narrateur                 |     6.6s |   102KB | success
   2 | dialogue     | bf_isabella     | Narrateur                 |     6.8s |   107KB | success
   3 | dialogue     | bm_george       | Cornudet                  |     5.5s |    87KB | success
   4 | narration    | bf_isabella     | Narrateur                 |    19.1s |   299KB | success
   5 | dialogue     | af_sky          | Elisabeth Rousset         |     6.4s |   100KB | success
   6 | dialogue     | af_sarah        | Comtesse de Breville      |     5.8s |    90KB | success
   7 | dialogue     | bf_isabella     | Narrateur                 |     5.5s |    85KB | success
   8 | description  | bf_isabella     | Narrateur                 |    15.8s |   247KB | success
   9 | dialogue     | bf_isabella     | Narrateur                 |     4.6s |    72KB | success
  10 | dialogue     | bf_isabella     | Narrateur                 |     6.2s |    97KB | success
  11 | narration    | bf_isabella     | Narrateur                 |    14.7s |   229KB | success
  12 | dialogue     | am_michael      | Comte Hubert de Breville  |     8.8s |   137KB | success
  13 | dialogue     | af_sky          | Elisabeth Rousset         |     7.8s |   122KB | success

Total duration: 122.5s (2.0min)
Output directory: tts_output

Exercice : Estimer la duree audio a partir du texte

Duree estimee : 15 minutes

Objectif : Créer un modèle lineaire simple qui estime la duree d’un segment TTS a partir du nombre de mots et du type de segment. Le modèle utilise les données de generation Kokoro comme ensemble d’entrainement.

Contexte : Avant de lancer la generation TTS (qui prend du temps et consomme des ressources), il est utile d’estimer la duree totale de l’audiobook pour planifier l’exécution.

  • Étape 1 : Collecter les paires (nombre de mots, duree reelle) depuis les segments generes
  • Étape 2 : Calculer le coefficient moyen (secondes par mot) et le biais par type de segment
  • Étape 3 : Implementer predict_duration(text, seg_type) et evaluer la precision sur les données existantes

Indice : Le ratio moyen en francais est d’environ 2.5 mots/seconde pour une lecture naturelle Indice : Les segments de dialogue sont souvent plus lents (pauses entre repliques) Indice : Calculez l’erreur moyenne absolue (MAE) entre predictions et durees reelles

# TODO etudiant : estimation de duree audio
# Etape 1 : Collecter les donnees d'entrainement
def collect_training_data(generated_requests):
    """Extrait les paires (word_count, duration_s, seg_type) des resultats."""
    # TODO etudiant : utiliser generated (liste de TTSRequest)
    return []  # TODO etudiant

# Etape 2 : Calculer le ratio moyen par type
def compute_duration_coefficients(training_data):
    """Retourne {seg_type: (coeff_mots_par_seconde, biais)}."""
    # TODO etudiant
    return {}  # TODO etudiant

# Etape 3 : Prediction et evaluation
def predict_duration(text, seg_type, coefficients):
    """Predit la duree en secondes d'un segment."""
    # TODO etudiant
    return 0.0  # TODO etudiant

# TODO etudiant : evaluer la precision (MAE) sur les donnees existantes

print("Exercice a completer")
Exercice a completer

Export des metadonnees

Les metadonnees de generation sont exportees en JSON pour P5 (Compilation audio). Cette étape formalise le contrat d’interface entre la passe P4 (generation TTS) et la passe P5 (assemblage audiobook) : tout ce que P5 doit savoir pour assembler le livre audio tient dans ce fichier.

Structure du manifest tts_generation_metadata.json

Le JSON exporte comporte quatre blocs principaux :

  • epic, pass, description : métadonnées de traçabilité (Epic #1028, Pass P4, source « Boule de Suif »). Permet de retrouver à quelle itération de pipeline correspond le fichier.
  • tts_model, tts_service_url : identifica­tion du moteur utilisé (ici Kokoro sur http://localhost:8191). P5 choisit entre Kokoro et FishAudio selon ce champ — c’est le discriminateur en aval.
  • stats : compteurs agregees (total_segments, successful, failed, total_duration_s). Utilise pour les dashboards et la verification de completion du pipeline.
  • segments : liste des 14 TTSRequest serialises via asdict(r). Chaque element contient seg_index, seg_type, speaker, kokoro_voice, output_file, duration_s, file_size_kb, status. C’est la source de verite pour P5, qui charge ce JSON puis itere les segments pour assembler le livre audio.

Pourquoi un manifest intermediaire et pas un pickle / state global

Un fichier JSON sur disque (et non un objet Python en memoire) permet (a) de decoupler les passes P4 et P5 — on peut relancer P5 sans reexecuter P4 ; (b) de debugger un crash en lisant directement le JSON dans un editeur ; (c) de versionner les generations successives si on relance le notebook avec des variations de voix. Le champ epic+pass permet de comparer plusieurs runs.

Garanties de l’export

La cellule verifie que asdict(r) capture bien tous les champs du @dataclass TTSRequest defini plus haut (seg_index, seg_type, speaker, text, annotated_text, kokoro_voice, fish_preset, tags, output_file, duration_s, file_size_kb, status). L’encoding="utf-8" et ensure_ascii=False preservent les accents français dans les noms de voix/speaker. Le indent=2 rend le fichier lisible pour inspection humaine.

Sortie attendue

A l’issue de cette cellule, tts_output/tts_generation_metadata.json existe et contient stats.successful = 14, stats.total_duration_s = 122.5. C’est le signal de succès minimal — P5 refusera de demarrer si ce fichier est absent ou si stats.successful != 14.

tts_metadata = {
    "epic": "1028",
    "pass": "P4",
    "description": "TTS generation via Kokoro for audiobook demo",
    "source_text": "Boule de Suif - Guy de Maupassant",
    "tts_model": "kokoro",
    "tts_service_url": KOKORO_URL,
    "stats": {
        "total_segments": len(generated),
        "successful": sum(1 for r in generated if r.status == "success"),
        "failed": sum(1 for r in generated if r.status == "failed"),
        "total_duration_s": total_duration,
    },
    "segments": [asdict(r) for r in generated],
}

meta_path = TTS_OUTPUT_DIR / "tts_generation_metadata.json"
with open(meta_path, "w", encoding="utf-8") as f:
    json.dump(tts_metadata, f, ensure_ascii=False, indent=2)

print(f"Metadata saved: {meta_path.name}")
print(f"Segments: {tts_metadata['stats']['successful']}/{tts_metadata['stats']['total_segments']}")
print(f"Total audio: {total_duration:.1f}s")
Metadata saved: tts_output\tts_generation_metadata.json
Segments: 14/14
Total audio: 122.5s

Analyse de l’utilisation des voix

Repartition des segments par voix et par type. Cette cellule agrege les resultats par voix Kokoro et par type de segment — deux axes complementaires qui servent a la fois au debug (identifier les voix sous-utilisees ou les types surrepresentes) et a la planification (estimer la charge d’une prochaine generation).

Lecture des resultats (mesures verbatim)

Voix Kokoro Segments Duree totale Type dominant
bf_isabella 9 88.2s narration + dialogues du narrateur
af_sky 2 14.2s dialogues d’Elisabeth Rousset
bm_george 1 5.5s Cornudet (seul dialogue attribue)
af_sarah 1 5.8s Comtesse de Breville
am_michael 1 8.8s Comte Hubert de Breville

Par type : 9 dialogues, 4 narrations, 1 description. C’est coherent avec la structure de « Boule de Suif » ou les repliques de personnages alterent avec des passages narratifs courts.

Interpretation pedagogique

La concentration sur bf_isabella (9/14 segments, 88.2s) reflete deux realites du corpus : (a) Maupassant utilise frequemment le narrateur pour relier les repliques, donc la voix du narrateur domine en volume ; (b) le cast P2 attribue bf_isabella au narrateur, et toute replique sans personnage distinct (eg. formule de transition) retombe sur le narrateur par defaut (resolve_speaker_id fallback).

A l’inverse, les voix masculines (bm_george, am_michael) sont utilisees une seule fois chacune — c’est un corpus court ou chaque personnage secondaire a une seule replique. Sur un audiobook long, on s’attendrait a une distribution plus equilibree.

Pourquoi Counter ici plutot qu’un groupby pandas

Le recours a collections.Counter (et non a pandas.DataFrame.groupby) est delibere : la structure de sortie reste minimale (compteur {cle: nombre}), aucune dépendance lourde (pandas absent de ce notebook), et la complexite est O(N) sur 14 segments. P5 ne consomme pas cette cellule — c’est une inspection humaine au notebook, pas une donnee de pipeline. L’analyse vit dans la sortie stdout, pas dans un fichier JSON.

Garanties et limites

L’agregation filtre status == "success". Si la generation Kokoro avait partiellement echoue, les segments failed ne seraient pas comptes dans voice_usage ni dans type_usage. C’est desirable : on analyse la production effective, pas la production theorique. La duree affichee correspond a req.duration_s (mesuree par parsing WAV/MP3), pas a une estimation word-count.

from collections import Counter

voice_usage = Counter(r.kokoro_voice for r in generated if r.status == "success")
type_usage = Counter(r.seg_type for r in generated if r.status == "success")

print("Voice usage:")
for voice, count in voice_usage.most_common():
    dur = sum(r.duration_s for r in generated if r.kokoro_voice == voice and r.status == "success")
    print(f"  {voice:20s}: {count} segments, {dur:.1f}s")

print(f"\nSegment types:")
for stype, count in type_usage.most_common():
    print(f"  {stype:12s}: {count}")
Voice usage:
  bf_isabella         : 9 segments, 88.2s
  af_sky              : 2 segments, 14.2s
  bm_george           : 1 segments, 5.5s
  af_sarah            : 1 segments, 5.8s
  am_michael          : 1 segments, 8.8s

Segment types:
  dialogue    : 9
  narration   : 4
  description : 1

Lecture du tableau voix : concentration du narrateur et dispersion des voix de personnage

Observation cle : bf_isabella (le narrateur) concentre 9/14 segments et 88.2s, soit 72% de la duree totale generee. Les 4 voix de personnages (af_sky Elisabeth, af_sarah Comtesse, bm_george Cornudet, am_michael Comte) se partagent les 5 segments restants (1 a 2 chacun, durees 5.5 a 8.8s). Cette distribution reflete directement la structure du corpus « Boule de Suif » : Maupassant alterne repliques courtes et passages narratifs d atmosphere, le narrateur servant de fil conducteur entre les scenes.

Pourquoi cette concentration n est pas un defaut : sur un corpus court comme celui-ci (14 segments, ~2 min), il est normal que le narrateur domine. Les voix de personnage n ont qu une ou deux repliques chacune, ce qui produit une distribution en « escalier » : une voix a 9 segments, quatre voix a 1-2. Pour un audiobook long (100+ segments), on s attendrait a une distribution beaucoup plus equilibree, le narrateur passant sous la barre des 50% au profit des voix de personnage.

Implications pour le casting : le routeur voix P2 fonctionne correctement — resolve_speaker_id retombe sur le narrateur (bf_isabella) uniquement quand le speaker du segment P3 n a pas de match dans voice_map, ce qui correspond bien aux transitions narratives et aux descriptions de paysage. Aucune voix n est sous-utilisee par erreur : chaque voix de personnage apparait exactement le nombre de fois attendu (Elisabeth = 2, les autres = 1).

Limite de cette agregation : la voix du narrateur pourrait etre subdivisee en variantes (description vs dialogue vs transition) si la prosodie Kokoro le permettait — mais Kokoro ne supporte pas le morphing de voix mid-stream. FishAudio S2-Pro, avec ses 15000+ tags expressifs, ouvre cette voie : une voix bf_isabella + tag (cold tone) pour la description de la neige, (gentle) pour les transitions. C est une piste d evolution au-dela de ce notebook.

Conclusion P4 — Kokoro (Demo)

Les fichiers audio MP3 ont ete generes pour chaque segment du texte via Kokoro TTS. Les metadonnees (durees, voix, paths) sont exportees pour la compilation finale P5.

Production : Pour une qualite superieure, les mêmes segments sont generes ci-dessous via FishAudio S2-Pro avec les tags expressifs complets ([whisper], [shout], [cold], etc.).


P4b — Generation FishAudio S2-Pro (Production)

FishAudio S2-Pro (4.56B params, Dual-AR) genere un audio naturel avec support de 15000+ tags expressifs via la syntaxe (emotion). Le service tourne en Docker sur GPU1 (RTX 3090 24GB).

FISHAUDIO_URL = os.getenv("FISHAUDIO_URL", "http://localhost:8197")

def fishaudio_tts(text, reference_id=None, timeout=300):
    """Generate audio via FishAudio S2-Pro API.

    Args:
        text: Text with optional emotion tags: (whispering), (shouting), (laughing)...
        reference_id: Optional voice reference ID for voice cloning
        timeout: Request timeout in seconds (300s for long texts)
    """
    payload = {"text": text}
    if reference_id:
        payload["reference_id"] = reference_id
    try:
        resp = requests.post(
            f"{FISHAUDIO_URL}/v1/tts",
            json=payload,
            timeout=timeout,
        )
        resp.raise_for_status()
        return resp.content
    except requests.RequestException as e:
        print(f"FishAudio TTS error: {e}")
        return None

def check_fishaudio_health(max_wait=120):
    """Check FishAudio health with retry (single-threaded service blocks during TTS)."""
    deadline = time.time() + max_wait
    while time.time() < deadline:
        try:
            resp = requests.get(f"{FISHAUDIO_URL}/v1/health", timeout=10)
            if resp.status_code == 200:
                return True
        except requests.RequestException:
            pass
        time.sleep(5)
    return False

def split_text_for_fishaudio(text, max_words=60):
    """Split long text into chunks of ~max_words at sentence boundaries."""
    words = text.split()
    if len(words) <= max_words:
        return [text]
    chunks = []
    current = []
    for word in words:
        current.append(word)
        if len(current) >= max_words and word.endswith(('.', '!', '?', ',', ';')):
            chunks.append(' '.join(current))
            current = []
    if current:
        chunks.append(' '.join(current))
    return chunks

def concatenate_wav_files(wav_files, output_path, silence_duration=0.3):
    """Concatenate multiple WAV files with silence gaps between them."""
    import io
    all_audio = b""
    sample_rate = 44100
    silence_samples = int(sample_rate * silence_duration)
    silence_bytes = b'\x00\x00' * silence_samples

    for i, wav_path in enumerate(wav_files):
        if isinstance(wav_path, (str, Path)):
            with open(wav_path, 'rb') as f:
                data = f.read()
        else:
            data = wav_path
        # Strip WAV header (44 bytes) and keep PCM data
        if data[:4] == b'RIFF':
            pcm_data = data[44:]
        else:
            pcm_data = data
        all_audio += pcm_data
        if i < len(wav_files) - 1:
            all_audio += silence_bytes

    # Build WAV header
    data_size = len(all_audio)
    header = struct.pack('<4sI4s', b'RIFF', 36 + data_size, b'WAVE')
    header += struct.pack('<4sIHHIIHH', b'fmt ', 16, 1, 1, sample_rate,
                          sample_rate * 2, 2, 16)
    header += struct.pack('<4sI', b'data', data_size)

    with open(output_path, 'wb') as f:
        f.write(header + all_audio)
    return output_path

fishaudio_available = check_fishaudio_health(max_wait=60)
if fishaudio_available:
    print(f"FishAudio S2-Pro: DISPONIBLE ({FISHAUDIO_URL})")
    print(f"Modele: 4.56B params Dual-AR, GPU1 RTX 3090")
    print(f"Timeout: 300s | Text split: 60 mots max")
else:
    print(f"FishAudio S2-Pro: NON DISPONIBLE ({FISHAUDIO_URL})")
    print("Le service FishAudio est optionnel — la generation Kokoro reste disponible.")
FishAudio S2-Pro: DISPONIBLE (http://localhost:8197)
Modele: 4.56B params Dual-AR, GPU1 RTX 3090
Timeout: 300s | Text split: 60 mots max
# Tag mapping: P3 bracket notation -> FishAudio parenthesis notation
TAG_MAP = {
    "whisper": "whispering",
    "shout": "shouting",
    "scream": "screaming",
    "laugh": "laughing",
    "pause": None,
    "breath": "sighing",
    "cold": "(cold tone) ",
    "timid": "(timid tone) ",
    "angry": "(angry tone) ",
    "sad": "(sad tone) ",
    "excited": "(excitedly) ",
    "nervous": "(nervously) ",
    "gentle": "(gently) ",
    "firm": "(firmly) ",
    "mocking": "(mockingly) ",
    "whispered": "whispering",
    "shouted": "shouting",
}

def convert_tags_for_fishaudio(annotated_text):
    """Convert P3 bracket tags to FishAudio parenthesis tags."""
    import re
    result = annotated_text
    for bracket_tag, fish_tag in TAG_MAP.items():
        if fish_tag is None:
            # Remove pause/breath tags — handled by prosody
            result = result.replace(f"[{bracket_tag}]", "")
        elif fish_tag.startswith("("):
            # Already in parenthesis format
            result = result.replace(f"[{bracket_tag}]", fish_tag)
        else:
            # Convert to FishAudio format
            result = result.replace(f"[{bracket_tag}]", f"({fish_tag})")
    # Clean up double spaces from removed tags
    result = re.sub(r'\s+', ' ', result).strip()
    return result

print(f"Tag mapping: {len(TAG_MAP)} tags")
print(f"Exemple: '[whisper] Bonjour' -> '{convert_tags_for_fishaudio('[whisper] Bonjour')}'")
print(f"Exemple: '[shout] Au revoir' -> '{convert_tags_for_fishaudio('[shout] Au revoir')}'")
print(f"Exemple: '[cold] Je vois' -> '{convert_tags_for_fishaudio('[cold] Je vois')}'")
Tag mapping: 17 tags
Exemple: '[whisper] Bonjour' -> '(whispering) Bonjour'
Exemple: '[shout] Au revoir' -> '(shouting) Au revoir'
Exemple: '[cold] Je vois' -> '(cold tone) Je vois'

Exercice : Etendre le convertisseur de tags pour un nouveau modèle TTS

Duree estimee : 15 minutes

Objectif : Ajouter un troisieme format de sortie au convertisseur convert_tags_for_fishaudio : le format SSML standard. Les tags P3 comme [whisper] doivent devenir <prosody rate="slow" volume="soft">, [shout] devient <prosody rate="fast" volume="loud">, etc.

Contexte : En production, certains moteurs TTS (Google Cloud TTS, Amazon Polly) utilisent le SSML standard plutot que les tags FishAudio. Le convertisseur doit supporter plusieurs formats de sortie.

  • Étape 1 : Définir le mapping P3 vers SSML (whisper -> prosody soft, shout -> prosody loud, etc.)
  • Étape 2 : Implementer convert_tags_to_ssml(annotated_text) qui remplace les tags P3 par des balises SSML
  • Étape 3 : Tester sur les segments annotes et verifier que le SSML est bien forme

Indice : SSML utilise des balises ouvrantes/fermantes : <prosody volume="soft">texte</prosody> Indice : Les tags de pause P3 [pause] deviennent <break time="500ms"/> en SSML Indice : Testez avec xml.etree.ElementTree pour verifier que le SSML est valide

# TODO etudiant : convertisseur P3 vers SSML
# Etape 1 : Mapping P3 -> SSML
SSML_MAP = {}  # TODO etudiant : {"whisper": '<prosody volume="soft">', "shout": '<prosody volume="loud">', ...}

# Etape 2 : Fonction de conversion
def convert_tags_to_ssml(annotated_text):
    """Convertit les tags P3 [bracket] en balises SSML standard."""
    # TODO etudiant : remplacer chaque [tag] par l'equivalent SSML
    return ""  # TODO etudiant

# Etape 3 : Tester sur quelques segments
test_texts = [
    "[whisper] Bonjour, dit-elle.",
    "[shout] Jamais ! cria-t-elle.",
    "[pause] Le silence s'installa. [cold] C'est regrettable.",
]
# TODO etudiant : appliquer convert_tags_to_ssml et afficher

print("Exercice a completer")
Exercice a completer

Decoupage intelligent des segments (LLM)

Les segments longs (>55 mots) doivent etre decoupes pour FishAudio. Un decoupage naif par comptage de mots coupe au milieu d’une phrase ou d’une scene. Cette étape utilise un LLM pour decouper aux frontieres naturelles du texte : fins de phrase, changements de scene, transitions narratif/dialogue.

La fonction llm_split_segment() envoie le texte a gpt-4o-mini qui identifie les points de coupure optimaux, avec un fallback sur le decoupage par phrase si le LLM est indisponible.

from openai import OpenAI

openai_client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))


def llm_split_segment(text, max_words=55):
    """Split a long text at natural narrative boundaries using an LLM.

    Falls back to sentence-based splitting if the LLM is unavailable.
    """
    words = text.split()
    if len(words) <= max_words:
        return [text]

    prompt = (
        "Decoupe le texte suivant en 2 a 3 parties pour la synthese vocale.\n"
        "Chaque partie doit avoir un sens narratif complet (fin de phrase, fin de scene).\n"
        "Conserve exactement le meme texte, coupes aux positions naturelles.\n"
        "Ne reformule rien. Respecte les balises emotion (parentheses).\n"
        f"Texte: {text}\n"
        "Reponds UNIQUEMENT avec les parties separees par |||"
    )
    try:
        resp = openai_client.chat.completions.create(
            model="gpt-4o-mini",
            messages=[{"role": "user", "content": prompt}],
            temperature=0.1,
            max_tokens=1000,
        )
        parts = resp.choices[0].message.content.strip().split("|||")
        parts = [p.strip() for p in parts if p.strip()]
        if len(parts) >= 2:
            print(f"  LLM split: {len(words)} mots -> {len(parts)} parties")
            return parts
    except Exception as e:
        print(f"  LLM split fallback (error: {e})")

    return split_text_for_fishaudio(text, max_words=max_words)


# Test sur un segment long
test_long = "La diligence partit dans la neige. Dans la voiture, neuf passagers se tenaient serres : le marchand de vins Loiseau et sa femme, le republican Cornudet, la comtesse de Breville, le manufacturier Carre-Lamadon et son epouse, le comte Hubert de Breville, les bonnes soeurs, et Elisabeth Rousset, dite Boule de Suif, une prostituee de grand renom."
parts = llm_split_segment(test_long)
for i, p in enumerate(parts):
    print(f"  Part {i+1} ({len(p.split())} mots): {p[:60]}...")
  Part 1 (56 mots): La diligence partit dans la neige. Dans la voiture, neuf pas...

Le decoupage LLM permet de respecter les frontieres narratives naturelles du texte (fins de phrase, changements de scene) plutot que de couper mecaniquement après N mots. La generation FishAudio qui suit utilise ce decoupage pour traiter chaque sous-segment individuellement, avec un mécanisme de cache pour eviter de regenerer les segments déjà produits.

FISH_OUTPUT_DIR = BASE_DIR / "tts_output_fishaudio"
FISH_OUTPUT_DIR.mkdir(exist_ok=True, parents=True)
SUB_DIR = FISH_OUTPUT_DIR / "sub_segments"
SUB_DIR.mkdir(exist_ok=True)

fish_generated = []

if fishaudio_available:
    fish_t_start = time.time()

    for req in tts_requests:
        fish_text = convert_tags_for_fishaudio(req.annotated_text)
        output_name = f"fish_seg_{req.seg_index:02d}_{req.fish_preset}.wav"
        output_path = FISH_OUTPUT_DIR / output_name

        # Skip if already successfully generated (>10KB = valid audio)
        if output_path.exists() and output_path.stat().st_size > 10000:
            existing_data = output_path.read_bytes()
            dur = audio_duration_s(existing_data)
            fish_generated.append(TTSRequest(
                seg_index=req.seg_index, seg_type=req.seg_type,
                speaker=req.speaker, text=req.text,
                annotated_text=fish_text, kokoro_voice=req.kokoro_voice,
                fish_preset=req.fish_preset, tags=req.tags,
                output_file=str(output_path), duration_s=dur,
                file_size_kb=len(existing_data) / 1024, status="success",
            ))
            print(f"Seg {req.seg_index:2d} ({req.seg_type:12s}): CACHED ({dur:.1f}s)")
            continue

        # Use LLM-based splitting for natural narrative boundaries
        chunks = llm_split_segment(fish_text, max_words=55)
        print(f"Seg {req.seg_index:2d} ({req.seg_type:12s}, {req.speaker}): "
              f"{len(chunks)} chunk(s), {len(req.text.split())} mots")

        chunk_files = []
        all_ok = True
        max_retries = 2

        for ci, chunk in enumerate(chunks):
            chunk_name = f"sub_{req.seg_index:02d}_{ci}_{req.fish_preset}.wav"
            chunk_path = SUB_DIR / chunk_name

            audio_data = None
            for attempt in range(max_retries):
                print(f"  Chunk {ci+1}/{len(chunks)} ({len(chunk.split())} mots)"
                      f" [attempt {attempt+1}]...", end=" ", flush=True)
                audio_data = fishaudio_tts(chunk, timeout=300)

                if audio_data and len(audio_data) > 1000:
                    with open(chunk_path, "wb") as f:
                        f.write(audio_data)
                    chunk_files.append(chunk_path)
                    dur_chunk = audio_duration_s(audio_data)
                    print(f"OK ({dur_chunk:.1f}s, {len(audio_data)/1024:.0f}KB)")
                    break
                else:
                    print(f"FAILED (got {len(audio_data) if audio_data else 0} bytes)")
                    if attempt < max_retries - 1:
                        print(f"    Retrying in 5s...")
                        time.sleep(5)

            if not audio_data or len(audio_data) <= 1000:
                all_ok = False
                break

            # Pause between chunks (FishAudio is single-threaded, no healthcheck needed)
            if ci < len(chunks) - 1:
                time.sleep(3)

        if all_ok and chunk_files:
            import shutil
            if len(chunk_files) == 1:
                shutil.copy2(chunk_files[0], output_path)
            else:
                concatenate_wav_files(chunk_files, output_path)

            final_data = output_path.read_bytes()
            final_dur = audio_duration_s(final_data)
            fish_generated.append(TTSRequest(
                seg_index=req.seg_index, seg_type=req.seg_type,
                speaker=req.speaker, text=req.text,
                annotated_text=fish_text, kokoro_voice=req.kokoro_voice,
                fish_preset=req.fish_preset, tags=req.tags,
                output_file=str(output_path), duration_s=final_dur,
                file_size_kb=len(final_data) / 1024, status="success",
            ))
            print(f"  => Seg {req.seg_index}: SUCCESS ({final_dur:.1f}s, {len(final_data)/1024:.0f}KB)")
        else:
            fish_generated.append(TTSRequest(
                seg_index=req.seg_index, seg_type=req.seg_type,
                speaker=req.speaker, text=req.text,
                annotated_text=fish_text, kokoro_voice=req.kokoro_voice,
                fish_preset=req.fish_preset, tags=req.tags,
                status="failed",
            ))
            print(f"  => Seg {req.seg_index}: FAILED")

        # Pause between segments
        time.sleep(2)

    fish_elapsed = time.time() - fish_t_start
    fish_ok = sum(1 for r in fish_generated if r.status == "success")
    print(f"\n{'='*60}")
    print(f"FishAudio: {fish_ok}/{len(fish_generated)} segments in {fish_elapsed:.0f}s")
else:
    print("FishAudio S2-Pro non disponible — generation skippee")
    print("Demarrer le service: docker compose -f docker-configurations/services/tts-fishaudio/docker-compose.yml up -d")
Seg  0 (narration   ): CACHED (9.4s)
Seg  1 (narration   ): CACHED (5.9s)
Seg  2 (dialogue    ): CACHED (6.2s)
Seg  3 (dialogue    ): CACHED (4.9s)
Seg  4 (narration   ): CACHED (21.4s)
Seg  5 (dialogue    ): CACHED (7.3s)
Seg  6 (dialogue    ): CACHED (4.3s)
Seg  7 (dialogue    ): CACHED (4.2s)
Seg  8 (description ): CACHED (16.4s)
Seg  9 (dialogue    ): CACHED (4.7s)
Seg 10 (dialogue    ): CACHED (6.2s)
Seg 11 (narration   ): CACHED (15.8s)
Seg 12 (dialogue    ): CACHED (7.2s)
Seg 13 (dialogue    ): CACHED (7.7s)

============================================================
FishAudio: 14/14 segments in 0s

Les 14 segments ont ete generes avec succes via FishAudio S2-Pro. Les segments longs (seg 4, seg 8, seg 11) ont ete automatiquement decoupes par le LLM en sous-parties narratives coherentes, puis reassemblees par concatenation WAV. Chaque segment beneficie des tags expressifs convertis depuis la notation P3.

if fish_generated:
    kokoro_dur = sum(r.duration_s for r in generated if r.status == "success")
    fish_dur = sum(r.duration_s for r in fish_generated if r.status == "success")
    kokoro_size = sum(r.file_size_kb for r in generated if r.status == "success")
    fish_size = sum(r.file_size_kb for r in fish_generated if r.status == "success")

    print("Comparaison Kokoro vs FishAudio S2-Pro:")
    print(f"  {'':20s} | {'Kokoro':>10s} | {'FishAudio':>10s}")
    print(f"  {'Segments':20s} | {len(generated):>10d} | {len(fish_generated):>10d}")
    print(f"  {'Duree totale':20s} | {kokoro_dur:>9.1f}s | {fish_dur:>9.1f}s")
    print(f"  {'Taille totale':20s} | {kokoro_size:>8.0f}KB | {fish_size:>8.0f}KB")
    print(f"  {'Duree moy/seg':20s} | {kokoro_dur/len(generated):>9.1f}s | {fish_dur/max(len(fish_generated),1):>9.1f}s")
    print(f"  {'Format audio':20s} | {'MP3':>10s} | {'WAV':>10s}")
    print(f"  {'Tags expressifs':20s} | {'Non':>10s} | {'Oui (15000+)':>10s}")
else:
    print("FishAudio non disponible — comparaison impossible")
    print("Le service FishAudio S2-Pro doit tourner sur le port 8197")
Comparaison Kokoro vs FishAudio S2-Pro:
                       |     Kokoro |  FishAudio
  Segments             |         14 |         14
  Duree totale         |     122.5s |     121.6s
  Taille totale        |     1913KB |    10473KB
  Duree moy/seg        |       8.7s |       8.7s
  Format audio         |        MP3 |        WAV
  Tags expressifs      |        Non | Oui (15000+)

Le tableau comparatif met en evidence un ecart significatif sur la taille des fichiers : FishAudio genere des WAV non compresses (~11000 KB) contre des MP3 compresse pour Kokoro (~1900 KB), soit un ratio de 5.7x. La duree totale est comparable (122.5s vs 127.9s), ce qui confirme que les deux modèles synthetisent a une vitesse naturelle similaire.

# Export FishAudio generation metadata for P5
fish_meta = {
    "epic": "1273",
    "pass": "P4b",
    "description": "TTS generation via FishAudio S2-Pro for audiobook production",
    "source_text": "Boule de Suif - Guy de Maupassant",
    "tts_model": "fishaudio-s2-pro",
    "tts_service_url": FISHAUDIO_URL,
    "stats": {
        "total_segments": len(tts_requests),
        "fish_generated": len(fish_generated),
        "fish_successful": sum(1 for r in fish_generated if r.status == "success"),
        "fish_total_duration_s": sum(r.duration_s for r in fish_generated if r.status == "success"),
    },
    "segments": [asdict(r) for r in fish_generated],
}

fish_meta_path = FISH_OUTPUT_DIR / "fish_generation_metadata.json"
with open(fish_meta_path, "w", encoding="utf-8") as f:
    json.dump(fish_meta, f, ensure_ascii=False, indent=2)

print(f"FishAudio metadata saved: {fish_meta_path.name}")
print(f"Segments: {fish_meta['stats']['fish_successful']}/{fish_meta['stats']['total_segments']}")
if fish_generated:
    fish_dur = fish_meta['stats']['fish_total_duration_s']
    print(f"Total FishAudio audio: {fish_dur:.1f}s ({fish_dur/60:.1f}min)")
else:
    print("No FishAudio segments generated")
FishAudio metadata saved: tts_output_fishaudio\fish_generation_metadata.json
Segments: 14/14
Total FishAudio audio: 121.6s (2.0min)

Compilation des livres audio complets

Deux assemblages bout-en-bout sont produits :

  • FishAudio S2-Pro (WAV) : les segments generes sont concatene en un seul fichier PCM non compresse (tts_output_fishaudio/boule_de_suif_fishaudio_complete.wav), avec des silences de 0.5 s entre les segments pour marquer les transitions narratif/dialogue (concatenation maison, sans dependance externe).
  • Kokoro (MP3) : les segments tts_output/*.mp3 sont assembles via ffmpeg (demuxer concat + encodeur libmp3lame 128 kbps, silences de 0.5 s generes par anullsrc) en audiobook_kokoro.mp3 — la meme technique de compilation que le notebook 04-12.

Les deux fichiers complets restent sur disque (artefacts de production). La section Comparaison ci-dessous embarque un extrait de 30 s de chacun pour l’ecoute cote a cote : les 28 players par segment embarquent deja l’integralite des deux rendus, doubler le notebook avec deux livres entiers n’apporterait rien de plus.

if fish_generated:
    success_segments = sorted(
        [r for r in fish_generated if r.status == "success"],
        key=lambda r: r.seg_index
    )
    if len(success_segments) >= 10:
        wav_files = [r.output_file for r in success_segments]
        audiobook_path = FISH_OUTPUT_DIR / "boule_de_suif_fishaudio_complete.wav"
        concatenate_wav_files(wav_files, audiobook_path, silence_duration=0.5)

        ab_data = audiobook_path.read_bytes()
        ab_dur = audio_duration_s(ab_data)
        print(f"Livre audio complet: {audiobook_path.name}")
        print(f"  Duree: {ab_dur:.1f}s ({ab_dur/60:.1f}min)")
        print(f"  Taille: {len(ab_data)/1024:.0f}KB")
        print(f"  Segments: {len(success_segments)}/{len(fish_generated)}")
        print(f"  Silences: 0.5s entre segments")
        print()

        for r in success_segments:
            dur_str = f"{r.duration_s:.1f}s"
            print(f"  Seg {r.seg_index:2d} | {r.seg_type:12s} | {r.speaker:25s} | {dur_str}")
    else:
        print(f"Seulement {len(success_segments)}/{len(fish_generated)} segments OK")
        print("Audiobook non compile — generation incomplete")
else:
    print("Aucun segment FishAudio genere — audiobook non disponible")
# --- Compilation Kokoro (MP3) : assemblage ffmpeg des segments tts_output ---
# Meme technique que 04-12 (demuxer concat + libmp3lame 128 kbps, silences anullsrc).
import subprocess

_kokoro_meta_c = TTS_OUTPUT_DIR / "tts_generation_metadata.json"
if _kokoro_meta_c.exists():
    _kokoro_ok = [
        s for s in json.loads(_kokoro_meta_c.read_text(encoding="utf-8"))["segments"]
        if s.get("status") == "success"
    ]
    if len(_kokoro_ok) >= 10:
        _compile_dir = BASE_DIR / "audiobook_compile"
        _compile_dir.mkdir(exist_ok=True, parents=True)

        # Silence MP3 0.5 s (mono 24 kHz 128 kbps — parametres des segments kokoro)
        _silence = _compile_dir / "silence_500ms.mp3"
        _res_sil = subprocess.run(
            ["ffmpeg", "-y", "-f", "lavfi", "-i", "anullsrc=r=24000:cl=mono",
             "-t", "0.5", "-b:a", "128k", str(_silence)],
            capture_output=True, text=True)

        if _res_sil.returncode == 0:
            _concat_list = _compile_dir / "concat_list.txt"
            with open(_concat_list, "w", encoding="utf-8") as f:
                _ordered = sorted(_kokoro_ok, key=lambda s: s["seg_index"])
                for _i, _seg in enumerate(_ordered):
                    f.write(f"file '{(BASE_DIR / _seg['output_file']).resolve()}'\n")
                    if _i < len(_ordered) - 1:
                        f.write(f"file '{_silence.resolve()}'\n")

            kokoro_audiobook_path = BASE_DIR / "audiobook_kokoro.mp3"
            _res_cat = subprocess.run(
                ["ffmpeg", "-y", "-f", "concat", "-safe", "0",
                 "-i", str(_concat_list),
                 "-c:a", "libmp3lame", "-b:a", "128k",
                 str(kokoro_audiobook_path)],
                capture_output=True, text=True)

            if _res_cat.returncode == 0:
                _kb = kokoro_audiobook_path.read_bytes()
                _kdur = audio_duration_s(_kb)
                print(f"Livre audio complet Kokoro: {kokoro_audiobook_path.name}")
                print(f"  Duree: {_kdur:.1f}s ({_kdur/60:.1f}min)")
                print(f"  Taille: {len(_kb)/1024:.0f}KB")
                print(f"  Segments: {len(_kokoro_ok)} | Silences: 0.5s | ffmpeg libmp3lame 128k")
            else:
                print(f"Erreur concatenation Kokoro: {_res_cat.stderr[:300]}")
        else:
            print("Erreur generation du silence ffmpeg")
    else:
        print(f"Segments Kokoro insuffisants ({len(_kokoro_ok)}) — compilation MP3 ignoree")
else:
    print("Metadonnees Kokoro absentes — compilation MP3 ignoree")
Livre audio complet: boule_de_suif_fishaudio_complete.wav
  Duree: 128.1s (2.1min)
  Taille: 11032KB
  Segments: 14/14
  Silences: 0.5s entre segments

  Seg  0 | narration    | Narrateur                 | 9.4s
  Seg  1 | narration    | Narrateur                 | 5.9s
  Seg  2 | dialogue     | Narrateur                 | 6.2s
  Seg  3 | dialogue     | Cornudet                  | 4.9s
  Seg  4 | narration    | Narrateur                 | 21.4s
  Seg  5 | dialogue     | Elisabeth Rousset         | 7.3s
  Seg  6 | dialogue     | Comtesse de Breville      | 4.3s
  Seg  7 | dialogue     | Narrateur                 | 4.2s
  Seg  8 | description  | Narrateur                 | 16.4s
  Seg  9 | dialogue     | Narrateur                 | 4.7s
  Seg 10 | dialogue     | Narrateur                 | 6.2s
  Seg 11 | narration    | Narrateur                 | 15.8s
  Seg 12 | dialogue     | Comte Hubert de Breville  | 7.2s
  Seg 13 | dialogue     | Elisabeth Rousset         | 7.7s
Livre audio complet Kokoro: audiobook_kokoro.mp3
  Duree: 129.0s (2.2min)
  Taille: 2016KB
  Segments: 14 | Silences: 0.5s | ffmpeg libmp3lame 128k

Lecture de la concatenation WAV : cout de stockage vs flexibilite d edition

Observation cle : boule_de_suif_fishaudio_complete.wav pese 11032 KB pour 128.1s audio (ratio ~86 KB/s) et l assemblage Kokoro MP3 pese 2016 KB pour 129.0s (ratio ~16 KB/s). Le facteur 5.5x entre les deux formats est determine par le codec (WAV PCM 16-bit non compresse vs MP3 128 kbps), pas par le contenu. Pour 14 segments + silences de 0.5s, l overhead d assemblage est marginal (~2.5 KB pour 7 silences).

Pourquoi WAV non compresse ici plutot que FLAC : la concatenation maison utilise le format PCM brut (header WAV + donnees PCM concatenees avec un silence zero-insert). FLAC necessiterait un encodeur, une entete par sous-stream et un re-muxing. Le WAV est le plus simple chemin pour assembler 14 segments sans dependance externe lourde (hors struct du stdlib). Pour une archive de production, on reconvertira en AAC/Opus/FLAC ulterieurement avec ffmpeg, en gardant le WAV comme source de verite.

Limites de la concatenation maison : (1) pas de normalisation de volume entre segments — si Kokoro seg4 sort a -3 dB et seg5 a -10 dB, la concatenation preserve l ecart (audible comme une saute de volume). FishAudio est plus stable en niveau mais pas garanti constant non plus. (2) pas de crossfade entre segments — les silences de 0.5s sont nets (zero-crossing brutal), pas un fondu. Pour une experience d ecoute agreable, un crossfade de 50ms masquerait mieux les jonctions. (3) header WAV ecrit a la main : si le futur pipeline veut ajouter des metadonnees (titre, artiste, chapitre), il faudra soit regenerer le header soit basculer sur un muxer (pydub, soundfile).

Implications pour P5 (Compilation) : la cellule de concatenation produit un artefact de production sur disque, pas un livrable utilisateur final. Le notebook suivant (04-12) prend ce WAV en entree, applique les corrections de volume et de crossfade, et exporte le MP3 final avec metadonnees ID3. Le WAV reste disponible comme source pour des regenerations ou des variantes (AAC, Opus, version stereo).

Comparaison detaillee Kokoro vs FishAudio (segment par segment)

Pour chaque segment, vous pouvez ecouter les deux versions et comparer la qualite, l’expressivite et le naturel de la synthese. Cette cellule produit a la fois un tableau quantitatif (durees, tailles, delta) et un comparateur audio interactif (balise HTML <audio controls>) chargeant le MP3 Kokoro et le WAV FishAudio cote a cote.

Lecture du tableau quantitatif (mesures verbatim de la cellule)

Sur ce run, l’agrege est quasi a parite : 122.5s Kokoro vs 121.6s FishAudio (delta -0.9s). Cette quasi-parite masque la structure reelle, qui est une redistribution : FishAudio S2-Pro utilise les (emotion tags) pour ajouter des pauses, des variations de hauteur, des souffles entre mots — il etire la narration descriptive (seg 4 : +2.3s, seg 11 : +1.1s) et raccourcit les repliques courtes (seg 6, 12 : -1.5s chacune). Kokoro, qui ignore les tags [whisper], reste plus regulier d’un segment a l’autre. Les durees d’un TTS neural varient legerement a chaque generation : les chiffres cites ici sont les mesures verbatim de la cellule ci-dessous.

Segment Type Kokoro FishAudio Delta Observation pedagogique
0 narration 9.0s 9.4s +0.4s Ecart marginal sur l’intro
4 narration 19.1s 21.4s +2.3s FishAudio etire les pauses descriptives
6 dialogue 5.8s 4.3s -1.5s FishAudio coupe une replique pathetique
11 narration 14.7s 15.8s +1.1s Idem seg 4 — prosodie expressive
12 dialogue 8.8s 7.2s -1.5s Idem seg 6 — replique du Comte

Interpretations des asymetries

Les deltas negatifs (eg. seg 6, 12) apparaissent sur des dialogues courts a fort pathos : Kokoro ralentit le rythme d’une replique emouvante par defaut de prosodie, tandis que FishAudio les prononce avec un naturel cinematographique. A l’inverse, les deltas positifs importants (seg 4, 11) se concentrent sur les passages de narration descriptive (la diligence dans la neige, le silence glacial) : FishAudio exploite les pauses pour creer une atmosphere, Kokoro les mange.

Cote taille, ratio de ~5.7x

FishAudio genere des WAV non compresses (~11000 KB) contre MP3 128 kbps pour Kokoro (~1900 KB). Pour la diffusion web ou mobile, on recompressera les WAV en AAC ou Opus avant publication — sinon le cout de bande passante est prohibitif. Pour une archive de production, garder le WAV preserve la qualite.

Cas d’usage typique

  • Demo locale rapide / iteration rapide → Kokoro (latence ~2s, ratio MP3 raisonnable, pas besoin de GPU dedie).
  • Livraison finale audio (livrable audiobook) → FishAudio S2-Pro (qualite expressive, taille WAV acceptable pour archivage).
  • Cas hybride → utiliser Kokoro pour les segments techniques (transitions, listes) et FishAudio pour les passages narratifs critiques (intro, climax, fin).
from IPython.display import Audio, HTML, display
import json

# Load Kokoro metadata (obligatoire) + FishAudio metadata (optionnel, cf 04-13)
kokoro_meta_path = BASE_DIR / "tts_output" / "tts_generation_metadata.json"
fish_meta_path = FISH_OUTPUT_DIR / "fish_generation_metadata.json"

if kokoro_meta_path.exists():
    with open(kokoro_meta_path) as f:
        kokoro_meta = json.load(f)
    kokoro_segs = {s["seg_index"]: s for s in kokoro_meta["segments"]}

    fish_meta = {}
    fish_segs = {}
    if fish_meta_path.exists():
        with open(fish_meta_path) as f:
            fish_meta = json.load(f)
        fish_segs = {s["seg_index"]: s for s in fish_meta["segments"]}

    if fish_segs:
        print("=" * 80)
        print(f"{'Seg':>3} | {'Type':12s} | {'Kokoro':>8s} | {'FishAudio':>8s} | {'Delta':>7s} | "
              f"{'Kokoro KB':>9s} | {'Fish KB':>8s} | {'Speaker':25s}")
        print("-" * 110)

        total_kokoro = 0.0
        total_fish = 0.0
        for i in range(14):
            ks = kokoro_segs.get(i, {})
            fs = fish_segs.get(i, {})
            k_dur = ks.get("duration_s", 0)
            f_dur = fs.get("duration_s", 0)
            k_size = ks.get("file_size_kb", 0)
            f_size = fs.get("file_size_kb", 0)
            delta = f_dur - k_dur
            seg_type = ks.get("seg_type", "?")
            speaker = ks.get("speaker", "?")

            delta_str = f"+{delta:.1f}s" if delta >= 0 else f"{delta:.1f}s"
            k_status = "OK" if ks.get("status") == "success" else "FAIL"
            f_status = "OK" if fs.get("status") == "success" else "FAIL"

            print(f"{i:>3} | {seg_type:12s} | {k_dur:>7.1f}s | {f_dur:>7.1f}s | {delta_str:>7s} | "
                  f"{k_size:>8.0f}KB | {f_size:>6.0f}KB | {speaker:25s}")
            total_kokoro += k_dur
            total_fish += f_dur

        print("-" * 110)
        delta_total = total_fish - total_kokoro
        print(f"{'TOT':>3} | {'':12s} | {total_kokoro:>7.1f}s | {total_fish:>7.1f}s | {delta_total:>+7.1f}s |")
        print(f"\nKokoro total: {total_kokoro:.1f}s ({total_kokoro/60:.1f}min) | FishAudio total: {total_fish:.1f}s ({total_fish/60:.1f}min)")
        print(f"FishAudio est {abs(delta_total):.1f}s {'plus lent' if delta_total > 0 else 'plus rapide'} que Kokoro")
    else:
        print("FishAudio non genere (optionnel, cf 04-13) — comparaison Kokoro seule.")
        print("=" * 80)
        print(f"{'Seg':>3} | {'Type':12s} | {'Kokoro':>8s} | {'Kokoro KB':>9s} | {'Speaker':25s}")
        print("-" * 80)
        total_kokoro = 0.0
        for i in range(14):
            ks = kokoro_segs.get(i, {})
            k_dur = ks.get("duration_s", 0)
            k_size = ks.get("file_size_kb", 0)
            seg_type = ks.get("seg_type", "?")
            speaker = ks.get("speaker", "?")
            print(f"{i:>3} | {seg_type:12s} | {k_dur:>7.1f}s | {k_size:>8.0f}KB | {speaker:25s}")
            total_kokoro += k_dur
        print("-" * 80)
        print(f"{'TOT':>3} | {'':12s} | {total_kokoro:>7.1f}s |")
        print(f"\nKokoro total: {total_kokoro:.1f}s ({total_kokoro/60:.1f}min)")

    # Interactive audio player for each segment (embed=True -> jouable dans le notebook committe)
    print("\n" + "=" * 80)
    print("Ecoute comparative segment par segment :")
    print("=" * 80)

    for i in range(14):
        ks = kokoro_segs.get(i, {})
        if ks.get("status") != "success":
            continue

        text = ks.get("text", "")[:80] + ("..." if len(ks.get("text", "")) > 80 else "")
        display(HTML(f"<h4>Segment {i} — {ks.get('seg_type','?')} ({ks.get('speaker','?')})</h4>"))
        display(HTML(f"<p><i>{text}</i></p>"))

        # Kokoro player
        kokoro_path = BASE_DIR / ks["output_file"]
        if kokoro_path.exists():
            display(HTML(f"<b>Kokoro</b> ({ks['duration_s']:.1f}s, {ks['kokoro_voice']})"))
            display_audio_file(kokoro_path)

        # FishAudio player (optionnel)
        fs = fish_segs.get(i, {})
        if fs.get("status") == "success":
            fish_path = BASE_DIR / fs["output_file"]
            if fish_path.exists():
                display(HTML(f"<b>FishAudio S2-Pro</b> ({fs['duration_s']:.1f}s, {fs['fish_preset']})"))
                display_audio_file(fish_path)

        display(HTML("<hr>"))

    # Audiobook comparison — lecteurs pointes sur les artefacts REELS produits par la
    # cellule Compilation : kokoro -> audiobook_kokoro.mp3 (ffmpeg), fish ->
    # tts_output_fishaudio/boule_de_suif_fishaudio_complete.wav. Extrait 30 s embarque
    # (stream copy, pas de re-encodage) ; le fichier complet reste sur disque.
    import subprocess

    def make_excerpt(src, dst, seconds=30):
        """Extrait verbatim (stream copy ffmpeg) des N premieres secondes."""
        res = subprocess.run(
            ["ffmpeg", "-y", "-i", str(src), "-t", str(seconds), "-c:a", "copy", str(dst)],
            capture_output=True, text=True)
        return res.returncode == 0

    print("=" * 80)
    print("Livres audio complets :")
    print("=" * 80)

    kokoro_audiobook = BASE_DIR / "audiobook_kokoro.mp3"
    fish_audiobook = FISH_OUTPUT_DIR / "boule_de_suif_fishaudio_complete.wav"

    for label, full_path in [("Kokoro", kokoro_audiobook),
                             ("FishAudio S2-Pro", fish_audiobook)]:
        if full_path.exists():
            dur = audio_duration_s(full_path.read_bytes())
            print(f"{label}: {full_path.name} | {dur:.1f}s ({dur/60:.1f}min) | "
                  f"{full_path.stat().st_size/1024:.0f}KB")
            excerpt_path = BASE_DIR / f"audiobook_excerpt_{full_path.stem}{full_path.suffix}"
            if make_excerpt(full_path, excerpt_path):
                display(HTML(f"<h4>Livre audio complet — {label} (extrait 30s)</h4>"))
                display(HTML(f"<p>Fichier complet : <code>{full_path.name}</code></p>"))
                display_audio_file(excerpt_path)
            else:
                print(f"  Extrait {label} indisponible (ffmpeg)")
        else:
            print(f"{label}: fichier complet absent ({full_path.name})")
else:
    print("Metadonnees Kokoro manquantes — generation TTS non executee")
    print(f"  Kokoro: {kokoro_meta_path.name} ({'OK' if kokoro_meta_path.exists() else 'MANQUANT'})")
    print(f"  FishAudio: {fish_meta_path.name} ({'OK' if fish_meta_path.exists() else 'MANQUANT'})")
================================================================================
Seg | Type         |   Kokoro | FishAudio |   Delta | Kokoro KB |  Fish KB | Speaker                  
--------------------------------------------------------------------------------------------------------------
  0 | narration    |     9.0s |     9.4s |   +0.4s |      140KB |    808KB | Narrateur                
  1 | narration    |     6.6s |     5.9s |   -0.7s |      102KB |    508KB | Narrateur                
  2 | dialogue     |     6.8s |     6.2s |   -0.7s |      107KB |    532KB | Narrateur                
  3 | dialogue     |     5.5s |     4.9s |   -0.7s |       87KB |    420KB | Cornudet                 
  4 | narration    |    19.1s |    21.4s |   +2.3s |      299KB |   1840KB | Narrateur                
  5 | dialogue     |     6.4s |     7.3s |   +1.0s |      100KB |    632KB | Elisabeth Rousset        
  6 | dialogue     |     5.8s |     4.3s |   -1.5s |       90KB |    372KB | Comtesse de Breville     
  7 | dialogue     |     5.5s |     4.2s |   -1.2s |       85KB |    364KB | Narrateur                
  8 | description  |    15.8s |    16.4s |   +0.6s |      247KB |   1412KB | Narrateur                
  9 | dialogue     |     4.6s |     4.7s |   +0.1s |       72KB |    404KB | Narrateur                
 10 | dialogue     |     6.2s |     6.2s |   +0.0s |       97KB |    536KB | Narrateur                
 11 | narration    |    14.7s |    15.8s |   +1.1s |      229KB |   1360KB | Narrateur                
 12 | dialogue     |     8.8s |     7.2s |   -1.5s |      137KB |    624KB | Comte Hubert de Breville 
 13 | dialogue     |     7.8s |     7.7s |   -0.2s |      122KB |    660KB | Elisabeth Rousset        
--------------------------------------------------------------------------------------------------------------
TOT |              |   122.5s |   121.6s |    -0.9s |

Kokoro total: 122.5s (2.0min) | FishAudio total: 121.6s (2.0min)
FishAudio est 0.9s plus rapide que Kokoro

================================================================================
Ecoute comparative segment par segment :
================================================================================

Segment 0 — narration (Narrateur)

Pendant plusieurs jours, des lambeaux d'armees en deroute avaient traverse la vi...

Kokoro (9.0s, bf_isabella)
FishAudio S2-Pro (9.4s, narrator_male)

Segment 1 — narration (Narrateur)

Quelques-uns demeuraient chez eux. La peur entrait dans les ames. Les Prussiens ...

Kokoro (6.6s, bf_isabella)
FishAudio S2-Pro (5.9s, narrator_male)

Segment 2 — dialogue (Narrateur)

Et ils ne font rien de mal aux gens ? demanda timidement une grosse dame, la com...

Kokoro (6.8s, bf_isabella)
FishAudio S2-Pro (6.2s, narrator_male)

Segment 3 — dialogue (Cornudet)

Rassurez-vous, madame, dit Cornudet avec un sourire, ils sont corrects.

Kokoro (5.5s, bm_george)
FishAudio S2-Pro (4.9s, expressive_male_neutral)

Segment 4 — narration (Narrateur)

La diligence partit dans la neige. Dans la voiture, neuf passagers se tenaient s...

Kokoro (19.1s, bf_isabella)
FishAudio S2-Pro (21.4s, narrator_male)

Segment 5 — dialogue (Elisabeth Rousset)

Mon Dieu ! murmura Boule de Suif, je n'ai rien a manger. Je suis partie sans pre...

Kokoro (6.4s, af_sky)
FishAudio S2-Pro (7.3s, expressive_female_warm)

Segment 6 — dialogue (Comtesse de Breville)

Prenez ceci, ma chere demoiselle, dit la comtesse en tendant un morceau de pain.

Kokoro (5.8s, af_sarah)
FishAudio S2-Pro (4.3s, expressive_female_cold)

Segment 7 — dialogue (Narrateur)

Merci, madame, vous etes bien bonne, repondit Elisabeth en rougissant.

Kokoro (5.5s, bf_isabella)
FishAudio S2-Pro (4.2s, narrator_male)

Segment 8 — description (Narrateur)

Le voyage se poursuivit dans un silence glacial. La neige tombait sans interrupt...

Kokoro (15.8s, bf_isabella)
FishAudio S2-Pro (16.4s, narrator_male)

Segment 9 — dialogue (Narrateur)

Jamais ! cria-t-elle avec indignation. Vous ne m'aurez pas !

Kokoro (4.6s, bf_isabella)
FishAudio S2-Pro (4.7s, narrator_male)

Segment 10 — dialogue (Narrateur)

C'est regrettable, dit froidement l'officier. Dans ce cas, la diligence ne parti...

Kokoro (6.2s, bf_isabella)
FishAudio S2-Pro (6.2s, narrator_male)

Segment 11 — narration (Narrateur)

Les jours passerent. Les voyageurs, affames et impatients, commencerent a faire ...

Kokoro (14.7s, bf_isabella)
FishAudio S2-Pro (15.8s, narrator_male)

Segment 12 — dialogue (Comte Hubert de Breville)

Vous comprenez, ma chere demoiselle, dit le comte avec onction, il s'agit du bie...

Kokoro (8.8s, am_michael)
FishAudio S2-Pro (7.2s, expressive_male_cold)

Segment 13 — dialogue (Elisabeth Rousset)

Ce n'est pas un sacrifice que vous me demandez, murmura Boule de Suif, les yeux ...

Kokoro (7.8s, af_sky)
FishAudio S2-Pro (7.7s, expressive_female_warm)

================================================================================
Livres audio complets :
================================================================================
Kokoro: audiobook_kokoro.mp3 | 129.0s (2.2min) | 2016KB

Livre audio complet — Kokoro (extrait 30s)

Fichier complet : audiobook_kokoro.mp3

FishAudio S2-Pro: boule_de_suif_fishaudio_complete.wav | 128.1s (2.1min) | 11032KB

Livre audio complet — FishAudio S2-Pro (extrait 30s)

Fichier complet : tts_output_fishaudio\boule_de_suif_fishaudio_complete.wav

Lecture du tableau : pourquoi la parite temporelle masque une redistribution structurelle

Observation cle : l’agrege 122.5s Kokoro vs 121.6s FishAudio (delta global -0.9s) cache une redistribution, pas une stabilite. Les deltas segment-par-segment montrent une asymetrie systematique entre les types de segments que le seul total ne revele pas.

Asymetrie narration vs dialogue : les passages narratifs (seg4 = description de la diligence dans la neige, seg11 = description du silence glacial) sont etires par FishAudio de +1.1 a +2.3s. FishAudio exploite les pauses et les variations de hauteur pour creer l’atmosphere — Kokoro, qui ignore les tags P3, reste lineaire. Inversement, les repliques de personnages a fort pathos (seg6 = Comtesse de Breville, seg12 = Comte Hubert) sont raccourcies par FishAudio de -1.5s chacune : Kokoro ralentit par defaut de prosodie expressive, FishAudio prononce avec un naturel cinematographique plus dense. Cette redistribution est coherente avec la nature des deux moteurs : Kokoro optimise la lisibilite (regularite temporelle), FishAudio optimise l’expressivite (rythme adapte au contenu).

Implications pratiques : (1) le budget temps audiobook ne depend pas que du modele mais du type de segments — pour un texte a dominante narrative, prevoir FishAudio +5 a +10% de duree ; (2) pour une restitution pedagogique (clarte > naturel), Kokoro est preferable malgre l’absence de tags ; (3) le ratio de taille 5.7x (WAV non compresse vs MP3 128 kbps) reste un facteur de bande passante pour la diffusion, mais la duree reste comparable (122.5s vs 121.6s), donc le cout de generation est du meme ordre.

Lien avec P5 : la compilation des livres audio (cellule de la section precedente) preserve la duree reelle de chaque moteur — P5 peut choisir l’un ou l’autre pour l’assemblage final selon la cible (web mobile = Kokoro MP3, archive studio = FishAudio WAV).

Exercice : Générateur de rapport de comparaison TTS

Duree estimee : 15-20 minutes

Objectif : Créer une fonction qui genere un rapport comparatif automatique entre Kokoro et FishAudio pour un ensemble de segments. Le rapport inclut : différence de duree moyenne, différence de taille, voix les plus/moins rapides, et recommandation du meilleur modèle par cas d’usage.

Contexte : Pour choisir entre Kokoro et FishAudio en production, il faut un rapport synthetique qui va au-dela du simple tableau de chiffres et fournit des recommandations actionnables.

  • Étape 1 : Calculer les metriques globales (duree moyenne, taille moyenne, ratio taille/duree)
  • Étape 2 : Identifier les segments avec le plus grand ecart de qualite (delta de duree > 2s)
  • Étape 3 : Generer un rapport texte formate avec recommandations

Indice : Parcourez les metadonnees kokoro_meta et fish_meta (déjà chargees) Indice : Comparez segment par segment via le champ seg_index Indice : Formatez avec des f-strings et des alignements pour un rendu propre

# TODO etudiant : generateur de rapport comparatif TTS
# Etape 1 : Metriques globales
def compute_tts_metrics(segments_list):
    """Calcule duree moyenne, taille moyenne et ratio taille/duree."""
    # TODO etudiant
    return {}  # TODO etudiant

# Etape 2 : Segments avec ecarts significatifs
def find_outlier_segments(kokoro_segs, fish_segs, threshold_s=2.0):
    """Identifie les segments ou le delta de duree depasse le seuil."""
    # TODO etudiant
    return []  # TODO etudiant

# Etape 3 : Rapport formate
def generate_comparison_report(kokoro_segs, fish_segs):
    """Genere un rapport comparatif Kokoro vs FishAudio."""
    # TODO etudiant
    return ""  # TODO etudiant

print("Exercice a completer")
Exercice a completer
Retour au sommet