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.
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 matchif name_lower in voice_map:return name_lower# Partial matchfor cid in voice_map:if cid in name_lower or name_lower in cid:return cid# Default narratorreturn"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 segmentsfor 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 indextts_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 structdef audio_duration_s(audio_bytes):"""Extract duration from audio bytes (WAV or MP3)."""iflen(audio_bytes) <12:return0.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 //8if bytes_per_sample ==0:return0.0return 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 =128000returnlen(audio_bytes) *8/ bitratereturn0.0generated = []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_nameprint(f"Generating seg {req.seg_index}: {req.kokoro_voice}...", end=" ") audio_data = kokoro_tts(clean_text, req.kokoro_voice)if audio_data:withopen(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 limitelapsed = time.time() - t_startsuccess_count =sum(1for 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)
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.
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'entrainementdef 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 typedef compute_duration_coefficients(training_data):"""Retourne {seg_type: (coeff_mots_par_seconde, biais)}."""# TODO etudiantreturn {} # TODO etudiant# Etape 3 : Prediction et evaluationdef predict_duration(text, seg_type, coefficients):"""Predit la duree en secondes d'un segment."""# TODO etudiantreturn0.0# TODO etudiant# TODO etudiant : evaluer la precision (MAE) sur les donnees existantesprint("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 : identification 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(1for r in generated if r.status =="success"),"failed": sum(1for 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"withopen(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 Countervoice_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}")
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_idtry: resp = requests.post(f"{FISHAUDIO_URL}/v1/tts", json=payload, timeout=timeout, ) resp.raise_for_status()return resp.contentexcept requests.RequestException as e:print(f"FishAudio TTS error: {e}")returnNonedef check_fishaudio_health(max_wait=120):"""Check FishAudio health with retry (single-threaded service blocks during TTS).""" deadline = time.time() + max_waitwhile time.time() < deadline:try: resp = requests.get(f"{FISHAUDIO_URL}/v1/health", timeout=10)if resp.status_code ==200:returnTrueexcept requests.RequestException:pass time.sleep(5)returnFalsedef split_text_for_fishaudio(text, max_words=60):"""Split long text into chunks of ~max_words at sentence boundaries.""" words = text.split()iflen(words) <= max_words:return [text] chunks = [] current = []for word in words: current.append(word)iflen(current) >= max_words and word.endswith(('.', '!', '?', ',', ';')): chunks.append(' '.join(current)) current = []if current: chunks.append(' '.join(current))return chunksdef 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_samplesfor i, wav_path inenumerate(wav_files):ifisinstance(wav_path, (str, Path)):withopen(wav_path, 'rb') as f: data = f.read()else: data = wav_path# Strip WAV header (44 bytes) and keep PCM dataif data[:4] ==b'RIFF': pcm_data = data[44:]else: pcm_data = data all_audio += pcm_dataif 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)withopen(output_path, 'wb') as f: f.write(header + all_audio)return output_pathfishaudio_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 notationTAG_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_textfor bracket_tag, fish_tag in TAG_MAP.items():if fish_tag isNone:# 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 resultprint(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 -> SSMLSSML_MAP = {} # TODO etudiant : {"whisper": '<prosody volume="soft">', "shout": '<prosody volume="loud">', ...}# Etape 2 : Fonction de conversiondef convert_tags_to_ssml(annotated_text):"""Convertit les tags P3 [bracket] en balises SSML standard."""# TODO etudiant : remplacer chaque [tag] par l'equivalent SSMLreturn""# TODO etudiant# Etape 3 : Tester sur quelques segmentstest_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 afficherprint("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 OpenAIopenai_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()iflen(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()]iflen(parts) >=2:print(f" LLM split: {len(words)} mots -> {len(parts)} parties")return partsexceptExceptionas e:print(f" LLM split fallback (error: {e})")return split_text_for_fishaudio(text, max_words=max_words)# Test sur un segment longtest_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 inenumerate(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.
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 P5fish_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(1for 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"withopen(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 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 )iflen(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" ]iflen(_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"withopen(_concat_list, "w", encoding="utf-8") as f: _ordered =sorted(_kokoro_ok, key=lambda s: s["seg_index"])for _i, _seg inenumerate(_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, displayimport 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():withopen(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():withopen(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.0for i inrange(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 >=0elsef"{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_durprint("-"*110) delta_total = total_fish - total_kokoroprint(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 >0else'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.0for i inrange(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_durprint("-"*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 inrange(14): ks = kokoro_segs.get(i, {})if ks.get("status") !="success":continue text = ks.get("text", "")[:80] + ("..."iflen(ks.get("text", "")) >80else"") 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 subprocessdef 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 ==0print("="*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'})")
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 globalesdef compute_tts_metrics(segments_list):"""Calcule duree moyenne, taille moyenne et ratio taille/duree."""# TODO etudiantreturn {} # TODO etudiant# Etape 2 : Segments avec ecarts significatifsdef find_outlier_segments(kokoro_segs, fish_segs, threshold_s=2.0):"""Identifie les segments ou le delta de duree depasse le seuil."""# TODO etudiantreturn [] # TODO etudiant# Etape 3 : Rapport formatedef generate_comparison_report(kokoro_segs, fish_segs):"""Genere un rapport comparatif Kokoro vs FishAudio."""# TODO etudiantreturn""# TODO etudiantprint("Exercice a completer")