# 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 = TruePipeline 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_KEYdans.env) - pydub installe (manipulation audio)
- Comprehension de base du TTS et du parsing de texte
Navigation : Index | << Précédent
# Parameters
BATCH_MODE = True
skip_widgets = TrueL’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
asyncioetaiohttppour 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
- Extraire les metadonnees d’un segment audio (duree, taille, debit)
- Comparer le debit de parole a des seuils de reference
- 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) / 1024pour 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 :
- Charger chaque segment audio (MP3 depuis BytesIO)
- Ajouter des silences entre les segments (800ms intra-rôle, 1500ms inter-rôle)
- Concatener dans l’ordre du script
- Normaliser le volume global a -20 dBFS
- 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
- Extraire les personnages et leurs traits depuis un texte via LLM
- Implementer des règles de classification (genre, age, rôle) -> voix
- 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
- Analyse structuree : Extraire personnages et dialogues en JSON avant la generation du script
- Marqueurs de locuteur : [NARRATEUR], [PERSONNAGE_N] pour un parsing automatique fiable
- Mapping voix : Configurer un dictionnaire personnage -> voix pour la flexibilite
- Silences adaptatifs : 800ms intra-rôle, 1500ms inter-rôle pour des transitions naturelles
- 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
- Choisir un texte court (200-400 mots) d’une oeuvre du domaine public
- Utiliser
analyze_text()pour extraire les personnages et les dialogues - Configurer le mapping voix en fonction des personnages detectes
- 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_voicesselon le nombre de personnages - Utilisez la fonctionparse_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