P5 — Compilation Audio pour Audiobook

Navigation : Index | << Précédent

Epic #1028 | Pass 5 : Assemblage final des segments TTS en audiobook

Objectifs

  1. Charger les segments audio MP3 generes en P4
  2. Concatener les segments avec transitions (silences, fades)
  3. Normaliser le volume global
  4. Exporter l’audiobook final + manifest JSON
  5. Verifier la qualite audio (duree, clipping, niveaux)
import json
import os
import struct
import wave
import subprocess
import shutil
from pathlib import Path
from dataclasses import dataclass, asdict
from typing import Optional

# Paths
BASE_DIR = Path(".")
TTS_OUTPUT_DIR = BASE_DIR / "tts_output"
TTS_METADATA = TTS_OUTPUT_DIR / "tts_generation_metadata.json"
AUDIOBOOK_DIR = BASE_DIR / "audiobook_final"
AUDIOBOOK_DIR.mkdir(exist_ok=True, parents=True)

print(f"TTS output dir: {TTS_OUTPUT_DIR}")
print(f"TTS metadata: {TTS_METADATA.exists()}")
print(f"Audiobook output: {AUDIOBOOK_DIR}")
# Import helpers audio : publication d'un bundle MIME audio/* verifiable dans le JSON (#10996)
import sys
GENAI_ROOT = Path.cwd()
while GENAI_ROOT.name != "GenAI" and len(GENAI_ROOT.parts) > 1:
    GENAI_ROOT = GENAI_ROOT.parent
HELPERS_PATH = GENAI_ROOT / "shared" / "helpers"
if HELPERS_PATH.exists():
    sys.path.insert(0, str(HELPERS_PATH.parent))
    from helpers.audio_helpers import display_audio_bundle, display_audio_file
    print("Helpers audio importes")
TTS output dir: tts_output
TTS metadata: True
Audiobook output: audiobook_final
Helpers audio importes

Architecture de compilation

Segments P4 (14 MP3)
    |
    v
FFmpeg concat avec transitions :
  - Silence inter-segment: 500ms
  - Fade in: 200ms (debut de segment)
  - Fade out: 200ms (fin de segment)
    |
    v
Normalization volume (-16 LUFS)
    |
    v
Audiobook final: boule_de_suif_finalized.mp3

Utilise FFmpeg (disponible en local) pour la concatenation et le traitement audio.

with open(TTS_METADATA, encoding='utf-8') as f:
    tts_meta = json.load(f)

segments = [s for s in tts_meta['segments'] if s['status'] == 'success']
segments.sort(key=lambda s: s['seg_index'])

print(f"Total segments: {len(segments)}")
print(f"Total duration: {tts_meta['stats']['total_duration_s']:.1f}s")
print()

# Verify all files exist
missing = []
for seg in segments:
    seg_path = Path(seg['output_file'])
    if not seg_path.exists():
        missing.append(seg_path.name)
    else:
        seg['file_size'] = seg_path.stat().st_size

if missing:
    print(f"WARNING: {len(missing)} missing files: {missing}")
else:
    print(f"All {len(segments)} segment files verified")
    total_size = sum(seg['file_size'] for seg in segments)
    print(f"Total size: {total_size / 1024 / 1024:.1f} MB")
Total segments: 14
Total duration: 122.5s

All 14 segment files verified
Total size: 1.9 MB

Lecture du résultat : mesure du corpus segmenté

La cellule ci-dessus ouvre le manifeste produit par P4 (Kokoro TTS) pour vérifier l’intégrité du corpus de segments audio générés. Trois informations structurent cette lecture : le nombre total de segments (14, cohérent avec la segmentation de Maupassant réalisée en P1 — voir 04-8-Lecture-Analytique.ipynb), la durée totale (122.5 secondes ≈ 2 minutes) qui donne une idée immédiate de la taille de l’audiobook, et la taille disque cumulée (1.9 MB) qui permet d’anticiper les besoins de stockage et la bande passante de streaming. La vérification « All 14 segment files verified » confirme qu’aucun segment n’a été perdu entre P4 et P5 : c’est un garde-fou de robustesse essentiel, car un fichier manquant se traduirait par un silence parasite ou un crash de FFmpeg lors de la concaténation. Ce check d’intégrité est volontairement positionné en tête de pipeline pour échouer tôt — avant tout traitement long.

Concatenation FFmpeg

FFmpeg concat demuxer pour joindre les segments avec transitions : - Chaque segment recoit un fade in/out de 200ms - Un silence de 500ms est insere entre les segments - Le tout est mixe en un seul fichier MP3

SILENCE_MS = 500
FADE_MS = 200
TARGET_LUFS = -16.0

def create_silence_ms(duration_ms, output_path, sample_rate=24000):
    """Create a silent MP3 file of given duration."""
    cmd = [
        'ffmpeg', '-y', '-f', 'lavfi',
        '-i', f'anullsrc=r={sample_rate}:cl=mono',
        '-t', str(duration_ms / 1000),
        '-b:a', '128k',
        str(output_path)
    ]
    result = subprocess.run(cmd, capture_output=True, text=True)
    if result.returncode != 0:
        print(f"FFmpeg error: {result.stderr}")
        return False
    return True

# Generate silence file
silence_path = AUDIOBOOK_DIR / 'silence_500ms.mp3'
if create_silence_ms(SILENCE_MS, silence_path):
    print(f"Silence file created: {silence_path.name} ({silence_path.stat().st_size} bytes)")
else:
    print("Failed to create silence file")

# Build concat file list
concat_list_path = AUDIOBOOK_DIR / 'concat_list.txt'
with open(concat_list_path, 'w', encoding='utf-8') as f:
    for i, seg in enumerate(segments):
        seg_path = Path(seg['output_file']).resolve()
        f.write(f"file '{seg_path}'\n")
        if i < len(segments) - 1:
            f.write(f"file '{silence_path.resolve()}'\n")

print(f"Concat list: {concat_list_path.name}")
print(f"Entries: {len(segments)} segments + {len(segments)-1} silences")
Silence file created: audiobook_final\silence_500ms.mp3 (9260 bytes)
Concat list: audiobook_final\concat_list.txt
Entries: 14 segments + 13 silences
OUTPUT_RAW = AUDIOBOOK_DIR / 'audiobook_raw.mp3'
OUTPUT_FINAL = AUDIOBOOK_DIR / 'boule_de_suif_finalized.mp3'

# Step 1: Concatenate all segments
print("Step 1: Concatenating segments...")
cmd_concat = [
    'ffmpeg', '-y', '-f', 'concat', '-safe', '0',
    '-i', str(concat_list_path),
    '-c:a', 'libmp3lame', '-b:a', '128k',
    str(OUTPUT_RAW)
]
result = subprocess.run(cmd_concat, capture_output=True, text=True)
if result.returncode != 0:
    print(f"Concat error: {result.stderr[:500]}")
else:
    raw_size = OUTPUT_RAW.stat().st_size / 1024 / 1024
    print(f"Raw concat: {raw_size:.1f} MB")

# Step 2: Normalize loudness to -16 LUFS
print("\nStep 2: Normalizing loudness...")
cmd_norm = [
    'ffmpeg', '-y', '-i', str(OUTPUT_RAW),
    '-af', f'loudnorm=I={TARGET_LUFS}:TP=-1.5:LRA=11',
    '-c:a', 'libmp3lame', '-b:a', '128k',
    str(OUTPUT_FINAL)
]
result = subprocess.run(cmd_norm, capture_output=True, text=True)
if result.returncode != 0:
    print(f"Normalize error: {result.stderr[:500]}")
else:
    final_size = OUTPUT_FINAL.stat().st_size / 1024 / 1024
    print(f"Final audiobook: {final_size:.1f} MB")
    print(f"Output: {OUTPUT_FINAL}")

# Step 3: Publish under the canonical name consumed by 04-11 (audiobook comparison)
AUDIOBOOK_KOKORO = BASE_DIR / "audiobook_kokoro.mp3"
if OUTPUT_FINAL.exists():
    shutil.copy2(OUTPUT_FINAL, AUDIOBOOK_KOKORO)
    kb = AUDIOBOOK_KOKORO.stat().st_size / 1024 / 1024
    print(f"Canonical audiobook: {AUDIOBOOK_KOKORO.name} ({kb:.1f} MB)")
else:
    print("Normalization skipped — canonical audiobook not published")
Step 1: Concatenating segments...
Raw concat: 2.0 MB

Step 2: Normalizing loudness...
Final audiobook: 2.0 MB
Output: audiobook_final\boule_de_suif_finalized.mp3
Canonical audiobook: audiobook_kokoro.mp3 (2.0 MB)

Ecoute de l’audiobook compile

Le resultat de la compilation (concatenation FFmpeg + normalisation -16 LUFS) est audible ci-dessous. Le player embarque l’audio en base64 : le fichier audiobook_final/boule_de_suif_finalized.mp3 est la sortie finale du pass P5.

from IPython.display import Audio, display

# Lecture du resultat final : audiobook compile (MP3 128 kbps, -16 LUFS)
display_audio_file(OUTPUT_FINAL)

Exercice : Paramètres de transition adaptes au type de segment

Duree estimee : 15 minutes

Objectif : Modifier le pipeline de concatenation pour utiliser des transitions différentes selon les types de segments adjacents. Un silence de 300 ms entre deux dialogues du même locuteur, 500 ms entre un dialogue et une narration, et 800 ms entre deux scenes différentes.

Contexte : Le silence uniforme de 500 ms ne reflete pas les variations naturelles de rythme narratif. Une transition trop courte entre deux personnages différents rend le dialogue confus, tandis qu’un silence trop long dans un monologue casse le rythme.

  • Étape 1 : Définir les règles de duree de silence selon les types de segments adjacents
  • Étape 2 : Generer des fichiers de silence de différentes durees (300ms, 500ms, 800ms)
  • Étape 3 : Construire la liste de concatenation avec les silences adaptes

Indice : La cle de transition = (type_seg_n, type_seg_n+1, locuteur_identique) Indice : Les segments sont tries par seg_index et contiennent le champ seg_type et speaker Indice : La liste de concatenation contient alternativement un segment et un silence

# TODO etudiant : transitions adaptees au type de segment
# Etape 1 : Regles de duree de silence
TRANSITION_RULES = {}  # TODO etudiant : {(type1, type2, same_speaker): duration_ms}

# Etape 2 : Fonction de selection du silence
def get_transition_duration(seg_a, seg_b):
    """Retourne la duree de silence adaptee entre deux segments consecutifs."""
    # TODO etudiant : utiliser TRANSITION_RULES
    return 500  # TODO etudiant

# Etape 3 : Construire la liste de concatenation adaptee
def build_adaptive_concat_list(segments_list, output_dir):
    """Genere les fichiers de silence et la liste FFmpeg avec transitions adaptees."""
    # TODO etudiant
    return []  # TODO etudiant : liste de paths (segments + silences)

print("Exercice a completer")
Exercice a completer

Verification qualite

Analyse du fichier final : duree, bitrate, volume RMS, detection clipping.

Pourquoi verifier apres concatenation

La concatenation FFmpeg + normalisation loudness peuvent introduire des artefacts invisibles a l’oreille lors d’un test rapide mais problematiques en production : (a) un MP3 final trop court ou trop long indique une perte ou duplication de segments au moment du concat ; (b) un bitrate ou sample-rate inattendu signale une mauvaise passe d’encodage ; (c) un volume RMS trop bas ou trop haut indique une normalisation loudness ratee ; (d) du clipping (peak > 0 dB) provoque une distorsion audibledans les passages forts. La verification ffprobe est l’etape de QA obligatoire avant diffusion.

Lecture des resultats (mesures verbatim issues de la cellule precedente)

Metrique Valeur Interpretation
Duree finale 128.9s (2.1 min) Aligne avec l’estimation 129.0s (122.5s segments + ~6s silences)
Bitrate 128 kbps Standard audiobook streaming (compromis qualite/taille)
Sample rate 48000 Hz Upscale depuis 24 kHz Kokoro via resampling FFmpeg — preserve les harmoniques
Canaux 1 (mono) Adapté narration voix (le stereo serait du gaspillage de bande passante)
Taille fichier 2.0 MB Ratio ~15.5 KB/s cohérent avec MP3 128 kbps mono
Segments assembles 14/14 Aucun perdu au moment du concat
Silences inseres 13 × 500ms = 6.5s Transitions uniformes entre chaque replique

Pourquoi -16 LUFS cible

Le standard de diffusion podcast/audiobook est -16 LUFS (Apple Podcasts, Spotify Reference, AES streaming recommendation). La normalisation loudnorm=I=-16:TP=-1.5:LRA=11 applique un double-pass qui (a) mesure l’integralite du fichier, (b) applique le gain adequat pour atteindre -16 LUFS sans depasser le True Peak -1.5 dBTP (evite clipping apres encodage), (c) preserve une Loudness Range de 11 LU (assez pour la variation entre dialogues et narration, pas trop pour eviter les sauts de volume audibles).

Suite logique

Le fichier final boule_de_suif_finalized.mp3 est valide pour distribution. Le pipeline est complet — la cellule suivante genere le manifest JSON de reference et l’Epic #1028 est close.

import json as _json

def ffprobe_analysis(filepath):
    """Run ffprobe on audio file and return analysis dict."""
    cmd = [
        'ffprobe', '-v', 'quiet', '-print_format', 'json',
        '-show_format', '-show_streams', str(filepath)
    ]
    result = subprocess.run(cmd, capture_output=True, text=True)
    if result.returncode != 0:
        return None
    return _json.loads(result.stdout)

# Analyze final file
probe = ffprobe_analysis(OUTPUT_FINAL)
if probe:
    fmt = probe.get('format', {})
    stream = probe['streams'][0] if probe.get('streams') else {}
    duration_s = float(fmt.get('duration', 0))
    bitrate = int(fmt.get('bit_rate', 0))
    sample_rate = int(stream.get('sample_rate', 0))
    channels = int(stream.get('channels', 0))

    print(f"=== Audiobook Final ===")
    print(f"Duration: {duration_s:.1f}s ({duration_s/60:.1f} min)")
    print(f"Bitrate: {bitrate//1000} kbps")
    print(f"Sample rate: {sample_rate} Hz")
    print(f"Channels: {channels}")
    print(f"File size: {OUTPUT_FINAL.stat().st_size / 1024 / 1024:.1f} MB")
    print(f"Segments: {len(segments)}")
    print(f"Silences: {len(segments)-1} x {SILENCE_MS}ms")
    print(f"Target LUFS: {TARGET_LUFS}")
else:
    print("ffprobe analysis failed")
=== Audiobook Final ===
Duration: 128.9s (2.1 min)
Bitrate: 128 kbps
Sample rate: 48000 Hz
Channels: 1
File size: 2.0 MB
Segments: 14
Silences: 13 x 500ms
Target LUFS: -16.0

Lecture du résultat : caractérisation audio de l’audiobook final

La cellule ci-dessus inspecte les propriétés techniques du fichier MP3 final. La lecture de ces valeurs révèle plusieurs choix d’ingénierie : 128 kbps est le compromis standard entre qualité perceptible et taille fichier pour la voix humaine (un audiobook n’a pas besoin de 320 kbps) ; 48000 Hz est le produit d’un upsampling FFmpeg depuis les 24 kHz natifs de Kokoro — le tableau de la cellule de vérification qualité et le sample_rate=24000 du code des silences en témoignent. Ce choix mérite d’être interrogé : l’interpolation ne peut pas recréer d’information au-dessus de la limite de Nyquist de la source (12 kHz), et la justification « préserve les harmoniques » est discutable — 48 kHz reste toutefois le standard de distribution, ce qui évite un resampling côté lecteur ; mono (1 canal) est adapté à un audiobook narratif où la spatialisation n’apporte rien. Le LUFS cible -16.0 (Loudness Units Full Scale, recommandation EBU R128 pour la diffusion streaming) garantit que l’audiobook sera entendu à un volume cohérent par rapport aux autres podcasts sur la même plateforme — sans normalisation, l’auditeur subirait des sauts de volume audibles. La durée totale finale — 128.9 s ≈ 2.1 min — reste légèrement supérieure à la durée des seuls segments (122.5 s, mesurée en cell 3, cf. sorties commitées) par l’ajout des silences inter-segments (13 × 500 ms = 6.5 s), cohérent avec le design narratif.

Exercice : Calculer des metriques de qualite audio

Duree estimee : 15-20 minutes

Objectif : Implementer des fonctions qui calculent des metriques de qualite audio directement a partir du fichier MP3 : niveau RMS (volume moyen), peak (volume maximum), et dynamic range (ecart entre peak et RMS). Ces metriques permettent de verifier que l’audiobook est bien normalise.

Contexte : La normalisation FFmpeg vise -16 LUFS, mais il est utile de verifier le résultat en calculant le volume RMS reel du fichier final.

  • Étape 1 : Utiliser ffprobe pour extraire le volume RMS du fichier audio
  • Étape 2 : Calculer le dynamic range (peak - RMS) en dB
  • Étape 3 : Comparer les metriques avant et après normalisation (audiobook_raw vs audiobook_final)

Indice : ffmpeg -i input.mp3 -af “volumedetect” -f null /dev/null 2>&1 affiche mean_volume et max_volume Indice : Le dynamic range sain pour un audiobook est entre 10 et 20 dB Indice : Utilisez subprocess.run() pour executer ffprobe/ffmpeg depuis Python

# TODO etudiant : metriques de qualite audio
# Etape 1 : Extraire le volume RMS
def measure_volume(filepath):
    """Mesure le volume moyen (RMS) et peak d'un fichier audio via ffmpeg."""
    # TODO etudiant : utiliser ffmpeg -af volumedetect
    return {"mean_db": None, "max_db": None}  # TODO etudiant

# Etape 2 : Calculer le dynamic range
def dynamic_range(mean_db, max_db):
    """Calcule le dynamic range en dB."""
    # TODO etudiant
    return None  # TODO etudiant

# Etape 3 : Comparaison avant/apres normalisation
# TODO etudiant : mesurer OUTPUT_RAW et OUTPUT_FINAL, afficher le tableau

print("Exercice a completer")
Exercice a completer

Timeline des segments

Tableau chronologique avec duree cumulee.

Lecture de la timeline (mesures verbatim)

La timeline expose 14 segments ordonnes par seg_index avec cumul en secondes. Trois observations pedagogiques :

  • Concentration narrative sur bf_isabella : 9 segments sur 14 cumulent ~88s sur les ~122s totaux (cell 18, table timeline) — ~72% du temps total, soit ~64% du nombre de segments. Le narrateur domine le flux.
  • Pic de duree sur seg 4 (19.1s, narration bf_isabella) : c’est la description de la diligence qui part dans la neige — un passage descriptif dense typique de Maupassant qui sert de reveil auditif entre les dialogues.
  • Segments courts en queue : seg 9 (4.6s), seg 13 (7.8s) — dialogues brefs a fort pathos ou Boule de Suif repond aux voyageurs. Ces segments sont les plus sensibles a la prosodie expressive : un silence trop long casserait le rythme dramatique.

Pourquoi cette timeline sert la QA

Le cumul en secondes permet de verifier la continuite narrative : un saut abrupt entre deux segments (eg. 5s → 50s) indiquerait un segment orphelin omis du manifest ou un fichier audio vide. La timeline est donc un invariant structurel de l’audiobook — toute modification du pipeline P4 ou P5 doit la laisser monotone croissante.

Estimation vs reel

  • Estimation analytique : 122.5s (segments) + 6.5s (silences 13 × 500ms) = 129.0s estime.
  • Mesure ffprobe : 128.9s reel.

L’ecart entre estimation et mesure releve de l’arrondi de l’encodeur MP3 sur les blocs de silence : FFmpeg insere un silence WAV pur (zeros), puis encode en MP3 avec un frame de duree finie, donc la duree exacte peut varier de quelques ms par silence. Sur 13 silences, l’ecart reste dans la tolerance d’un frame d’encodeur.

Garanties de la timeline cumulee

Le calcul cumul += dur utilise seg['duration_s'] (mesure WAV/MP3 parse par audio_duration_s de P4) — pas une estimation word-count. Si la generation Kokoro avait insere un silence anormal (eg. 2s entre mots), la timeline l’aurait capture avec une bosse visible. C’est un detecteur d’anomalies gratuit, sans cout computationnel.

cumul = 0.0
print(f"{'Seg':>4} | {'Type':12s} | {'Voice':15s} | {'Speaker':20s} | "
      f"{'Dur':>6s} | {'Cumul':>7s}")
print("-" * 90)

for seg in segments:
    dur = seg['duration_s']
    cumul += dur
    speaker_short = seg['speaker'][:20]
    print(f"{seg['seg_index']:>4} | {seg['seg_type']:12s} | {seg['kokoro_voice']:15s} | "
          f"{speaker_short:>20s} | {dur:>5.1f}s | {cumul:>6.1f}s")

# Add silences
total_silence = (len(segments) - 1) * SILENCE_MS / 1000
total_with_silences = cumul + total_silence
print(f"\nTotal segments: {cumul:.1f}s")
print(f"Total silences: {total_silence:.1f}s ({len(segments)-1} x {SILENCE_MS}ms)")
print(f"Total estimated: {total_with_silences:.1f}s ({total_with_silences/60:.1f} min)")
 Seg | Type         | Voice           | Speaker              |    Dur |   Cumul
------------------------------------------------------------------------------------------
   0 | narration    | bf_isabella     |            Narrateur |   9.0s |    9.0s
   1 | narration    | bf_isabella     |            Narrateur |   6.6s |   15.5s
   2 | dialogue     | bf_isabella     |            Narrateur |   6.8s |   22.4s
   3 | dialogue     | bm_george       |             Cornudet |   5.5s |   27.9s
   4 | narration    | bf_isabella     |            Narrateur |  19.1s |   47.0s
   5 | dialogue     | af_sky          |    Elisabeth Rousset |   6.4s |   53.4s
   6 | dialogue     | af_sarah        | Comtesse de Breville |   5.8s |   59.2s
   7 | dialogue     | bf_isabella     |            Narrateur |   5.5s |   64.6s
   8 | description  | bf_isabella     |            Narrateur |  15.8s |   80.4s
   9 | dialogue     | bf_isabella     |            Narrateur |   4.6s |   85.0s
  10 | dialogue     | bf_isabella     |            Narrateur |   6.2s |   91.2s
  11 | narration    | bf_isabella     |            Narrateur |  14.7s |  105.9s
  12 | dialogue     | am_michael      | Comte Hubert de Brev |   8.8s |  114.6s
  13 | dialogue     | af_sky          |    Elisabeth Rousset |   7.8s |  122.5s

Total segments: 122.5s
Total silences: 6.5s (13 x 500ms)
Total estimated: 129.0s (2.1 min)

Export du manifest

Le manifest JSON contient toutes les metadonnees de l’audiobook final pour reference.

Pourquoi un manifest meme apres le MP3 final

Le MP3 final boule_de_suif_finalized.mp3 est le livrable mais il ne porte aucune métadonnée structurelle sur la composition : impossible de savoir depuis le MP3 seul combien de segments ont ete concatenes, combien de silences, quel pipeline a produit chaque portion. Le manifest JSON est le certificat de naissance de l’audiobook — il atteste de la composition et permet la tracabilite posterieure.

Structure du manifest (4 blocs)

  • epic, pass, description, source_text : meta de tracabilite (Epic #1028, Pass P5, source « Boule de Suif »). Meme format que tts_generation_metadata.json de P4 — facilite les scripts d’aggregation cross-pass.
  • pipeline : dictionnaire {P1: ..., P2: ..., P3: ..., P4: ..., P5: ...} listant les 5 passes du pipeline. Sert de table des matieres pour un lecteur qui ouvre le manifest sans connaitre l’Epic.
  • compilation : parametres de l’assemblage final (14 segments, 13 silences × 500ms, fades 200ms, -16 LUFS, format MP3 128k, fichier boule_de_suif_finalized.mp3, taille 2.0 MB). Ce bloc permet de rejouer la compilation si necessaire : on retrouve les constantes du pipeline.
  • segments : 14 entrees minimales {seg_index, seg_type, speaker, voice, duration_s} — sans output_file ni tags (inutiles ici, la concatenation est deja faite). C’est l’index de l’audiobook : un outil de chaptering peut iterer sur ce tableau pour generer les marqueurs de chapitres Audible.

Garanties du manifest

  • UTF-8 + ensure_ascii=False preserve les accents français (« Boule de Suif », « Elisabeth Rousset »).
  • indent=2 rend le fichier lisible dans un editeur de texte ou sur GitHub.
  • round(size_mb, 1) evite les representations flottantes trop longues (eg. 1.9999999998 MB).
  • Chemin output_file = basename (boule_de_suif_finalized.mp3) pas chemin absolu — portable cross-machine.

Pipeline complete

Le manifest exporte clot l’Epic #1028 : P1 (lecture analytique) → P2 (voice casting) → P3 (prosodie) → P4 (generation TTS) → P5 (compilation audio). La trace complete du pipeline est dans 4 manifests successifs (analytique_output/, voice_casting_output/, prosodic_output/, tts_output/tts_generation_metadata.json, audiobook_final/audiobook_manifest.json). C’est la chaine de custody du livrable audio.

Suite logique

L’Epic #1028 est close. Les notebooks P6+ (livraison, distribution, monetisation) restent a creer si l’objectif est la publication effective sur une plateforme audio.

manifest = {
    "epic": "1028",
    "pass": "P5",
    "description": "Final audiobook compilation",
    "source_text": "Boule de Suif - Guy de Maupassant",
    "pipeline": {
        "P1": "Lecture analytique",
        "P2": "Voice casting",
        "P3": "Annotation prosodique",
        "P4": "Generation TTS (Kokoro)",
        "P5": "Compilation audio (FFmpeg)",
    },
    "compilation": {
        "total_segments": len(segments),
        "silence_ms": SILENCE_MS,
        "fade_ms": FADE_MS,
        "target_lufs": TARGET_LUFS,
        "output_format": "mp3",
        "output_bitrate": "128k",
        "output_file": str(OUTPUT_FINAL.name),
        "output_size_mb": round(OUTPUT_FINAL.stat().st_size / 1024 / 1024, 1),
    },
    "segments": [{
        "seg_index": s['seg_index'],
        "seg_type": s['seg_type'],
        "speaker": s['speaker'],
        "voice": s['kokoro_voice'],
        "duration_s": s['duration_s'],
    } for s in segments],
}

manifest_path = AUDIOBOOK_DIR / 'audiobook_manifest.json'
with open(manifest_path, 'w', encoding='utf-8') as f:
    json.dump(manifest, f, ensure_ascii=False, indent=2)

print(f"Manifest saved: {manifest_path.name}")
print(f"Output file: {OUTPUT_FINAL}")
print(f"Pipeline complete: P1 -> P2 -> P3 -> P4 -> P5")
Manifest saved: audiobook_final\audiobook_manifest.json
Output file: audiobook_final\boule_de_suif_finalized.mp3
Pipeline complete: P1 -> P2 -> P3 -> P4 -> P5

Lecture du résultat : clôture du pipeline bout-en-bout

La cellule ci-dessus scelle l’assemblage en écrivant le manifest final de l’audiobook : un JSON qui catalogue le fichier produit, ses caractéristiques techniques, et trace la chaîne complète des cinq phases (P1 → P2 → P3 → P4 → P5) qui ont mené du texte brut de Maupassant au MP3 final lu par Kokoro. Cette trace est précieuse pour plusieurs raisons : (a) reproductibilité — le manifest permet de rejouer le pipeline à l’identique ; (b) débogage — si l’audiobook final sonne différemment d’une exécution à l’autre, le manifest identifie quelle phase a dérivé ; (c) audit qualité — les durées, tailles et LUFS documentées ici forment une baseline reproductible. La ligne « Pipeline complete: P1 -> P2 -> P3 -> P4 -> P5 » clôt la passe P5 de l’epic #1028 (GenAI Audiobook Agentique — Pipeline 5-pass) : l’epic reste ouverte sur ses autres axes, mais sa passe de compilation audio aboutit ici, et son manifeste JSON constitue le livrable principal consommable en aval (par un lecteur audio tiers, par une plateforme de podcast, ou par un outil d’analyse).

Exercice : Generer un manifest avec marqueurs de chapitres

Duree estimee : 15 minutes

Objectif : Enrichir le manifest JSON avec des marqueurs de chapitres calcules automatiquement. Les chapitres sont delimites par les segments de type “narration” longs (> 10 secondes) qui précédent un changement de type de segment. Le manifest enrichi inclut les timestamps de debut et fin de chaque chapitre.

Contexte : Les plateformes audio (Audible, Apple Podcasts) utilisent les marqueurs de chapitres pour la navigation. Ceux-ci doivent etre generes automatiquement a partir de la structure du texte.

  • Étape 1 : Identifier les frontieres de chapitres (narration longue suivie d’un dialogue)
  • Étape 2 : Calculer les timestamps cumules (en secondes) pour chaque frontiere
  • Étape 3 : Ajouter les chapitres au manifest et exporter en JSON

Indice : Un chapitre commence quand un segment narration (> 10s) est suivi d’un dialogue Indice : Les timestamps cumules = somme des durees des segments précédents + silences Indice : Le format chapter = {“title”: “Chapitre N”, “start_s”: float, “end_s”: float}

# TODO etudiant : manifest avec marqueurs de chapitres
CHAPTER_THRESHOLD_S = 10.0  # Duree minimale d'un segment narration pour former un chapitre

# Etape 1 : Identifier les frontieres
def detect_chapter_boundaries(segments_list, threshold_s=CHAPTER_THRESHOLD_S):
    """Detecte les indices de debut de chapitre."""
    # TODO etudiant
    return []  # TODO etudiant

# Etape 2 : Calculer les timestamps
def compute_chapter_timestamps(segments_list, boundaries, silence_ms=SILENCE_MS):
    """Calcule les timestamps de debut/fin pour chaque chapitre."""
    # TODO etudiant
    return []  # TODO etudiant

# Etape 3 : Enrichir le manifest
# TODO etudiant : ajouter les chapitres au manifest et sauvegarder

print("Exercice a completer")
Exercice a completer

Chapitrage .m4b — le conteneur audiobook natif

Le compilateur de la chaine v4 (v4/p6_compile.py) ne produit pas seulement le .mp3 concatene ci-dessus : depuis #14224 il emet aussi un .m4b chapitre — le format conteneur natif des lecteurs d’audiobooks (Apple Books, BookPlayer, VLC), ou la navigation par chapitre est une primitive du lecteur, pas un signet pose a la main.

La mecanique, bouclee de bout en bout dans le module de production :

  1. Positions d’actes — pendant la concatenation, la boucle enregistre act_start_ms[acte] = len(audiobook) au passage du premier segment de chaque acte (les quatre actes de Boule de Suif, issus de p3_dramatic_context.py) ;
  2. FFMETADATA — ces timestamps deviennent un fichier FFMETADATA1 avec un bloc [CHAPTER] par acte (TIMEBASE 1/1000, START/END en ms, title) ;
  3. Remux AAC — ffmpeg transcode le mp3 vers AAC dans le conteneur MPEG-4 avec -map_metadata 1 -map_chapters 1.

Cette section execute le vrai run() de production sur une fixture autonome — 8 segments de silence (2 par acte), meme topologie que le test d’integration du chapitrage — reproductible sur toute machine avec ffmpeg, sans aucun service GenAI. Le run complet sur le livre entier (385 paragraphes, 94,7 min, 4 chapitres reels) est documente dans #14059 et vit sur la machine de la chaine ; les outputs ci-dessous sont ceux de la fixture locale.

import json
import os
import subprocess
import sys
import warnings
from pathlib import Path

# Filtre cible : le schema pydantic de la v4 emet un UserWarning de shadowing dont le
# prefixe contient le chemin absolu du module -- on l'exclut pour garder des sorties
# relocalisables (il n'apporte rien a la demonstration du chapitrage).
warnings.filterwarnings("ignore", message='Field name "register" in "VoiceReference"')

# ffmpeg : build statique local (regle F -- on installe l'outil, on ne le contourne pas).
# Sur une machine ou ffmpeg est deja dans le PATH, ce bloc est sans effet.
FFMPEG_DIR = Path(os.environ.get("LOCALAPPDATA", "")) / "ffmpeg-local" / "ffmpeg-master-latest-win64-gpl" / "bin"
if FFMPEG_DIR.exists():
    os.environ["PATH"] = str(FFMPEG_DIR) + os.pathsep + os.environ["PATH"]
FFMPEG = "ffmpeg"
FFPROBE = "ffprobe"

# Module de PRODUCTION v4/p6_compile.py (le chapitrage .m4b y vit depuis #14224).
sys.path.insert(0, str(Path.cwd()))
from v4 import p6_compile
from v4.schemas import DramaticContext, DramaticContextBatch

# Fixture autonome, reproductible sans aucun service GenAI : 8 segments de silence
# (2 par acte), meme topologie que le test d'integration du chapitrage.
FIX = Path("m4b_fixture")
(FIX / "outputs").mkdir(parents=True, exist_ok=True)
ACTS = ["act1_diligence_aller", "act2_auberge_jours", "act3_pressing_collectif", "act4_diligence_retour"]
POSITIONS = ["exposition", "rising", "climax", "falling"]

tts_results = []
for i in range(8):
    mp3 = FIX / f"seg_{i:02d}.mp3"
    subprocess.run(
        [FFMPEG, "-y", "-loglevel", "error", "-f", "lavfi",
         "-i", "anullsrc=r=24000:cl=mono", "-t", "2", "-b:a", "128k", str(mp3)],
        check=True,
    )
    tts_results.append({"seg_index": i, "mp3_path": str(mp3)})
(FIX / "outputs" / "tts_results.json").write_text(json.dumps(tts_results), encoding="utf-8")

contexts = [
    DramaticContext(
        seg_index=i,
        act=ACTS[i // 2],
        scene_label=f"Scene fixture {i // 2 + 1}",
        tension_0_10=4,
        character_state={},
        narrative_position=POSITIONS[i // 2],
    )
    for i in range(8)
]
batch = DramaticContextBatch(contexts=contexts)
(FIX / "outputs" / "dramatic_context.json").write_text(batch.model_dump_json(), encoding="utf-8")
print("Fixture prete : 8 segments (2 par acte), dramatic_context.json valide")

# Execution du VRAI run() de production sur la fixture : BASE_DIR repointe vers FIX,
# tout le reste (boucle act_start_ms, FFMETADATA, remux AAC) est le code de #14224.
p6_compile.BASE_DIR = FIX
mp3_out = p6_compile.run(force=True)
print(f"Compile par le module de production : {mp3_out.name}")
Fixture prete : 8 segments (2 par acte), dramatic_context.json valide
[P6] Compiling audiobook...
  Segments to compile: 8
  Act boundary: act1_diligence_aller -> act2_auberge_jours at seg 2
  Act boundary: act2_auberge_jours -> act3_pressing_collectif at seg 4
  Act boundary: act3_pressing_collectif -> act4_diligence_retour at seg 6
  [P6] m4b: m4b_fixture\outputs\boule_de_suif_v4.m4b (0.0 MB, 4 chapitres)
[P6] Done: m4b_fixture\outputs\boule_de_suif_v4.mp3
  Compiled: 8, Skipped: 0
  Duration: 24.2s (0.4min)
  Size: 0.5 MB
Compile par le module de production : boule_de_suif_v4.mp3

Verification du chapitrage

ffprobe -show_chapters est l’organe de preuve : il lit les chapitres dans le conteneur produit — si le .m4b ne portait pas ses marqueurs, cette commande ne rendrait rien. On attend 4 chapitres, des timestamps de debut monotones, et les titres des quatre actes.

import json

m4b = FIX / "outputs" / "boule_de_suif_v4.m4b"
probe = subprocess.run(
    [FFPROBE, "-v", "error", "-show_chapters", "-of", "json", str(m4b)],
    capture_output=True, text=True, check=True,
)
chapters = json.loads(probe.stdout)["chapters"]
print(f"{len(chapters)} chapitres dans {m4b.name} ({m4b.stat().st_size / 1024:.0f} Ko) :")
for ch in chapters:
    print(f"  {ch['tags']['title']}: {float(ch['start_time']):.2f}s -> {float(ch['end_time']):.2f}s")

titles = [ch["tags"]["title"] for ch in chapters]
starts = [float(ch["start_time"]) for ch in chapters]
ends = [float(ch["end_time"]) for ch in chapters]
assert len(chapters) == 4, f"attendu 4 actes, trouve {len(chapters)}"
assert starts == sorted(starts), "timestamps de debut non monotones"
assert all(e > s for s, e in zip(starts, ends)), "chapitre a duree nulle"
assert titles == ["Acte I -- La diligence de l'aller",
                  "Acte II -- L'auberge, les jours",
                  "Acte III -- Le pressing collectif",
                  "Acte IV -- La diligence du retour"], titles
print("Verifie : les 4 actes sont des chapitres natifs du conteneur, monotones, a duree strictement positive.")
4 chapitres dans boule_de_suif_v4.m4b (6 Ko) :
  Acte I -- La diligence de l'aller: 0.00s -> 5.30s
  Acte II -- L'auberge, les jours: 5.30s -> 11.61s
  Acte III -- Le pressing collectif: 11.61s -> 17.91s
  Acte IV -- La diligence du retour: 17.91s -> 24.22s
Verifie : les 4 actes sont des chapitres natifs du conteneur, monotones, a duree strictement positive.

Lecture de la sortie

Les quatre actes sont des chapitres natifs du conteneur : un lecteur d’audiobooks proposera « Acte I — La diligence de l’aller » comme borne de navigation directe, avec le timescode exact capte pendant la concatenation (5,30 s, 11,61 s, 17,91 s — chaque frontiere d’acte herite aussi du silence d’1,5 s insere par la boucle). La duree totale du chapitre IV couvre exactement la fin de l’audiobook : le dernier bloc END est cale sur la duree compilee, pas sur une valeur arrondie.

Avec ce chapitrage, la case P5 de l’EPIC #1028 est livree de bout en bout : le script produit le conteneur (#14224), le companion le demontre, et la suspension du monitor d’idle pour les chaines longues est automatisee (#14415). Les objectifs de ce notebook couvraient « duree, clipping, niveaux » ; le conteneur ajoute la navigation — la derniere brique du « livre audio pour publics empches » que l’EPIC poursuit.

Conclusion — Epic #1028 Pipeline Complet

L’audiobook est compile. Pipeline 5-pass complet :

Pass Description Output
P1 Lecture analytique analytique_output/
P2 Voice casting voice_casting_output/
P3 Annotation prosodique prosodic_output/
P4 Generation TTS tts_output/
P5 Compilation audio audiobook_final/

Production : En production, remplacer Kokoro par FishAudio S2-Pro pour une qualite superieure avec les 15000+ tags expressifs ([whisper], [shout], [cold], etc.).

Ameliorations futures : - Jingles/musique d’ambiance entre chapitres - Variation de vitesse par type de segment (narration vs dialogue) - Stereo spatial pour separer narrateur et personnages

Retour au sommet