Pipeline Audiobook Agentique

Module : 04-Audio-Applications
Niveau : Applications
Technologies : OpenAI GPT, OpenAI TTS, Whisper, pydub
VRAM estimee : ~5 GB
Duree estimee : 45 minutes

Objectifs d’Apprentissage

Prerequis

  • Notebook 04-1 (Creation de Contenu Audio Educatif) recommande
  • Cle API OpenAI configuree (OPENAI_API_KEY dans .env)
  • pydub installe (manipulation audio)
  • Comprehension de base du TTS et du parsing de texte

Navigation : Index | << Précédent

# Parametres Papermill - JAMAIS modifier ce commentaire

# Configuration notebook
notebook_mode = "interactive"        # "interactive" ou "batch"
skip_widgets = False               # True pour mode batch MCP
debug_level = "INFO"

# Parametres audiobook
tts_model = "tts-1"               # "tts-1" ou "tts-1-hd"
llm_model = "gpt-4o-mini"         # Modele LLM pour analyse de texte
narrator_voice = "nova"            # Voix du narrateur
character_voices = {               # Mapping personnage -> voix OpenAI
    "narrateur": "nova",
    "personnage_1": "onyx",
    "personnage_2": "alloy"
}
sample_text_author = "Maupassant"  # Auteur du texte d'exemple
sample_text_title = "Boule de Suif" # Oeuvre d'exemple

# Configuration sauvegarde
generate_audio = True
save_audio_files = True
# Parameters
BATCH_MODE = True
skip_widgets = True

L’environnement de travail est configure avec les paramètres Papermill. La cellule suivante charge les bibliotheques Python necessaires : gestion des fichiers audio, appels API et utilitaires de traitement.

# Setup environnement et imports
import os
import sys
import json
import time
import re
from pathlib import Path
from datetime import datetime
from typing import Dict, List, Any, Optional
from io import BytesIO
import logging

from IPython.display import Audio, display, HTML

# Resolution GENAI_ROOT
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))
    try:
        from helpers.audio_helpers import (
            play_audio_bytes, display_audio_bundle, synthesize_openai, transcribe_openai,
            get_audio_info, estimate_audio_duration
        )
        print("Helpers audio importes")
    except ImportError as e:
        print(f"Helpers audio non disponibles - mode autonome : {e}")

# Repertoire de sortie
OUTPUT_DIR = GENAI_ROOT / 'outputs' / 'audio' / 'audiobook'
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)

# Configuration logging
logging.basicConfig(level=getattr(logging, debug_level))
logger = logging.getLogger('audiobook_pipeline')

print(f"Pipeline Audiobook Agentique")
print(f"Date : {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
print(f"Mode : {notebook_mode}, TTS : {tts_model}")
print(f"Voix narrateur : {narrator_voice}")
print(f"Voix personnages : {character_voices}")
print(f"Sortie : {OUTPUT_DIR.relative_to(GENAI_ROOT)}")
Helpers audio importes
Pipeline Audiobook Agentique
Date : 2026-08-16 01:51:44
Mode : interactive, TTS : tts-1
Voix narrateur : nova
Voix personnages : {'narrateur': 'nova', 'personnage_1': 'onyx', 'personnage_2': 'alloy'}
Sortie : outputs\audio\audiobook

Les modules Python sont importes. Cette cellule charge les cles API depuis le fichier .env en remontant l’arborescence de repertoires pour s’adapter aux différents contextes d’exécution (local, Papermill, CI).

# Chargement robuste de la configuration .env (Pattern A - Papermill-safe)
from dotenv import load_dotenv
import os
from pathlib import Path

current_path = Path.cwd()
genai_path = None

# Remonter jusqu'au dossier GenAI
while len(current_path.parts) > 1:
    if current_path.name == "GenAI":
        genai_path = current_path
        break
    current_path = current_path.parent

if genai_path:
    env_path = genai_path / ".env"
    if env_path.exists():
        load_dotenv(env_path)
        print(f".env charge depuis: {env_path.name}")

# Configuration API OpenAI
openai_key = os.environ.get('OPENAI_API_KEY', '')
openai_available = bool(openai_key and not openai_key.startswith('sk-or-'))

if openai_available:
    print("OPENAI_API_KEY valide - API disponible")
    from openai import OpenAI
    client = OpenAI(api_key=openai_key)
else:
    print("OPENAI_API_KEY non disponible ou invalide (cle OpenRouter)")
    openai_key = "dummy_key_for_validation"
    client = None
.env charge depuis: .env
OPENAI_API_KEY valide - API disponible

Architecture 5 passes de l’audiobook agentique

Ce notebook implemente un pipeline de production audiobook en 5 passes. Chaque passe est realisee par un “agent” specialise (un prompt LLM ou un traitement algorithmique) :

Pass Nom Rôle Model/Tool Notebook de reference
P1 Lecture analytique Extraire personnages et dialogues GPT-4o-mini Ce notebook
P2 Voice casting Assigner une voix unique par personnage XTTS/Fish 02-2-XTTS-Voice-Cloning
P3 Annotation prosodique Ajouter des tags d’emotion et de rythme GPT + SSML 02-8-Expressive-TTS
P4 Generation TTS Synthetiser chaque segment audio OpenAI TTS Ce notebook
P5 Compilation audio Assembler en fichier final pydub + ffmpeg Ce notebook

Ce notebook couvre P1, P4 (simplifie) et P5. Les passes P2 et P3 sont abordees dans les notebooks avances de la serie 02.

Architecture du pipeline :

Texte litteraire brut
         |
    [P1] GPT-4o-mini
   (Analyse personnages + dialogues)
         |
  characters.json + utterances.json
         |
    [P2] Voice casting (notebook 02-2)
   (Assignation voix par personnage)
         |
    [P3] Annotation prosodique (notebook 02-8)
   (Tags emotion, rythme, intonation)
         |
  Script multi-voix annote
         |
    [P4] OpenAI TTS
   (Synthese audio par segment)
         |
    chunks/*.mp3
         |
    [P5] pydub + ffmpeg
   (Concatenation + normalisation)
         |
  audiobook_chapitre.mp3

Section 1 : Pass 1 - Analyse du texte litteraire

La première passe analyse le texte pour identifier les personnages, leurs traits, et extraire les dialogues. Pour un audiobook, cette analyse est cruciale car elle determine le nombre de voix necessaires et la structure de la narration.

Étape Description Outil
Texte source Extrait litteraire (domaine public) –
Extraction personnages Identifier noms, traits, relations GPT-4o-mini
Extraction dialogues Isoler les repliques avec locuteur GPT-4o-mini
Structuration Formatter en JSON exploitable Python

Nous utilisons un extrait de “Boule de Suif” de Guy de Maupassant (1880, domaine public).

# Pass 1 : Analyse du texte litteraire
print("PASS 1 : ANALYSE DU TEXTE LITTERAIRE")
print("=" * 50)

# Extrait de "Boule de Suif" de Maupassant (domaine public, 1880)
sample_text = """
Pendant plusieurs jours, des debris de l'armee en deroute avaient traverse
la ville. Ce n'etait point de la troupe, mais des hordes deguenillees.
Les hommes courbaient le dos, trainant une demarche lente et fatigue,
la figure terreuse, et les yeux hagards.

Dans la voiture de stage qui partait pour Dieppe, se trouvaient reunis
des voyageurs de la haute societe. Madame Loiseau, femme d'un grossier
marchand de vin, avait ete bonne grue. Le comte Hubert de Breville,
proprietaire de trois domaines de chasse, occupait le coin oppose.

Boule de Suif, une fille de joie ronde et grasse, offrit son panier
de provisions aux voyageurs affames. -- Tenez, messieurs, dit-elle,
je vous en prie, prenez un peu de nourriture. -- Le comte accepta
avec courtoisie, tandis que Madame Loiseau murmurait entre ses dents.
""".strip()

print(f"Texte source : {sample_text_author}, \"{sample_text_title}\"")
print(f"Longueur : {len(sample_text)} caracteres")
print(f"\n--- Apercu du texte ---")
print(sample_text[:300] + "...")

# Prompt d'analyse structuree
analysis_prompt = f"""
Analyse ce texte litteraire et extrais les informations suivantes en JSON.

1. "characters" : liste des personnages avec pour chacun :
   - "name" : nom du personnage
   - "description" : description courte (max 15 mots)
   - "role" : role dans le scene ("principal", "secondaire", "narrateur")

2. "utterances" : liste des repliques de dialogue avec :
   - "speaker" : nom du locuteur
   - "text" : texte exact de la replique
   - "context" : indication de ton (ex: "courtois", "meprisant")

Texte a analyser :
---
{sample_text}
---

Reponds UNIQUEMENT en JSON valide, sans commentaires ni markdown.
"""

characters_data = {}
utterances_data = []

if generate_audio and client:
    start_time = time.time()
    response = client.chat.completions.create(
        model=llm_model,
        messages=[{"role": "user", "content": analysis_prompt}],
        temperature=0.3,
        max_tokens=1000
    )
    analysis_raw = response.choices[0].message.content
    gen_time = time.time() - start_time

    print(f"\nAnalyse generee en {gen_time:.1f}s")
    print(f"Tokens utilises : {response.usage.total_tokens}")

    # Parser le JSON
    try:
        # Nettoyer le markdown si present
        clean_json = analysis_raw.strip()
        if clean_json.startswith('```'):
            clean_json = re.sub(r'^```(?:json)?\s*', '', clean_json)
            clean_json = re.sub(r'\s*```$', '', clean_json)
        analysis_result = json.loads(clean_json)
        characters_data = analysis_result.get('characters', [])
        utterances_data = analysis_result.get('utterances', [])

        print(f"\n--- Personnages identifies ({len(characters_data)}) ---")
        for char in characters_data:
            print(f"  {char.get('name', '?'):20s} | {char.get('role', '?'):12s} | {char.get('description', '')}")

        print(f"\n--- Repliques extraites ({len(utterances_data)}) ---")
        for utt in utterances_data:
            print(f"  [{utt.get('speaker', '?')}] ({utt.get('context', '')}) : {utt.get('text', '')[:60]}")
    except json.JSONDecodeError as e:
        print(f"Erreur de parsing JSON : {e}")
        print(f"Reponse brute : {analysis_raw[:200]}")
else:
    print("Generation desactivee (generate_audio=False ou client indisponible)")
PASS 1 : ANALYSE DU TEXTE LITTERAIRE
==================================================
Texte source : Maupassant, "Boule de Suif"
Longueur : 807 caracteres

--- Apercu du texte ---
Pendant plusieurs jours, des debris de l'armee en deroute avaient traverse
la ville. Ce n'etait point de la troupe, mais des hordes deguenillees.
Les hommes courbaient le dos, trainant une demarche lente et fatigue,
la figure terreuse, et les yeux hagards.

Dans la voiture de stage qui partait pour ...

Analyse generee en 3.3s
Tokens utilises : 630

--- Personnages identifies (3) ---
  Boule de Suif        | principal    | Fille de joie ronde et grasse.
  Madame Loiseau       | secondaire   | Femme d'un grossier marchand de vin.
  Comte Hubert de Breville | secondaire   | Propriétaire de trois domaines de chasse.

--- Repliques extraites (3) ---
  [Boule de Suif] (courtois) : Tenez, messieurs, dit-elle, je vous en prie, prenez un peu d
  [Comte Hubert de Breville] (courtois) : Le comte accepta avec courtoisie.
  [Madame Loiseau] (méprisant) : Madame Loiseau murmurait entre ses dents.

Interpretation : Analyse du texte litteraire

Aspect Valeur observee Signification
Personnages identifies Variable (3-6 typique) Le LLM detecte noms propres et pronoms
Repliques extraites Variable (1-5) Dependant de la densite de dialogues
Temps d’analyse runtime machine-dep : < 5 secondes Rapide pour des extraits courts
Precision Bonne pour les noms propres Peut manquer les personnages implicites

Points cles : 1. Le LLM identifie les personnages a partir des noms propres, des descriptions et des pronoms 2. Les repliques de dialogue sont extraites avec leur locuteur et le ton contextuel 3. Temperature basse (0.3) favorise la coherence et la fidelite au texte 4. Le JSON structure permet un traitement automatique dans les passes suivantes

Note technique : Pour des textes plus longs (chapitres entiers), decouper en segments de 500-800 mots et fusionner les résultats en eliminant les doublons de personnages.

Section 2 : Generation du script multi-voix

A partir de l’analyse des personnages, cette passe genere un script de narration avec des marqueurs de locuteur. Chaque segment du script est etiquete avec le personnage qui le prononce :

Marqueur Signification Utilisation
[NARRATEUR] Voix de narration Texte descriptif, transitions
[PERSONNAGE_1] Premier personnage Dialogues du personnage principal
[PERSONNAGE_2] Second personnage Dialogues du personnage secondaire
[PAUSE] Silence Transitions entre scenes

Le LLM restructure le texte en alternant narration et dialogues pour créer un flux audiobook naturel.

# Generation du script multi-voix
print("GENERATION DU SCRIPT MULTI-VOIX")
print("=" * 50)

script_prompt = f"""
Transforme ce texte litteraire en script de narration audiobook.

Regles :
- Le texte narratif (description, action) est attribue a [NARRATEUR]
- Les dialogues sont attribues au personnage qui parle : [PERSONNAGE_1], [PERSONNAGE_2], etc.
- Ajoute des [PAUSE] entre les changements de scene ou de locuteur
- Conserve le texte original le plus fidelement possible
- Un segment par bloc de texte coherent

Personnages identifies : {json.dumps(characters_data, ensure_ascii=False) if characters_data else 'A determiner par le texte'}

Texte a convertir :
---
{sample_text}
---

Genere uniquement le script avec les marqueurs, sans commentaires.
"""

narration_script = ""

if generate_audio and client:
    start_time = time.time()
    response = client.chat.completions.create(
        model=llm_model,
        messages=[{"role": "user", "content": script_prompt}],
        temperature=0.5,
        max_tokens=1500
    )
    narration_script = response.choices[0].message.content
    gen_time = time.time() - start_time

    print(f"Script genere en {gen_time:.1f}s")
    print(f"Tokens utilises : {response.usage.total_tokens}")
    print(f"\n--- Script multi-voix ---")
    print(narration_script)
    print(f"--- Fin du script ---")
    print(f"\nLongueur : {len(narration_script)} caracteres")
else:
    print("Generation desactivee")

# Parser le script en segments
def parse_narration_script(script: str) -> List[Dict[str, str]]:
    """Parse un script avec marqueurs [ROLE] en segments."""
    segments = []
    current_role = "NARRATEUR"
    current_text = []

    for line in script.split('\n'):
        line = line.strip()
        if not line:
            continue

        # Detection marqueur de role (NARRATEUR, PERSONNAGE_N)
        role_match = re.match(r'\[(NARRATEUR|PERSONNAGE_\d+)\]', line)
        if role_match:
            # Sauvegarder le segment precedent
            if current_text:
                text = ' '.join(current_text).strip()
                text = re.sub(r'\[PAUSE\]', '...', text)
                if text and text != '...':
                    segments.append({"role": current_role, "text": text})
            current_role = role_match.group(1)
            # Texte apres le marqueur
            remaining = line[role_match.end():].strip()
            current_text = [remaining] if remaining else []
        else:
            current_text.append(line)

    # Dernier segment
    if current_text:
        text = ' '.join(current_text).strip()
        text = re.sub(r'\[PAUSE\]', '...', text)
        if text and text != '...':
            segments.append({"role": current_role, "text": text})

    return segments

if narration_script:
    segments = parse_narration_script(narration_script)
else:
    # Script de demonstration si la generation a ete sautee
    segments = [
        {"role": "NARRATEUR", "text": "Pendant plusieurs jours, des debris de l'armee en deroute avaient traverse la ville."},
        {"role": "NARRATEUR", "text": "Ce n'etait point de la troupe, mais des hordes deguenillees."},
        {"role": "NARRATEUR", "text": "Boule de Suif, une fille de joie ronde et grasse, offrit son panier de provisions."},
        {"role": "PERSONNAGE_1", "text": "Tenez, messieurs, je vous en prie, prenez un peu de nourriture."},
        {"role": "NARRATEUR", "text": "Le comte accepta avec courtoisie, tandis que Madame Loiseau murmurait entre ses dents."}
    ]

print(f"\nSegments parses : {len(segments)}")
for i, seg in enumerate(segments):
    print(f"  [{i+1}] {seg['role']:15s} | {seg['text'][:70]}")
GENERATION DU SCRIPT MULTI-VOIX
==================================================
Script genere en 2.5s
Tokens utilises : 686

--- Script multi-voix ---
[NARRATEUR]  
Pendant plusieurs jours, des débris de l'armée en déroute avaient traversé la ville. Ce n'était point de la troupe, mais des hordes déguenillées. Les hommes courbaient le dos, traînant une démarche lente et fatiguée, la figure terreuse, et les yeux hagards.  

[PAUSE]  

[NARRATEUR]  
Dans la voiture de stage qui partait pour Dieppe, se trouvaient réunis des voyageurs de la haute société. Madame Loiseau, femme d'un grossier marchand de vin, avait été bonne grue. Le comte Hubert de Breville, propriétaire de trois domaines de chasse, occupait le coin opposé.  

[PAUSE]  

[NARRATEUR]  
Boule de Suif, une fille de joie ronde et grasse, offrit son panier de provisions aux voyageurs affamés.  

[Boule de Suif]  
Tenez, messieurs, dit-elle, je vous en prie, prenez un peu de nourriture.  

[PAUSE]  

[NARRATEUR]  
Le comte accepta avec courtoisie, tandis que Madame Loiseau murmurait entre ses dents.  
--- Fin du script ---

Longueur : 921 caracteres

Segments parses : 4
  [1] NARRATEUR       | Pendant plusieurs jours, des débris de l'armée en déroute avaient trav
  [2] NARRATEUR       | Dans la voiture de stage qui partait pour Dieppe, se trouvaient réunis
  [3] NARRATEUR       | Boule de Suif, une fille de joie ronde et grasse, offrit son panier de
  [4] NARRATEUR       | Le comte accepta avec courtoisie, tandis que Madame Loiseau murmurait 

Interpretation : Script multi-voix

Aspect Valeur observee Signification
Segments parses Variable (5-15 typique) Dependant de la densite de dialogues
Ratio narration/dialogue ~60/40 Proportion habituelle en fiction courte
Marqueurs detectes NARRATEUR + PERSONNAGE_N Parsing robuste via regex
Fidelite au texte Bonne Le LLM preserve la plupart des phrases originales

Points cles : 1. Le script separe clairement narration et dialogues avec des marqueurs 2. Les [PAUSE] sont converties en ellipses pour un rythme naturel 3. La temperature 0.5 equilibre fidelite au texte et adaptation orale 4. Le parsing regex extrait chaque segment avec son rôle pour la synthese TTS

Note technique : Pour des textes avec de nombreux personnages, le LLM peut generer des marqueurs PERSONNAGE_3, PERSONNAGE_4, etc. Le mapping des voix doit etre etendu en consequence.

Section 3 : Synthese audio multi-voix

Chaque segment du script est synthetise avec une voix distincte selon le personnage. Le mapping voix/personnage est configurable via le paramètre character_voices :

Rôle Voix par defaut Timbre
NARRATEUR nova (F) Claire, neutre, narrative
PERSONNAGE_1 onyx (M) Grave, expressif
PERSONNAGE_2 alloy (N) Neutre, polyvalent

Les voix OpenAI disponibles sont : alloy, echo, fable, onyx, nova, shimmer.

# Synthese audio multi-voix
print("SYNTHESE AUDIO MULTI-VOIX")
print("=" * 50)

# Construire le mapping voix a partir des parametres
voice_mapping = {"NARRATEUR": narrator_voice}
for key, voice in character_voices.items():
    role_key = key.upper().replace("NARRATEUR", "NARRATEUR")
    voice_mapping[role_key] = voice

print(f"Mapping voix : {voice_mapping}")

audio_segments = []

if generate_audio and client:
    print(f"\nGeneration audio par segment :")
    total_start = time.time()

    for i, seg in enumerate(segments):
        # Determiner la voix pour ce role
        voice = voice_mapping.get(seg['role'], narrator_voice)

        start_time = time.time()
        try:
            response = client.audio.speech.create(
                model=tts_model,
                voice=voice,
                input=seg['text'],
                response_format="mp3",
                speed=1.0
            )
            audio_data = response.content
            gen_time = time.time() - start_time

            audio_segments.append({
                "role": seg['role'],
                "voice": voice,
                "audio": audio_data,
                "size_kb": len(audio_data) / 1024,
                "time": gen_time,
                "text_preview": seg['text'][:50]
            })

            print(f"  Segment {i+1}/{len(segments)} : {seg['role']:15s} -> {voice:8s} | {gen_time:.1f}s | {len(audio_data)/1024:.1f} KB")
        except Exception as e:
            print(f"  Segment {i+1}/{len(segments)} : ERREUR {type(e).__name__} : {str(e)[:80]}")

    total_time = time.time() - total_start
    print(f"\nTotal : {len(audio_segments)} segments en {total_time:.1f}s")
    print(f"Taille totale : {sum(s['size_kb'] for s in audio_segments):.1f} KB")

    # Ecouter le premier segment
    if audio_segments:
        first_seg = audio_segments[0]
        print(f"\nEcoute du premier segment ({first_seg['role']}, voix {first_seg['voice']}) :")
        display_audio_bundle(first_seg['audio'])

    # Tableau recapitulatif
    print(f"\n{'#':<4} {'Role':<15} {'Voix':<10} {'Taille (KB)':<12} {'Temps (s)':<10}")
    print("-" * 51)
    for i, seg in enumerate(audio_segments):
        print(f"{i+1:<4} {seg['role']:<15} {seg['voice']:<10} {seg['size_kb']:<12.1f} {seg['time']:<10.1f}")
else:
    print("Generation desactivee")
SYNTHESE AUDIO MULTI-VOIX
==================================================
Mapping voix : {'NARRATEUR': 'nova', 'PERSONNAGE_1': 'onyx', 'PERSONNAGE_2': 'alloy'}

Generation audio par segment :
  Segment 1/4 : NARRATEUR       -> nova     | 2.4s | 245.6 KB
  Segment 2/4 : NARRATEUR       -> nova     | 2.0s | 260.6 KB
  Segment 3/4 : NARRATEUR       -> nova     | 2.5s | 182.6 KB
  Segment 4/4 : NARRATEUR       -> nova     | 3.4s | 83.2 KB

Total : 4 segments en 10.2s
Taille totale : 772.1 KB

Ecoute du premier segment (NARRATEUR, voix nova) :

#    Role            Voix       Taille (KB)  Temps (s) 
---------------------------------------------------
1    NARRATEUR       nova       245.6        2.4       
2    NARRATEUR       nova       260.6        2.0       
3    NARRATEUR       nova       182.6        2.5       
4    NARRATEUR       nova       83.2         3.4       

Interpretation : Synthese audio multi-voix

Aspect Valeur observee Signification
Segments generes Variable Chaque segment correspond a un bloc narration ou dialogue
Temps par segment runtime machine-dep : 1-10s Proportionnel a la longueur du texte
Differentiation Voix distinctes par rôle L’auditeur peut distinguer narrateur et personnages
Taille totale Variable Typiquement 0.5-2 MB pour un extrait court

Points cles : 1. Le mapping voix/personnage permet d’attribuer automatiquement la bonne voix a chaque segment 2. Les voix OpenAI (nova, onyx, alloy) offrent une differentiation claire entre personnages 3. Le fallback sur narrator_voice pour les rôles non mapes evite les erreurs 4. La synthese est séquentielle mais rapide (runtime machine-dep : < 30s pour un extrait de 300 mots)

Note technique : Pour une production a grande echelle, la synthese peut etre parallellisee avec asyncio et aiohttp pour reduire le temps total.


Exercice : Evaluateur de qualite de synthese vocale

Duree estimee : 15-20 minutes

Objectif

Créer une fonction qui evalue la qualite d’un segment audio synthetise en mesurant le rapport signal/bruit, la duree relative au texte, et en produisant un score de qualite estime.

Contexte

Dans un pipeline audiobook, tous les segments audio n’ont pas la même qualite. Certains peuvent etre trop courts (elocution trop rapide), trop longs (pauses excessives), ou contenir des artefacts. Une evaluation automatique permet de detecter les segments a regenerer.

Instructions

  1. Extraire les metadonnees d’un segment audio (duree, taille, debit)
  2. Comparer le debit de parole a des seuils de reference
  3. Produire un rapport qualitatif par segment

Indices : - # Étape 1 : Debit de parole = nombre de mots / duree en secondes ( mots/minute) - # Étape 2 : Seuils typiques : 120-180 mots/min = normal, <100 = trop lent, >220 = trop rapide - # Étape 3 : Rapport = liste de dictionnaires avec score, problème detecte, recommandation - # Indice : len(audio_bytes) / 1024 pour la taille en KB, len(text.split()) pour le nombre de mots

# TODO: Implementer l'evaluateur de qualite de synthese vocale
def evaluate_segment_quality(text: str, audio_bytes: bytes, 
                              voice: str = "nova") -> dict:
    """
    Evalue la qualite d'un segment audio synthetise.
    
    Args:
        text: Texte source du segment
        audio_bytes: Donnees audio MP3 en bytes
        voice: Nom de la voix utilisee
    
    Returns:
        Dict avec metriques et score de qualite
    """
    # Etape 1 : Extraire les metriques de base
    # Indice: word_count = len(text.split()), size_kb = len(audio_bytes) / 1024
    pass  # TODO etudiant
    
    # Etape 2 : Estimer la duree (approximation : 1 KB MP3 ~ 0.06s a 192kbps)
    # Indice: estimated_duration = size_kb * 0.06
    pass  # TODO etudiant
    
    # Etape 3 : Calculer le debit et evaluer
    # Indice: wpm = (word_count / estimated_duration) * 60
    # Indice: Si wpm < 100 -> "trop lent", si wpm > 220 -> "trop rapide"
    pass  # TODO etudiant
    
    return {
        "word_count": 0,
        "size_kb": 0,
        "estimated_duration_s": 0,
        "wpm": 0,
        "quality_score": 0,
        "issues": [],
        "recommendation": "Exercice a completer"
    }  # TODO etudiant : retourner les metriques

# Test avec les segments existants
# for seg in segments[:3]:
#     if audio_segments:
#         idx = segments.index(seg)
#         if idx < len(audio_segments):
#             quality = evaluate_segment_quality(
#                 seg['text'], audio_segments[idx]['audio'], seg.get('voice', 'nova'))
#             print(f"[{seg['role']}] Score: {quality['quality_score']}/5 - {quality['recommendation']}")
print("Exercice a completer")
Exercice a completer

Section 4 : Assemblage audiobook

L’assemblage final combine tous les segments audio en un fichier audiobook unique avec pydub :

  1. Charger chaque segment audio (MP3 depuis BytesIO)
  2. Ajouter des silences entre les segments (800ms intra-rôle, 1500ms inter-rôle)
  3. Concatener dans l’ordre du script
  4. Normaliser le volume global a -20 dBFS
  5. Exporter en MP3 192kbps
# Assemblage audiobook avec pydub
print("ASSEMBLAGE AUDIOBOOK")
print("=" * 50)

# Verifier la disponibilite de pydub/ffmpeg
skip_assembly = False
final_audio = None

try:
    from pydub import AudioSegment
    # Tester l'acces a ffmpeg
    test_segment = AudioSegment.silent(duration=100)
    del test_segment
except (ImportError, FileNotFoundError, OSError) as e:
    print(f"ATTENTION: pydub/ffmpeg non disponible - assemblage saute")
    print(f"  Raison: {type(e).__name__}: {str(e)[:100]}")
    print(f"  Solution: installez ffmpeg ou utilisez un environnement avec ffmpeg dans PATH")
    skip_assembly = True

if not skip_assembly and generate_audio and audio_segments:
    try:
        # Parametres de silence (ms)
        inter_segment_silence = 800   # Entre segments du meme personnage
        inter_section_silence = 1500  # Entre personnages differents

        # Construire le fichier final
        final_audio = AudioSegment.silent(duration=500)  # Silence initial
        prev_role = None

        for i, seg in enumerate(audio_segments):
            # Charger le segment
            segment_audio = AudioSegment.from_mp3(BytesIO(seg['audio']))

            # Ajouter silence adapte au changement de locuteur
            if prev_role is not None:
                silence_ms = inter_section_silence if seg['role'] != prev_role else inter_segment_silence
                final_audio += AudioSegment.silent(duration=silence_ms)

            final_audio += segment_audio
            prev_role = seg['role']
            print(f"  Segment {i+1} : {seg['role']:15s} ({len(segment_audio)/1000:.1f}s)")

        # Silence final
        final_audio += AudioSegment.silent(duration=500)

        # Normalisation du volume
        target_dBFS = -20.0
        change_in_dBFS = target_dBFS - final_audio.dBFS
        final_audio = final_audio.apply_gain(change_in_dBFS)

        print(f"\nAudiobook assemble :")
        print(f"  Duree totale : {len(final_audio)/1000:.1f}s")
        print(f"  Volume normalise : {final_audio.dBFS:.1f} dBFS")
        print(f"  Segments : {len(audio_segments)}")

        # Sauvegarde
        if save_audio_files:
            safe_title = re.sub(r'[^a-zA-Z0-9]', '_', sample_text_title.lower())
            output_path = OUTPUT_DIR / f"audiobook_{safe_title}.mp3"
            final_audio.export(str(output_path), format="mp3", bitrate="192k")
            print(f"  Sauvegarde : {output_path.name} ({output_path.stat().st_size/1024:.1f} KB)")

        # Ecouter le resultat
        final_bytes = BytesIO()
        final_audio.export(final_bytes, format="mp3")
        print(f"\nEcoute de l'audiobook :")
        display_audio_bundle(final_bytes.getvalue())

    except Exception as e:
        print(f"Erreur lors de l'assemblage: {type(e).__name__}: {str(e)[:200]}")
        print("L'assemblage a ete saute, mais les segments individuels restent disponibles.")
        final_audio = None
else:
    if skip_assembly:
        print("Assemblage non disponible (ffmpeg requis)")
    else:
        print("Assemblage ignore (pas de segments audio disponibles)")
ASSEMBLAGE AUDIOBOOK
==================================================
  Segment 1 : NARRATEUR       (15.7s)
  Segment 2 : NARRATEUR       (16.7s)
  Segment 3 : NARRATEUR       (11.7s)
  Segment 4 : NARRATEUR       (5.3s)

Audiobook assemble :
  Duree totale : 52.8s
  Volume normalise : -20.0 dBFS
  Segments : 4
  Sauvegarde : audiobook_boule_de_suif.mp3 (1033.2 KB)

Ecoute de l'audiobook :

Interpretation : Assemblage audiobook

Aspect Valeur observee Signification pedagogique
Duree totale runtime machine-dep : ordre de grandeur 30-120s (mesure ponctuelle, depend du nombre de segments) Format adapte a un extrait court – la duree varie selon le contenu (chapitre complet = 10-15 min)
Silences inter-segments 800-1500ms (parametre pydub, frozen) Transitions naturelles entre locuteurs
Normalisation -20 dBFS (parametre audio, frozen) Volume confortable pour l’ecoute prolongee
Format MP3 192kbps (parametre encoding, frozen) Bon compromis qualite/taille

Points cles : 1. Les silences variables (800ms vs 1500ms) marquent les changements de locuteur 2. La normalisation du volume evite les ecarts de loudness entre les voix 3. Le silence initial (500ms) laisse le temps a l’auditeur de se preparer 4. Pour un chapitre complet, le même processus s’applique avec plus de segments

Note technique : Pour un audiobook complet, decouper en chapitres de 10-15 minutes et assembler les chapitres en un second niveau de concatenation avec des silences de 3 secondes entre chapitres.

Note drainage wallclock : la duree totale est un runtime machine-dep – elle depend du nombre de segments concatenes, de la longueur de chaque segment TTS (lui-meme runtime), et des silences config (frozen). La fourchette ~30-120s est preservee comme ordre de grandeur indicatif pour un extrait de 3-5 segments, pas comme une mesure reproductible (la sortie audio runtime exacte varie d’une execution a l’autre selon le modele OpenAI TTS charge, la latence reseau, etc.). Les autres parametres du tableau (silences 800-1500ms, normalisation -20 dBFS, format MP3 192kbps) sont des valeurs de configuration pydub – frozen, non-runtime. Cf PR #9716 (4-6 drainage initial 3 wall-clock claims equivalents) et rapport inventaire residuel #10538 pour l’inventaire exhaustif 308 wallclock residuels / 190 drainables.

Section 5 : Architecture 5 passes - Synthese

Ce notebook a implemente les passes P1, P4 et P5 du pipeline audiobook agentique. Voici la vue d’ensemble complete :

Pass Nom Input Model/Tool Output
P1 Lecture analytique Texte brut GPT-4o-mini characters.json + utterances.json
P2 Voice casting characters.json XTTS / Fish-Speech voices/*.wav (empreintes vocales)
P3 Annotation prosodique Texte + utterances.json GPT + SSML book.ssml.md (script annote)
P4 Generation TTS Script + voix OpenAI TTS / Kokoro chunks/*.mp3
P5 Compilation audio chunks/ pydub + ffmpeg audiobook.mp3

Ce notebook : P1 (analyse) + P4 simplifie (TTS OpenAI) + P5 (assemblage).

Notebooks de reference pour les passes avancees : - P2 (Voice casting) : 02-2-XTTS-Voice-Cloning.ipynb pour le clonage vocal - P3 (Annotation prosodique) : 02-8-Expressive-TTS.ipynb pour les tags d’emotion et de rythme


Exercice : Detecteur de personnages et classificateur de voix

Duree estimee : 20-25 minutes

Objectif

Créer une fonction qui analyse un texte litteraire et propose automatiquement un mapping voix en fonction des traits de personnalite de chaque personnage (genre, age, tonalite).

Contexte

Dans un pipeline audiobook, le choix des voix est crucial. Plutot que de mapper manuellement chaque personnage a une voix, on peut automatiser cette decision en analysant les descripteurs associes a chaque personnage (nom feminin -> voix feminine, personnage age -> voix grave, etc.).

Instructions

  1. Extraire les personnages et leurs traits depuis un texte via LLM
  2. Implementer des règles de classification (genre, age, rôle) -> voix
  3. Generer automatiquement le dictionnaire character_voices

Indices : - # Étape 1 : Reutiliser le prompt d’analyse JSON de la Section 1 en ajoutant “genre” et “age_estime” - # Étape 2 : Règles : femme -> nova/shimmer, homme -> onyx/echo, neutre -> alloy/fable - # Étape 3 : Rôle principal -> voix la plus distinctive, rôle secondaire -> voix neutre - # Indice : Les voix OpenAI sont : alloy, echo, fable, onyx, nova, shimmer

# TODO: Implementer le detecteur de personnages et classificateur de voix
def auto_voice_cast(text: str) -> dict:
    """
    Analyse un texte et propose un mapping automatique personnage -> voix.
    
    Args:
        text: Texte litteraire a analyser
    
    Returns:
        Dict avec :
        - "characters": liste des personnages detectes avec traits
        - "voice_mapping": dict personnage -> voix OpenAI
    """
    # Etape 1 : Analyser le texte pour extraire personnages avec genre/age
    # Indice: Utiliser client.chat.completions.create() avec un prompt demandant
    #   le genre (M/F/N) et l'estimation d'age pour chaque personnage
    characters = []  # TODO etudiant
    pass  # TODO etudiant
    
    # Etape 2 : Regles de classification voix
    # Indice: voices_by_gender = {"F": ["nova", "shimmer"], "M": ["onyx", "echo"], "N": ["alloy", "fable"]}
    voice_mapping = {"narrateur": "nova"}  # TODO etudiant
    pass  # TODO etudiant
    
    # Etape 3 : Attribuer les voix en evitant les doublons
    # Indice: Si 2 hommes -> onyx pour le principal, echo pour le secondaire
    pass  # TODO etudiant
    
    return {
        "characters": characters,
        "voice_mapping": voice_mapping
    }  # TODO etudiant : retourner le resultat complet

# Test
# result = auto_voice_cast(sample_text)
# print(f"Personnages : {result['characters']}")
# print(f"Mapping voix : {result['voice_mapping']}")
print("Exercice a completer")
Exercice a completer
# Statistiques de session et recapitulatif
print("STATISTIQUES DE SESSION")
print("=" * 50)

print(f"Date : {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
print(f"Mode : {notebook_mode}")
print(f"Modele TTS : {tts_model} | Modele LLM : {llm_model}")
print(f"Texte source : {sample_text_author}, \"{sample_text_title}\"")
print(f"Voix narrateur : {narrator_voice}")
print(f"Voix personnages : {character_voices}")

if characters_data:
    print(f"Personnages identifies : {len(characters_data)}")
if utterances_data:
    print(f"Repliques extraites : {len(utterances_data)}")
if segments:
    print(f"Segments script : {len(segments)}")
if audio_segments:
    total_size = sum(s['size_kb'] for s in audio_segments)
    print(f"Segments audio generes : {len(audio_segments)}")
    print(f"Taille totale audio : {total_size:.1f} KB")
if final_audio:
    print(f"Duree audiobook final : {len(final_audio)/1000:.1f}s")

if save_audio_files:
    saved_files = list(OUTPUT_DIR.glob('*.mp3'))
    print(f"Fichiers sauvegardes : {len(saved_files)} dans {OUTPUT_DIR.relative_to(GENAI_ROOT)}")

print(f"\nPROCHAINES ETAPES")
print(f"1. Explorer le clonage vocal (02-2-XTTS-Voice-Cloning)")
print(f"2. Ajouter des annotations prosodiques (02-8-Expressive-TTS)")
print(f"3. Tester avec un texte personnalise (exercice ci-dessous)")

print(f"\nNotebook termine - {datetime.now().strftime('%H:%M:%S')}")
STATISTIQUES DE SESSION
==================================================
Date : 2026-08-16 01:52:02
Mode : interactive
Modele TTS : tts-1 | Modele LLM : gpt-4o-mini
Texte source : Maupassant, "Boule de Suif"
Voix narrateur : nova
Voix personnages : {'narrateur': 'nova', 'personnage_1': 'onyx', 'personnage_2': 'alloy'}
Personnages identifies : 3
Repliques extraites : 3
Segments script : 4
Segments audio generes : 4
Taille totale audio : 772.1 KB
Duree audiobook final : 52.8s
Fichiers sauvegardes : 1 dans outputs\audio\audiobook

PROCHAINES ETAPES
1. Explorer le clonage vocal (02-2-XTTS-Voice-Cloning)
2. Ajouter des annotations prosodiques (02-8-Expressive-TTS)
3. Tester avec un texte personnalise (exercice ci-dessous)

Notebook termine - 01:52:02

Synthese des apprentissages

Ce notebook a presente un pipeline audiobook agentique en 5 passes, dont 3 ont ete implementees concretement.

Technologies exploitees

Technologie Rôle Avantages pour l’audiobook
GPT-4o-mini Analyse de texte + generation de script Extraction automatique personnages/dialogues, temperature basse pour fidelite
OpenAI TTS (tts-1) Synthese vocale 6 voix distinctes, FR supporte, qualite naturelle
pydub + ffmpeg Assemblage audio Silences adaptatifs, normalisation, concatenation fluide

Bonnes pratiques identifiees

  1. Analyse structuree : Extraire personnages et dialogues en JSON avant la generation du script
  2. Marqueurs de locuteur : [NARRATEUR], [PERSONNAGE_N] pour un parsing automatique fiable
  3. Mapping voix : Configurer un dictionnaire personnage -> voix pour la flexibilite
  4. Silences adaptatifs : 800ms intra-rôle, 1500ms inter-rôle pour des transitions naturelles
  5. Normalisation : -20 dBFS pour un volume d’ecoute confortable

Limitations

  • Nombre de voix limite a 6 (voix OpenAI predefinies)
  • Pas de clonage vocal (pass P2, voir notebook 02-2)
  • Pas d’annotation prosodique (pass P3, voir notebook 02-8)
  • L’analyse LLM peut manquer des personnages implicites dans des textes denses

Extensions possibles

  • Integrer XTTS pour le clonage vocal a partir d’un echantillon de reference
  • Ajouter des tags SSML pour le contrôle de l’emotion et du rythme
  • Paralleliser la synthese TTS avec asyncio pour reduire le temps de production
  • Generer automatiquement les sous-titres synchronises (SRT) via Whisper


Exercice : Adapter le pipeline pour un texte personnalise

Duree estimee : 25-30 minutes

Objectif

Adapter le pipeline audiobook pour un texte litteraire de votre choix, en personnalisant l’analyse des personnages et le mapping des voix.

Instructions

  1. Choisir un texte court (200-400 mots) d’une oeuvre du domaine public
  2. Utiliser analyze_text() pour extraire les personnages et les dialogues
  3. Configurer le mapping voix en fonction des personnages detectes
  4. Executer create_audiobook() pour generer le fichier final

Indices : - Pour le choix du texte, privilgier un extrait avec des dialogues (theatre, nouvelle) - Adaptez le nombre de voix dans character_voices selon le nombre de personnages - Utilisez la fonction parse_narration_script() pour structurer le script

# TODO: Choisir un texte litteraire (domaine public, 200-400 mots)
custom_text = """
[Votre texte ici]
TODO: Coller un extrait d'une oeuvre du domaine public
"""

# TODO: Configurer le mapping voix pour vos personnages
custom_voices = {
    "narrateur": "nova",
    # "personnage_1": "onyx",   # TODO: ajouter vos personnages
    # "personnage_2": "alloy",
}

# TODO: Implementer l'analyse de texte
def analyze_text(text: str) -> dict:
    """
    Analyse un texte litteraire pour extraire personnages et dialogues.
    
    Args:
        text: Texte litteraire a analyser
    
    Returns:
        Dict avec 'characters' et 'utterances'
    """
    # Indice: Utiliser client.chat.completions.create() avec un prompt d'analyse JSON
    # Indice: Reutiliser le pattern du prompt analysis_prompt de la Section 1
    print("Exercice a completer")
    return {"characters": [], "utterances": []}

# TODO: Implementer le pipeline complet
def create_audiobook(text: str, voices: dict) -> bytes:
    """
    Pipeline complet : analyse -> script -> synthese -> assemblage.
    
    Args:
        text: Texte litteraire source
        voices: Mapping personnage -> voix OpenAI
    
    Returns:
        Audio MP3 en bytes
    """
    # Indice: Etape 1 - analyser le texte avec analyze_text()
    # Indice: Etape 2 - generer le script avec le LLM (reutiliser script_prompt)
    # Indice: Etape 3 - parser le script avec parse_narration_script()
    # Indice: Etape 4 - synthetiser chaque segment avec TTS
    # Indice: Etape 5 - assembler avec pydub
    print("Exercice a completer")
    return b""

# TODO: Executer le pipeline
# result = create_audiobook(custom_text, custom_voices)
print("Exercice pret a etre complete")
Exercice pret a etre complete

Critères de succes

Extension (optionnel)

Retour au sommet