# Parameters
BATCH_MODE = "true"Lecture Analytique pour Audiobook
< Retour aux applications audio | Plan du cours Audio >
Objectifs
- Parser un texte litteraire en chapitres et paragraphes
- Detecter les personnages et leur distribution dans le texte
- Segmenter le texte en narration, dialogue et description
- Extraire les repliques avec attribution du locuteur
- Recommander des voix TTS par personnage
Prerequis
- Concepts de base du traitement du texte (regex, tokenisation)
- Notebook 04-6-Audiobook-Pipeline recommande
Duree estimee : 30 minutes
import json
import re
import os
from pathlib import Path
from dataclasses import dataclass, field, asdict
from typing import Optional
from collections import Counter
try:
from dotenv import load_dotenv
_env_candidates = [
Path("../.env").resolve(),
Path("../../.env").resolve(),
Path("../../../.env").resolve(),
]
for _env_path in _env_candidates:
if _env_path.exists():
load_dotenv(_env_path)
break
except ImportError:
pass
OUTPUT_DIR = Path("output_analytique")
OUTPUT_DIR.mkdir(exist_ok=True)
# Deux sources explicites :
# "local" (defaut) : texte canonique vendored dans le depot, reproductible.
# "gutenberg" (option) : re-telechargement depuis Project Gutenberg.
TEXT_SOURCE = os.getenv("TEXT_SOURCE", "local")
GUTENBERG_ID = 10746 # Boule de Suif, Guy de Maupassant
LOCAL_TEXT_PATH = Path("boule_de_suif_full.txt")
print("Environnement initialise.")
print(f"Repertoire de sortie : {OUTPUT_DIR}")
print(f"Source de texte : {TEXT_SOURCE}")Environnement initialise.
Repertoire de sortie : output_analytique
Source de texte : local
1. Chargement du texte
Le conte Boule de Suif de Guy de Maupassant est charge depuis un fichier canonique vendored dans le depot (boule_de_suif_full.txt). Ce fichier porte en realite deux contes – Boule de Suif (environ 83 000 caracteres), puis L’Épave – : l’analyse est donc bornee au seul conte Boule de Suif, la frontiere etant le titre du second conte (\nL'Épave). C’est cette meme frontiere qui borne la source Gutenberg, et le corps compare commence a l’ouverture canonique du conte (Pendant plusieurs jours) : les editions Gutenberg portent du front matter (page d’editeur, table des matieres) qu’une comparaison depuis le titre melangerait au conte. La cellule de verification dediee ci-dessous exerce reellement le chemin distant et imprime son verdict a deux niveaux dans les sorties : les editions different-elles ? le corps du conte est-il identique (espaces normalises, marqueurs d’illustration retires) ? Une divergence du corps, un corps introuvable, ou un echec reseau declarent chacun leur verdict – jamais une equivalence affirmee sans execution.
Deux sources sont supportees via la variable d’environnement TEXT_SOURCE :
local(defaut) : le fichier vendored, toujours disponible, meme hors ligne ;gutenberg(option) : un re-telechargement depuis Project Gutenberg (identifiant 10746), qui se replie sur le fichier vendored en cas d’echec reseau.
Aucun texte fabrique n’est utilise comme fallback : si le fichier canonique est absent, le notebook echoue explicitement (FileNotFoundError) plutot que d’analyser un extrait de substitution, conformement a la volonte de ne jamais presenter des metriques calculees sur un texte invente comme si elles portaient sur l’oeuvre.
import urllib.request
# Le fichier vendored porte DEUX contes : Boule de Suif, puis L'Épave a partir
# d'environ 83 000 caracteres. L'analyse porte sur le SEUL conte Boule de Suif :
# la frontiere est le titre du second conte, et la meme frontiere borne la
# source Gutenberg. Ce que les deux sources delivrent reellement, la cellule
# de verification dediee ci-dessous l'etablit par execution.
TALE_BOUNDARY = "\nL'Épave"
# Ouverture canonique du conte : le corps compare commence ici. Les editions
# Gutenberg portent du front matter (page d'editeur, table des matieres) avant
# le texte -- comparer depuis le titre melangerait ce front matter au conte.
TALE_OPENING = "Pendant plusieurs jours"
# Marqueurs d'illustration Project Gutenberg : boilerplate d'edition, pas du texte.
ILLUSTRATION_MARKER = re.compile(r"\[Illustration[^\]]*\]\s*", re.I)
GUTENBERG_MARKER = re.compile(
r"\*\*\*\s*(START|END) OF (?:THE |THIS )?PROJECT GUTENBERG EBOOK.*?\*\*\*",
re.I,
)
def _slice_tale(body: str) -> str:
"""Conserve uniquement le conte Boule de Suif (le vendored porte deux contes).
Frontiere absente = contenu inattendu : echouer explicitement plutot que
d'analyser le recueil entier sous le libelle du conte."""
idx = body.find(TALE_BOUNDARY)
if idx == -1:
raise ValueError(
"Frontiere du conte (titre du second conte) absente : le contenu "
"lu n'est pas le recueil attendu -- refus d'analyser une collection "
"sous le libelle du conte."
)
return body[:idx].strip()
def _extract_body(raw: str) -> str:
"""Conserve uniquement le corps de l'oeuvre, entre les marqueurs START/END
reellement servis par Project Gutenberg (insensibles a la casse)."""
start = GUTENBERG_MARKER.search(raw)
if start and start.group(1).upper() == "START":
raw = raw[start.end():]
end = GUTENBERG_MARKER.search(raw)
if end and end.group(1).upper() == "END":
raw = raw[:end.start()]
return raw.strip()
def _read_local() -> str:
if not LOCAL_TEXT_PATH.exists():
raise FileNotFoundError(
f"Texte canonique absent : {LOCAL_TEXT_PATH.resolve()}. "
"Restaurez boule_de_suif_full.txt depuis l'arbre git."
)
return LOCAL_TEXT_PATH.read_text(encoding="utf-8")
def _norm(s: str) -> str:
return " ".join(s.split())
def load_text(source: str):
"""Charge le CONTE Boule de Suif selon la source, retourne (texte, libelle)."""
vendored_tale = _slice_tale(_read_local())
if source == "local":
return vendored_tale, "conte Boule de Suif (fichier vendored)"
if source == "gutenberg":
try:
url = f"https://www.gutenberg.org/cache/epub/{GUTENBERG_ID}/pg{GUTENBERG_ID}.txt"
req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
with urllib.request.urlopen(req, timeout=15) as resp:
raw = resp.read().decode("utf-8")
body = _extract_body(raw)
oidx = body.find(TALE_OPENING)
idx = body.find(TALE_BOUNDARY)
if oidx == -1 or idx == -1 or oidx >= idx:
print("Corps du conte introuvable dans le contenu Gutenberg (ouverture ou")
print("frontiere absente) -> repli sur le vendored (jamais une collection")
print("sous le libelle du conte).")
return vendored_tale, "conte Boule de Suif (fichier vendored, repli corps introuvable)"
tale = ILLUSTRATION_MARKER.sub("", body[oidx:idx]).strip()
# le vendored porte le titre avant l'ouverture : aligner les deux
# corps sur l'ouverture canonique pour une comparaison honnete
vidx = vendored_tale.find(TALE_OPENING)
vendored_body = vendored_tale[vidx:] if vidx != -1 else vendored_tale
equiv = _norm(tale) == _norm(vendored_body)
print(f"Corps du conte Gutenberg (ouverture -> frontiere, illustrations retirees) : {len(_norm(tale))} car.")
print(f"Corps du conte vendored (depuis la meme ouverture) : {len(_norm(vendored_body))} car.")
print(f"Equivalence des corps (espaces normalises) : {equiv}")
if not equiv:
print("Divergence du corps -> repli sur le fichier vendored")
print("(le texte distant divergent n'entre pas dans l'analyse).")
return vendored_tale, "conte Boule de Suif (fichier vendored, repli divergence)"
return tale, f"conte Boule de Suif (Project Gutenberg #{GUTENBERG_ID}, corps verifie identique)"
except (urllib.error.URLError, TimeoutError, OSError) as e:
print(f"Echec du telechargement Gutenberg ({e}); repli sur le fichier vendored.")
return vendored_tale, "conte Boule de Suif (fichier vendored, repli)"
# source inconnue -> repli explicite sur le texte canonique, jamais un texte fabrique
print(f"TEXT_SOURCE '{source}' inconnu; repli sur le fichier vendored.")
return vendored_tale, "conte Boule de Suif (fichier vendored, repli defaut)"
text, text_source_label = load_text(TEXT_SOURCE)
print(f"Source du texte : {text_source_label}")
print(f"Longueur : {len(text)} caracteres, {len(text.split())} mots")Source du texte : conte Boule de Suif (fichier vendored)
Longueur : 83097 caracteres, 13372 mots
# Verification dediee : exercer REELLEMENT le chemin Gutenberg, independamment
# de TEXT_SOURCE, et imprimer le verdict dans les sorties commitees. Le verdict
# est a DEUX niveaux : (1) les editions different-elles ? (front matter : page
# d'editeur, table des matieres, marqueurs [Illustration]) ; (2) le CORPS du
# conte (ouverture canonique -> frontiere) est-il identique au vendored, les
# deux alignes sur la meme ouverture ? C'est le niveau 2 qui fonde l'equivalence.
# Trois issues honnetes : corps identique, divergence detaillee, ou limite
# reseau declaree.
_tale_local = _slice_tale(_read_local()) # reference canonique, independante de load_text
_vopen = _tale_local.find(TALE_OPENING)
if _vopen == -1:
raise RuntimeError("Ouverture canonique absente du vendored : reference locale inattendue")
_vend_body = _norm(_tale_local[_vopen:])
try:
_url = f"https://www.gutenberg.org/cache/epub/{GUTENBERG_ID}/pg{GUTENBERG_ID}.txt"
_req = urllib.request.Request(_url, headers={"User-Agent": "Mozilla/5.0"})
with urllib.request.urlopen(_req, timeout=15) as _resp:
_raw_gut = _resp.read().decode("utf-8")
_body_gut = _extract_body(_raw_gut)
_oidx = _body_gut.find(TALE_OPENING)
_idx_gut = _body_gut.find(TALE_BOUNDARY)
if _oidx == -1 or _idx_gut == -1 or _oidx >= _idx_gut:
print("VERIFICATION GUTENBERG : corps du conte introuvable a distance --")
print(" ouverture ou frontiere absente ; l'analyse reste sur le vendored.")
_verdict_equiv = False
else:
_front = _body_gut[:_oidx]
_has_toc = bool(re.search(r"L'ÉPAVE\s*--|UN PARRICIDE", _front))
_n_illu = len(ILLUSTRATION_MARKER.findall(_body_gut))
print(f"VERIFICATION GUTENBERG : edition distante avec front matter "
f"(table des matieres : {'oui' if _has_toc else 'non'}, "
f"marqueurs [Illustration] : {_n_illu})")
_tale_gut = ILLUSTRATION_MARKER.sub("", _body_gut[_oidx:_idx_gut]).strip()
_a = _norm(_tale_gut)
_verdict_equiv = _a == _vend_body
print(f" corps du conte (ouverture -> frontiere, espaces normalises) :")
print(f" distant {len(_a)} car. vs vendored {len(_vend_body)} car. -- identiques : {_verdict_equiv}")
if not _verdict_equiv:
for _pos in range(min(len(_a), len(_vend_body))):
if _a[_pos] != _vend_body[_pos]:
print(f" Premiere divergence au caractere {_pos} :")
print(f" distant : ...{_a[max(0, _pos - 40):_pos + 40]!r}...")
print(f" vendored : ...{_vend_body[max(0, _pos - 40):_pos + 40]!r}...")
break
else:
print(f" Prefixe commun integralement egal ; ecart de longueur : {abs(len(_a) - len(_vend_body))} car.")
except (urllib.error.URLError, TimeoutError, OSError) as _e:
_verdict_equiv = None
print(f"VERIFICATION GUTENBERG : telechargement impossible ({_e}).")
print(" Limite declaree : l'equivalence n'est PAS prouvee sur cette execution ;")
print(" l'analyse reste sur le vendored, source locale canonique du depot.")
VERIFICATION GUTENBERG : edition distante avec front matter (table des matieres : oui, marqueurs [Illustration] : 1)
corps du conte (ouverture -> frontiere, espaces normalises) :
distant 82797 car. vs vendored 82797 car. -- identiques : True
2. Segmentation du texte
Le texte est decoupe en segments semantiques : narration, dialogue et description. La classification repose sur les guillemets francais (« ... ») et des heuristiques typographiques propres a la prose de Maupassant.
Types de segments :
narration: texte narratif sans dialogue, le coeur du recit ;dialogue: passage entre guillemets introduit par un tiret (--) ou un verbe de parole (dit-il,repondit,demanda, …) — la replique est stockee sans les guillemets ;description: passage descriptif dense (au moins 2 indices lexicaux : paysage, visage, silhouette, …).
Les guillemets seuls ne suffisent pas : dans Boule de Suif, des expressions mises entre guillemets — verifiees dans le texte : « Loiseau vole », « prostituée », « droit de guerre » — sont de la narration, pas des repliques. Le detecteur de dialogue (_is_dialogue) exige donc un introducteur — un tiret de transition ou un verbe de parole dans les ~60 caracteres precedents — avant de retenir un segment comme dialogue.
@dataclass
class Segment:
"""Un segment texte avec son type semantique."""
index: int
text: str
seg_type: str # narration, dialogue, description
char_count: int
word_count: int
paragraph_idx: int
GUY_QUOTES = re.compile(r"\u00ab([^\u00bb]*)\u00bb")
SPEECH_VERB_RE = re.compile(
r"(demanda|r[ée]pondit|r[ée]pliqua|dit\b|cria|murmura|reprit|s[ée]cria|"
r"dit-il|reprit-il|demanda-t-il|ajouta|remercia|interrompit|continua|conclut|souffla)",
re.I,
)
DESC_KEYWORDS = [
"decrivait", "decrivaient", "description", "paysage",
"apparence", "visage", "silhouette", "vetu", "vetement",
"blond", "brun", "robuste", "mince",
]
_DASH_FLOW = re.compile(r"--\s*$|\u2014\s*$")
def _is_dialogue(para: str, m) -> bool:
"""Un guillemet est du dialogue quand il est introduit par un tiret-tiret
ou un verbe de parole dans la fenetre immediatement precedente."""
before = para[max(0, m.start() - 60):m.start()].replace("\n", " ")
return bool(_DASH_FLOW.search(before)) or bool(SPEECH_VERB_RE.search(before + " "))
def _narr_type(s: str) -> str:
low = s.lower()
hits = sum(1 for kw in DESC_KEYWORDS if kw in low)
return "description" if hits >= 2 else "narration"
def _is_junk(s: str) -> bool:
"""Morceau sans contenu semantique (tirets de liaison, ponctuation seule)."""
return bool(re.fullmatch(r"[\s\u2014-]+", s))
def segment_text(text: str) -> list[Segment]:
"""Decoupe le texte en segments types selon les guillemets francais."""
paragraphs = [p.strip() for p in re.split(r"\n\s*\n", text) if p.strip()]
segments = []
idx = 0
for para_idx, para in enumerate(paragraphs):
last = 0
parts = []
for m in GUY_QUOTES.finditer(para):
if not _is_dialogue(para, m):
continue
between = para[last:m.start()].strip()
if between and not _is_junk(between):
parts.append((between, _narr_type(between)))
content = m.group(1).strip()
if content:
parts.append((content, "dialogue"))
last = m.end()
tail = para[last:].strip()
if tail and not _is_junk(tail):
parts.append((tail, _narr_type(tail)))
for raw, typ in parts:
if typ == "dialogue":
segments.append(Segment(
index=idx, text=raw, seg_type="dialogue",
char_count=len(raw), word_count=len(raw.split()),
paragraph_idx=para_idx,
))
idx += 1
else:
if (segments and segments[-1].seg_type == typ
and segments[-1].paragraph_idx == para_idx
and not _is_junk(segments[-1].text)):
segments[-1].text += " " + raw
segments[-1].char_count = len(segments[-1].text)
segments[-1].word_count = len(segments[-1].text.split())
else:
segments.append(Segment(
index=idx, text=raw, seg_type=typ,
char_count=len(raw), word_count=len(raw.split()),
paragraph_idx=para_idx,
))
idx += 1
return segments
segments = segment_text(text)
type_counts = Counter(s.seg_type for s in segments)
print(f"Segmentation : {len(segments)} segments")
for st, count in sorted(type_counts.items(), key=lambda x: -x[1]):
avg_words = sum(s.word_count for s in segments if s.seg_type == st) / count
print(f" {st:12s} : {count:3d} segments (moy. {avg_words:.0f} mots)")
print(f"\nExemples de segments :")
for s in segments[:5]:
preview = s.text[:80] + ("..." if len(s.text) > 80 else "")
print(f" [{s.seg_type:12s}] {preview}")Segmentation : 342 segments
narration : 302 segments (moy. 42 mots)
dialogue : 39 segments (moy. 14 mots)
description : 1 segments (moy. 94 mots)
Exemples de segments :
[narration ] BOULE DE SUIF
[narration ] Pendant plusieurs jours de suite des lambeaux d'armée en déroute avaient
travers...
[narration ] Des légions de francs-tireurs aux appellations héroïques: «les
Vengeurs de la Dé...
[narration ] Leurs chefs, anciens commerçants en draps ou en graines, ex-marchands de
suif ou...
[narration ] Les Prussiens allaient entrer dans Rouen, disait-on.
Lecture du resultat : segmentation semantique
La cellule ci-dessus decompose le texte integral de Maupassant en 342 segments types selon leur fonction narrative. La distribution observee reflete l’architecture du conte realiste :
- 302 segments de narration (moyenne 42 mots) — la proportion dominante : la nouvelle de Maupassant est un recit qui progresse temporellement, et les scenes de la debacle (l’armee en deroute, l’occupation de Rouen) portent l’essentiel de cette narration ;
- 39 segments de dialogue (moyenne 14 mots) — brievete typique des repliques dans les echanges en diligence ou a l’hotel, la parole y est courte et incidente ;
- 1 segment de description (94 mots) — une seule plage dense (vraisemblablement une presentation), d’ou son poids moyen eleve.
La detection repose sur le motif GUY_QUOTES (guillemets francais) combine a _is_dialogue, qui exige un introducteur (tiret -- ou verbe de parole) dans la fenetre precedente. Voir l’exercice ci-dessous, qui propose d’enrichir cette classification (type interjection) pour mieux isoler les apartes dramatiques.
Exercice : Ameliorer la segmentation avec un detecteur d’interjection
Duree estimee : 15-20 minutes
Objectif : Ajouter un nouveau type de segment interjection pour les courtes repliques isolees (moins de 10 mots) qui ne sont ni du dialogue complet ni de la narration. Actuellement, ces fragments sont classes comme dialogue ou narration de maniere incorrecte.
Contexte : Les interjections (“Jamais !”, “Merci, madame.”) sont des elements cles du texte dramatique mais n’ont pas le meme traitement TTS qu’un dialogue soutenu. Un segment interjection permet d’ajuster le style vocal (plus intense, plus court).
- Etape 1 : Definir un critere pour les interjections (moins de 10 mots + commence par un tiret ou point d’exclamation)
- Etape 2 : Modifier la fonction segment_text pour creer ce nouveau type
- Etape 3 : Re-segmenter le texte et afficher le nouveau decompte par type
Indice : Ajoutez un test len(stripped.split()) < 10 dans la boucle de classification Indice : Les interjections commencent souvent par “!” ou sont une seule phrase courte apres un tiret
# TODO etudiant : segmentation amelioree avec type "interjection"
# Etape 1 : Critere d'interjection
MAX_INTERJECTION_WORDS = None # TODO etudiant : seuil de mots (ex: 10)
# Etape 2 : Fonction de segmentation modifiee
def segment_text_v2(txt):
"""Version amelioree avec detection des interjections."""
# TODO etudiant : adapter segment_text() pour ajouter le type "interjection"
return [] # TODO etudiant
# Etape 3 : Re-segmenter et comparer
# TODO etudiant : appeler segment_text_v2(text) et afficher le decompte
print("Exercice a completer")Exercice a completer
3. Detection des personnages
Nous detectons les personnages du texte en utilisant un dictionnaire predefini des personnages connus de Boule de Suif. L’approche combine la recherche par nom complet, par surnom et par titre.
Cette méthode est adaptee aux textes classiques ou les personnages sont en nombre fini et bien identifie. Pour un texte inconnu, il faudrait utiliser un modèle NER (Named Entity Recognition).
KNOWN_CHARACTERS = {
"boule_de_suif": {
"full_name": "Elisabeth Rousset",
"aliases": ["Boule de Suif", "Rousset", "Elisabeth"],
"gender": "F",
"role": "protagoniste",
"description": "Prostitutee de grand renom, coeur genereux",
},
"comtesse": {
"full_name": "Comtesse de Breville",
"aliases": ["comtesse", "la comtesse", "Breville"],
"gender": "F",
"role": "secondaire",
"description": "Femme de la noblesse, diplomate et condescendante",
},
"comte": {
"full_name": "Comte Hubert de Breville",
"aliases": ["comte", "le comte", "Hubert"],
"gender": "M",
"role": "secondaire",
"description": "Diplomate de la noblesse, manipulateur poli",
},
"cornudet": {
"full_name": "Cornudet",
"aliases": ["Cornudet", "le republicain"],
"gender": "M",
"role": "secondaire",
"description": "Republicain, bon vivant, partisan de Boule de Suif",
},
"loiseau": {
"full_name": "Monsieur Loiseau",
"aliases": ["Loiseau", "marchand de vins"],
"gender": "M",
"role": "secondaire",
"description": "Marchand de vins, grossier et calculateur",
},
"mme_loiseau": {
"full_name": "Mme Loiseau",
"aliases": ["Mme Loiseau", "madame Loiseau", "femme de Loiseau"],
"gender": "F",
"role": "secondaire",
"description": "Epouse du marchand de vins, a la langue bien pendue",
},
"officier": {
"full_name": "Officier prussien",
"aliases": ["officier", "Prussien"],
"gender": "M",
"role": "antagoniste",
"description": "Officier prussien, froid et exigeant",
},
"carre_lamadon": {
"full_name": "Monsieur Carre-Lamadon",
"aliases": ["Carre-Lamadon", "le manufacturier"],
"gender": "M",
"role": "secondaire",
"description": "Manufacturier, patriote de circonstance",
},
"soeurs": {
"full_name": "Les bonnes soeurs",
"aliases": ["soeurs", "les bonnes soeurs", "religieuses"],
"gender": "F",
"role": "figurant",
"description": "Deux religieuses, silencieuses et pieuses",
},
}
def detect_characters(text: str, known: dict) -> dict:
"""Detecte les personnages dans le texte."""
results = {}
text_lower = text.lower()
for char_id, info in known.items():
mentions = 0
mention_positions = []
for alias in info["aliases"]:
for m in re.finditer(re.escape(alias.lower()), text_lower):
mentions += 1
mention_positions.append(m.start())
if mentions > 0:
results[char_id] = {
**info,
"mentions": mentions,
"mention_positions": sorted(set(mention_positions)),
"text_coverage": len(mention_positions) / max(len(text.split()), 1),
}
return dict(sorted(results.items(), key=lambda x: -x[1]["mentions"]))
characters = detect_characters(text, KNOWN_CHARACTERS)
print(f"Personnages detectes : {len(characters)}")
for cid, info in characters.items():
role_tag = f"[{info['role']}]" if info['role'] != 'figurant' else ''
print(f" {info['full_name']:30s} {role_tag:15s} {info['mentions']:3d} mentions")Personnages detectes : 9
Comte Hubert de Breville [secondaire] 86 mentions
Officier prussien [antagoniste] 52 mentions
Monsieur Loiseau [secondaire] 50 mentions
Elisabeth Rousset [protagoniste] 42 mentions
Comtesse de Breville [secondaire] 35 mentions
Cornudet [secondaire] 30 mentions
Les bonnes soeurs 21 mentions
Mme Loiseau [secondaire] 8 mentions
Monsieur Carre-Lamadon [secondaire] 2 mentions
Lecture du resultat : detection des personnages
La cellule ci-dessus croise le texte avec le dictionnaire KNOWN_CHARACTERS pour identifier 9 personnages appartenant a l’univers diegetique de Boule de Suif. La distribution des mentions est dominee par le Comte Hubert de Breville (86), l’Officier prussien (52), Monsieur Loiseau (50) et Elisabeth Rousset (42), la protagoniste du titre — un renversement interessant : la figure eponyme n’est pas la plus mentionnee, effet classique de la focalisation chez Maupassant. Suivent la Comtesse de Breville (35), Cornudet (30) et les bonnes soeurs (21) ; Mme Loiseau (8) et Monsieur Carre-Lamadon (2) ferment la distribution.
A noter : ces comptes sont des mentions brutes — la detection remonte les occurrences des alias par correspondance de sous-chaines (re.finditer sans frontiere de mot). Un alias comme comte est ainsi compte aussi a l’interieur de comtesse, et loiseau a l’interieur de madame loiseau ; les totaux sur-estiment donc les personnages dont le nom est un prefixe d’un autre (ici essentiellement le Comte, la Comtesse et les deux Loiseau). Pour un comptage exact, il faudrait des frontieres de mots ou un modele NER — limite documentee dans la conclusion. La classification protagoniste / antagoniste / secondaire / figurant du dictionnaire permet neanmoins a la section 6 d’attribuer des voix TTS differenciees selon le statut narratif.
Exercice : Analyse de la densite de mentions entre personnages
Duree estimee : 15 minutes
Objectif : Calculer un score d’interaction entre chaque paire de personnages, mesure par la co-occurrence de leurs mentions dans un même segment. Ce score revele quels personnages interagissent le plus dans le texte.
Contexte : Dans Boule de Suif, les personnages interagissent principalement dans la diligence et a l’hôtel. L’analyse de co-occurrence permet de detecter les scenes de groupe et les duos narratifs.
- Étape 1 : Pour chaque segment, identifier tous les personnages mentionnes
- Étape 2 : Compter les co-occurrences (paires de personnages apparaissant dans le même segment)
- Étape 3 : Afficher les 5 paires les plus frequentes avec un tableau formate
Indice : Utilisez itertools.combinations(present_in_seg, 2) pour generer les paires Indice : Un dictionnaire {(char1, char2): count} stocke les co-occurrences Indice : Triez par count decroissant et affichez avec un formatage propre
# TODO etudiant : analyse de co-occurrence des personnages
import itertools
# Etape 1 : Identifier les personnages presents dans chaque segment
def characters_in_segment(seg_text, characters_dict):
"""Retourne la liste des character_id mentionnes dans le segment."""
# TODO etudiant : verifier quelles alias apparaissent dans seg_text
return None # TODO etudiant
# Etape 2 : Compter les co-occurrences
cooccurrences = None # TODO etudiant : dictionnaire {(char1, char2): count}
# Etape 3 : Afficher les top 5
# TODO etudiant : trier et afficher
print("Exercice a completer")Exercice a completer
4. Extraction des repliques
A partir des segments de dialogue, nous extrayons les repliques individuelles avec attribution du locuteur. L’attribution utilise des patterns linguistiques du francais (verbes de parole, constructions inverses).
La structure Utterance capture chaque prise de parole avec son locuteur probable et le segment source.
@dataclass
class Utterance:
"""Une replique extraite du texte."""
index: int
speaker: str
text: str
seg_index: int
utterance_type: str # dialogue
def build_alias_maps(characters: dict) -> tuple[dict, dict]:
"""Table alias -> nom canonique et genre, deduite de KNOWN_CHARACTERS."""
aliases = {}
gender = {}
for cid, info in characters.items():
name = info["full_name"]
gender[name] = info["gender"]
for a in info["aliases"]:
aliases[a.lower()] = name
return aliases, gender
def extract_utterances(
segments: list[Segment], characters: dict
) -> list[Utterance]:
"""Extrait les repliques des segments de dialogue et estime le locuteur.
L'attribution est heuristique : elle privilegie un personnage nomme juste
apres un verbe de parole, puis le dernier personnage de meme genre mentionne
dans le paragraphe quand la replique est introduite par un pronom (il/elle).
Les coreferences non resolues retombent sur le locuteur par defaut.
"""
aliases, gender = build_alias_maps(characters)
alias_keys = sorted(aliases, key=len, reverse=True)
def _best_speaker(attr):
low = attr.lower()
best = None
best_score = None
for a in alias_keys:
if len(a) <= 2:
continue
for m in re.finditer(re.escape(a), low):
pos = m.start()
window = low[max(0, pos - len(a) - 15):pos + len(a) + 15]
score = (3 if SPEECH_VERB_RE.search(window) else 0)
score += pos / max(len(attr), 1) * 2
if best_score is None or score > best_score:
best_score = score
best = aliases[a]
return best
def _last_gender(paragraph_text, want):
last = None
low = paragraph_text.lower()
for a in alias_keys:
if len(a) <= 2:
continue
name = aliases[a]
if gender.get(name) != want:
continue
for m in re.finditer(re.escape(a), low):
last = name
return last
para_narr = {}
for seg in segments:
if seg.seg_type in ("narration", "description"):
para_narr.setdefault(seg.paragraph_idx, []).append(seg.text)
utterances = []
idx = 0
for i, seg in enumerate(segments):
if seg.seg_type != "dialogue":
continue
para = seg.paragraph_idx
pre = ""
post = ""
for j in range(i - 1, -1, -1):
if segments[j].paragraph_idx != para:
break
if segments[j].seg_type in ("narration", "description"):
pre = segments[j].text
break
for j in range(i + 1, len(segments)):
if segments[j].paragraph_idx != para:
break
if segments[j].seg_type in ("narration", "description"):
post = segments[j].text
break
attr = (pre[-80:] + " " + post[:80]).strip()
pronoun = None
for p in ("il", "elle", "on"):
if re.search(r"\b" + p + r"\s+(?:r[ée]pondit|r[ée]pliqua|dit\b|cria|murmura|"
r"reprit|ajouta|demanda|souffla|continua|conclut)", attr, re.I):
pronoun = p
break
if pronoun:
ptext = " ".join(para_narr.get(para, []))
speaker = _last_gender(ptext, "M" if pronoun == "il" else "F")
else:
speaker = _best_speaker(attr)
utterances.append(Utterance(
index=idx, speaker=speaker or "Narrateur", text=seg.text,
seg_index=seg.index, utterance_type="dialogue",
))
idx += 1
return utterances
utterances = extract_utterances(segments, characters)
speaker_counts = Counter(u.speaker for u in utterances)
print(f"Repliques extraites : {len(utterances)}")
print(f"\nDistribution des locuteurs :")
for speaker, count in speaker_counts.most_common():
print(f" {speaker:30s} : {count:3d} repliques")
print(f"\nPremieres repliques :")
for u in utterances[:6]:
preview = u.text[:70] + ("..." if len(u.text) > 70 else "")
print(f" [{u.speaker:20s}] {preview}")Repliques extraites : 39
Distribution des locuteurs :
Narrateur : 21 repliques
Monsieur Loiseau : 6 repliques
Comte Hubert de Breville : 5 repliques
Mme Loiseau : 2 repliques
Officier prussien : 2 repliques
Les bonnes soeurs : 1 repliques
Elisabeth Rousset : 1 repliques
Cornudet : 1 repliques
Premieres repliques :
[Narrateur ] J'emmène ma
femme,
[Narrateur ] J'en fais autant.«--«Et moi aussi.
[Narrateur ] Nous ne reviendrons pas à Rouen, et si les Prussiens
approchent du Hav...
[Narrateur ] Pourquoi ne montez-vous
pas dans la voiture, vous serez à l'abri, au m...
[Narrateur ] Tout le
monde est-il monté?
[Narrateur ] Oui.
Lecture du resultat : extraction des repliques
La cellule ci-dessus extrait 39 repliques a partir des segments de dialogue identifies a la section 2. La distribution des locuteurs est dominee par le Narrateur (21) — marqueur de la limite principale de l’attribution heuristique, et non l’indice d’un narrateur qui parlerait : beaucoup de ces repliques sont des echanges entre voyageurs annonces par des pronoms (« J’emmene ma femme », « Et moi aussi », « Pourquoi ne montez-vous pas … ») sans nom de personnage adjacent, donc non rattachables a un alias de KNOWN_CHARACTERS. Les personnages nommes qui parlent le plus sont Monsieur Loiseau (6), le Comte Hubert de Breville (5), Mme Loiseau (2) et l’Officier prussien (2) ; suivent les bonnes soeurs, Elisabeth Rousset et Cornudet (1 chacun).
Deux lectures honnetes :
- L’attribution est heuristique : un personnage nomme a proximite d’un verbe de parole, ou le dernier personnage de meme genre en coreference (il/elle), est retenu comme locuteur ; sinon repli sur le locuteur par defaut
Narrateur. Ce choix est conscient et documente dans la section « Limites » de la conclusion. - La protagoniste Elisabeth Rousset parle rarement selon l’extracteur. Ce compte est un resultat de l’heuristique, pas un fait de l’oeuvre : une replique dont le verbe de parole est postpose ou absent d’une part avec le personnage adjacent est rejetee ou reattribuee — l’asymetrie observee melange donc la parole reelle du personnage et les limites de l’attribution automatique (cf. « Limites » en conclusion).
5. Profils des personnages
Nous construisons un profil detaille pour chaque personnage detecte, combinant les statistiques de presence, la distribution par type de segment et le ratio parole/narration.
Ces profils guident le choix des voix TTS dans la section suivante.
def build_character_profiles(
characters: dict, segments: list[Segment], utterances: list[Utterance]
) -> dict:
"""Construit un profil detaille par personnage."""
profiles = {}
for cid, info in characters.items():
name = info["full_name"]
aliases_lower = [a.lower() for a in info["aliases"]]
relevant_segs = []
for seg in segments:
seg_lower = seg.text.lower()
if any(alias in seg_lower for alias in aliases_lower):
relevant_segs.append(seg)
relevant_utts = [u for u in utterances if u.speaker == name]
seg_type_dist = Counter(s.seg_type for s in relevant_segs)
total_words_in_utts = sum(len(u.text.split()) for u in relevant_utts)
total_words_in_segs = sum(s.word_count for s in relevant_segs)
profiles[cid] = {
"full_name": name,
"gender": info["gender"],
"role": info["role"],
"mentions": info["mentions"],
"segments_mentioned": len(relevant_segs),
"utterances": len(relevant_utts),
"words_spoken": total_words_in_utts,
"words_in_segments": total_words_in_segs,
"seg_type_distribution": dict(seg_type_dist),
"speech_ratio": total_words_in_utts / max(total_words_in_segs, 1),
}
return dict(sorted(profiles.items(), key=lambda x: -x[1]["mentions"]))
profiles = build_character_profiles(characters, segments, utterances)
print("Profils des personnages :")
print(f"{"" :30s} {"Mentions":>8s} {"Segs":>5s} {"Utt":>4s} {"Mots dits":>9s} {"Ratio":>6s}")
print("-" * 70)
for cid, p in profiles.items():
print(
f"{p['full_name']:30s} "
f"{p['mentions']:8d} "
f"{p['segments_mentioned']:5d} "
f"{p['utterances']:4d} "
f"{p['words_spoken']:9d} "
f"{p['speech_ratio']:6.2f}"
)Profils des personnages :
Mentions Segs Utt Mots dits Ratio
----------------------------------------------------------------------
Comte Hubert de Breville 86 42 5 46 0.02
Officier prussien 52 42 2 27 0.01
Monsieur Loiseau 50 48 6 203 0.07
Elisabeth Rousset 42 38 1 2 0.00
Comtesse de Breville 35 17 0 0 0.00
Cornudet 30 28 1 12 0.01
Les bonnes soeurs 21 17 1 6 0.00
Mme Loiseau 8 8 2 6 0.02
Monsieur Carre-Lamadon 2 2 0 0 0.00
6. Recommandations de voix TTS
A partir des profils, nous associons chaque personnage a une voix TTS adaptee. Les recommandations sont generees dynamiquement a partir du catalogue Kokoro TTS (port 8191) en fonction du genre et du rôle narratif de chaque personnage.
Voix Kokoro disponibles (services heberges sur po-2023) : - Feminines : af_bella (chaleureuse), af_sky (douce), af_nova (determinee), af_sarah (distinguee) - Masculines : am_adam (grave), am_michael (polyvalente)
L’algorithme associe automatiquement une voix selon le rôle (protagoniste, antagoniste, secondaire, figurant) et le genre du personnage.
KOKORO_CATALOG = {
"F": {
"af_bella": "Chaleureuse et expressive - ideale pour personnages centraux",
"af_sky": "Douce et aerienne - ideale pour personnages discrets ou jeunes",
"af_nova": "Claire et determinee - ideale pour personnages forts",
"af_sarah": "Posee et distinguee - ideale pour noblesse, autorite douce",
},
"M": {
"am_adam": "Grave et diplomate - ideale pour autorite, noblesse",
"am_michael": "Detendue et polyvalente - ideale pour personnages varies",
},
}
ROLE_VOICE_MAP = {
"protagoniste": {"F": "af_bella", "M": "am_michael"},
"antagoniste": {"F": "af_nova", "M": "am_adam"},
"secondaire": {"F": "af_sarah", "M": "am_adam"},
"figurant": {"F": "af_sky", "M": "am_michael"},
}
def recommend_voices(characters, role_map, catalog):
recommandations = {}
for cid, info in characters.items():
gender = info["gender"]
role = info["role"]
voice_id = role_map.get(role, role_map["secondaire"]).get(gender, "am_michael")
raison = catalog[gender][voice_id]
recommandations[cid] = {
"voice": voice_id,
"model": "kokoro",
"raison": raison,
}
return recommandations
VOICE_RECOMMENDATIONS = recommend_voices(characters, ROLE_VOICE_MAP, KOKORO_CATALOG)
print("Recommandations de voix TTS par personnage :")
print("=" * 70)
for cid, rec in VOICE_RECOMMENDATIONS.items():
char_name = characters[cid]["full_name"]
gender = characters[cid]["gender"]
role = characters[cid]["role"]
print(f"\n{char_name} ({gender}, {role})")
print(f" Kokoro / {rec['voice']:10s} - {rec['raison']}")Recommandations de voix TTS par personnage :
======================================================================
Comte Hubert de Breville (M, secondaire)
Kokoro / am_adam - Grave et diplomate - ideale pour autorite, noblesse
Officier prussien (M, antagoniste)
Kokoro / am_adam - Grave et diplomate - ideale pour autorite, noblesse
Monsieur Loiseau (M, secondaire)
Kokoro / am_adam - Grave et diplomate - ideale pour autorite, noblesse
Elisabeth Rousset (F, protagoniste)
Kokoro / af_bella - Chaleureuse et expressive - ideale pour personnages centraux
Comtesse de Breville (F, secondaire)
Kokoro / af_sarah - Posee et distinguee - ideale pour noblesse, autorite douce
Cornudet (M, secondaire)
Kokoro / am_adam - Grave et diplomate - ideale pour autorite, noblesse
Les bonnes soeurs (F, figurant)
Kokoro / af_sky - Douce et aerienne - ideale pour personnages discrets ou jeunes
Mme Loiseau (F, secondaire)
Kokoro / af_sarah - Posee et distinguee - ideale pour noblesse, autorite douce
Monsieur Carre-Lamadon (M, secondaire)
Kokoro / am_adam - Grave et diplomate - ideale pour autorite, noblesse
Lecture du resultat : recommandations de voix TTS
La cellule ci-dessus croise le genre et le statut narratif (dictionnaire KNOWN_CHARACTERS, section 3) avec le catalogue KOKORO_CATALOG pour produire des recommandations de voix pour les 9 personnages de l’analyse. Cote feminin, quatre voix differenciees suivent le statut narratif — Elisabeth Rousset af_bella (« chaleureuse et expressive », protagoniste), la Comtesse de Breville et Mme Loiseau af_sarah (« posee et distinguee », secondaires), et les bonnes soeurs af_sky (« douce et aerienne », figurant). Cote masculin en revanche, le mapping est uniforme : les cinq personnages (Comte Hubert, Officier prussien, Loiseau, Cornudet, Carre-Lamadon) recoivent tous am_adam (« grave et diplomate — ideale pour autorite, noblesse »), y compris l’antagoniste prussien et les bourgeois.
La grille produite est donc essentiellement un mapping genre vers voix, affine cote feminin par le role narratif. Cette uniformite masculine est la limite que l’exercice final (« Strategie de recommandation vocale personnalisee ») invite a depasser : l’Officier prussien et le Comte partageant la meme voix, combiner plusieurs dimensions (age, registre, emotion) est precisement ce qui permettrait de les differencier.
Exercice : Stratégie de recommandation vocale personnalisee
Duree estimee : 15 minutes
Objectif : Créer une fonction de recommandation de voix TTS qui prend en compte le taux de parole (speech_ratio) du personnage, en plus de son genre et de son rôle narratif. Les personnages très bavards (ratio eleve) doivent recevoir une voix plus naturelle et soutenue, tandis que les personnages discrets peuvent avoir une voix plus aeree.
Contexte : Le mapping actuel ROLE_VOICE_MAP utilise uniquement le rôle et le genre. En pratique, un personnage secondaire avec beaucoup de repliques merite une voix plus expressive qu’un figurant silencieux.
- Étape 1 : Définir des seuils de speech_ratio (ex: < 0.05 = silencieux, 0.05-0.15 = modere, > 0.15 = bavard)
- Étape 2 : Créer un nouveau mapping qui combine rôle + bavardise pour choisir la voix
- Étape 3 : Appliquer le mapping aux personnages et comparer avec les recommandations originales
Indice : Les profils sont dans le dictionnaire profiles (cle: speech_ratio) Indice : Les voix bavardes pour les femmes : af_bella (chaleureuse) ; pour les hommes : am_michael (polyvalente)
# TODO etudiant : recommandation vocale personnalisee
# Etape 1 : Definir les seuils de bavardise
SILENT_THRESHOLD = None # TODO etudiant : seuil bas (float)
VERBOSE_THRESHOLD = None # TODO etudiant : seuil haut (float)
# Etape 2 : Fonction de recommandation amelioree
def recommend_voice_adaptive(profile, catalog):
"""Recommande une voix en fonction du role, genre ET speech_ratio."""
# TODO etudiant : combiner role, gender et speech_ratio
return None # TODO etudiant : dict avec voice, model, raison
# Etape 3 : Appliquer et comparer
# TODO etudiant : iterer sur profiles et appeler recommend_voice_adaptive
print("Exercice a completer")Exercice a completer
7. Synthese et export
Nous consolidons tous les résultats en structures exportables : JSON pour l’integration avec un pipeline audio, CSV pour l’analyse tabulaire.
Ces exports servent de base au pipeline audiobook complet (notebook 04-6).
Contrat P1 canonique v1
En plus des artefacts d’analyse (JSON consolidate + CSV), cette cellule exporte le contrat d’interface canonique consomme par le notebook suivant (04-9-Voice-Casting.ipynb), qui exige des noms et structures fixes :
| Fichier contrat | Forme | Contenu |
|---|---|---|
characters.json |
JSON {cid: {...}} |
{name, gender, role, utterance_count, first_utterance} par personnage — la forme exacte attendue par P2 |
utterances.csv |
CSV | Repliques individuelles (colonnes index, speaker, utterance_type, seg_index, text) |
voice_recommendations.csv |
CSV | Recommandations vocales aplaties (character_id, name, voice, model, raison) |
segments.csv |
CSV | Deja exporte ci-dessus — memes colonnes que le contrat |
p1_contract.json |
JSON | Manifeste : version du contrat, producteur, source (Gutenberg ID, TEXT_SOURCE), et nombre d’entrees attendu par fichier |
Le manifeste permet au consommateur de verifier le contrat : il lit p1_contract.json, affiche la version et les compteurs declares, puis exige les 4 fichiers en mode pipeline (echec clair si manquant) ou les remplace par le fallback embarque en mode demo (annonce comme tel). Le mapping personnages.csv -> characters.json (aplatissement : full_name -> name, utterances -> utterance_count, + first_utterance derivee de la premiere replique du personnage) est fait ici, cote producteur, pour que P2 n’ait aucune transformation a faire.
import csv
analysis_results = {
"metadata": {
"source": "Boule de Suif - Guy de Maupassant",
"gutenberg_id": GUTENBERG_ID,
"text_length": len(text),
"word_count": len(text.split()),
},
"segmentation": {
"total_segments": len(segments),
"type_distribution": dict(type_counts),
"segments": [asdict(s) for s in segments],
},
"characters": {
"detected": len(characters),
"profiles": profiles,
},
"utterances": {
"total": len(utterances),
"entries": [asdict(u) for u in utterances],
},
"voice_recommendations": VOICE_RECOMMENDATIONS,
}
json_path = OUTPUT_DIR / "analyse_boule_de_suif.json"
with open(json_path, "w", encoding="utf-8") as f:
json.dump(analysis_results, f, ensure_ascii=False, indent=2)
csv_seg_path = OUTPUT_DIR / "segments.csv"
with open(csv_seg_path, "w", newline="", encoding="utf-8") as f:
writer = csv.DictWriter(f, fieldnames=["index", "seg_type", "char_count", "word_count", "paragraph_idx", "text"])
writer.writeheader()
for s in segments:
writer.writerow(asdict(s))
csv_char_path = OUTPUT_DIR / "personnages.csv"
with open(csv_char_path, "w", newline="", encoding="utf-8") as f:
flat_profiles = []
for cid, p in profiles.items():
row = {"id": cid}
row.update(p)
row["seg_type_distribution"] = json.dumps(p["seg_type_distribution"])
flat_profiles.append(row)
writer = csv.DictWriter(f, fieldnames=list(flat_profiles[0].keys()))
writer.writeheader()
writer.writerows(flat_profiles)
csv_utt_path = OUTPUT_DIR / "repliques.csv"
with open(csv_utt_path, "w", newline="", encoding="utf-8") as f:
writer = csv.DictWriter(f, fieldnames=["index", "speaker", "utterance_type", "seg_index", "text"])
writer.writeheader()
for u in utterances:
writer.writerow(asdict(u))
# ---- Contrat P1 canonique v1 (consomme par 04-9-Voice-Casting) ----
# Le consommateur P2 (casting vocal) exige 4 artefacts aux noms et structures fixes,
# plus un manifeste qui atteste la version du contrat et le contenu attendu.
# Ces fichiers canoniques s'ajoutent aux artefacts d'analyse ci-dessus (meme contenu,
# noms et formes demandes par P2). Voir la section 7 du markdown pour le detail.
first_utt = {}
for u in utterances:
first_utt.setdefault(u.speaker, u.text)
characters_contract = {}
for cid, p in profiles.items():
characters_contract[cid] = {
"name": p["full_name"],
"gender": p["gender"],
"role": p["role"],
"utterance_count": p["utterances"],
"first_utterance": first_utt.get(p["full_name"], ""),
}
with open(OUTPUT_DIR / "characters.json", "w", encoding="utf-8") as f:
json.dump(characters_contract, f, ensure_ascii=False, indent=2)
with open(OUTPUT_DIR / "utterances.csv", "w", newline="", encoding="utf-8") as f:
writer = csv.DictWriter(f, fieldnames=["index", "speaker", "utterance_type", "seg_index", "text"])
writer.writeheader()
for u in utterances:
writer.writerow(asdict(u))
with open(OUTPUT_DIR / "voice_recommendations.csv", "w", newline="", encoding="utf-8") as f:
writer = csv.DictWriter(f, fieldnames=["character_id", "name", "voice", "model", "raison"])
writer.writeheader()
for cid, rec in VOICE_RECOMMENDATIONS.items():
writer.writerow({"character_id": cid, "name": profiles[cid]["full_name"], **rec})
P1_CONTRACT_VERSION = "1.0"
p1_contract = {
"version": P1_CONTRACT_VERSION,
"producer": "04-8-Lecture-Analytique.ipynb",
"consumer": "04-9-Voice-Casting.ipynb",
"source": {"gutenberg_id": GUTENBERG_ID, "title": "Boule de Suif",
"text_source": TEXT_SOURCE, "text_length": len(text)},
"files": {
"characters.json": len(characters_contract),
"segments.csv": len(segments),
"utterances.csv": len(utterances),
"voice_recommendations.csv": len(VOICE_RECOMMENDATIONS),
},
}
with open(OUTPUT_DIR / "p1_contract.json", "w", encoding="utf-8") as f:
json.dump(p1_contract, f, ensure_ascii=False, indent=2)
print(f"Contrat P1 canonique v{P1_CONTRACT_VERSION} exporte : characters.json "
f"({len(characters_contract)} pers.), utterances.csv ({len(utterances)} repl.), "
f"voice_recommendations.csv ({len(VOICE_RECOMMENDATIONS)} specs), p1_contract.json")
print()
print(f"Fichiers exportes dans {OUTPUT_DIR}/ :")
for p in sorted(OUTPUT_DIR.iterdir()):
size_kb = p.stat().st_size / 1024
print(f" {p.name:35s} {size_kb:8.1f} KB")Contrat P1 canonique v1.0 exporte : characters.json (9 pers.), utterances.csv (39 repl.), voice_recommendations.csv (9 specs), p1_contract.json
Fichiers exportes dans output_analytique/ :
analyse_boule_de_suif.json 158.6 KB
characters.json 1.7 KB
p1_contract.json 0.4 KB
personnages.csv 1.0 KB
repliques.csv 4.3 KB
segments.csv 91.7 KB
utterances.csv 4.3 KB
voice_recommendations.csv 0.9 KB
Lecture du resultat : synthese et export
La cellule ci-dessus orchestre la consolidation finale du pipeline d’analyse en huit artefacts exportes dans le dossier output_analytique/. Le JSON agrege analyse_boule_de_suif.json (158.6 KB) est le livrable principal : il contient les metadonnees de la source, les comptes de segmentation, les profils de personnages et les statistiques d’extraction des repliques, le tout structure pour etre consomme en aval par un moteur TTS ou un outil de visualisation. Quatre CSV thematiques offrent une lecture tabulaire fine : segments.csv (91.7 KB, 342 segments), repliques.csv (4.3 KB, 39 repliques), personnages.csv (1.0 KB, 9 personnages) et utterances.csv (4.3 KB, 39 repliques).
Cette cellule exporte aussi le contrat P1 canonique consomme par le notebook suivant (04-9-Voice-Casting.ipynb) : characters.json (9 personnages, forme {cid: {name, gender, role, utterance_count, first_utterance}}), utterances.csv (39 repliques), voice_recommendations.csv (9 specifications aplaties) et p1_contract.json — le manifeste qui declare la version du contrat, le producteur, la source (Gutenberg ID 10746, TEXT_SOURCE=local) et le nombre d’entrees attendu par fichier. Ce manifeste permet au consommateur de verifier le contrat avant de proceder : il lit p1_contract.json, compare les compteurs declares, puis exige les fichiers en mode pipeline ou bascule en mode demo (annonce comme tel) le cas echeant. La separation JSON/CSV est deliberee : le JSON porte la structure semantique complete, les CSV offrent un regard plat pour pandas ou un tableur.
8. Conclusion
Ce notebook a mis en place un pipeline d’analyse textuelle pour la production d’audiobooks, applique au texte integral (vendu dans le depot) de Boule de Suif :
| Etape | Methode | Sortie reelle |
|---|---|---|
| Chargement | Fichier vendored canonique (repli Gutenberg 10746 optionnel) | Texte brut 83 097 caract. (conte seul) |
| Segmentation | Guillemets francais + heuristiques (tiret --, verbe de parole) |
342 segments types |
| Detection personnages | Dictionnaire + sous-chaines d’alias | 9 profils (mentions brutes) |
| Extraction repliques | Split guillemets + attribution heuristique | 39 utterances |
| Voix TTS | Regles profil (genre + role) | 9 recommandations |
| Export | JSON + CSV + contrat P1 | 8 fichiers dans output_analytique/ |
Limites et pistes d’amelioration
- Comptage des personnages : les mentions sont remontees par sous-chaines d’alias, sans frontiere de mot —
comteest compte danscomtesse,loiseaudansmadame loiseau. Des frontieres de mots (\b) ou un modele NER (spaCy, CamemBERT) donneraient un comptage exact. - Segmentation des guillemets : les transcriptions de Project Gutenberg ne ferment pas toujours les guillemets (
« J'en fais autant.non suivi d’un»), ce qui fusionne parfois deux repliques en un seul segment quand une transition» -- «apparait. Une scission explicite sur» -- «separerait proprement les locuteurs successifs. - Attribution locuteur : les coreferences (il, elle) ne sont pas resolues — une replique sans nom adjacent retombe sur
Narrateurpar defaut (21/39). Un modele de coreference ameliorerait l’attribution. - Voix TTS : les recommandations sont basees sur des regles (genre + role) ; un modele pourrait les suggerer automatiquement en fonction des profils.
Pour aller plus loin
- 04-6-Audiobook-Pipeline : Pipeline complet de production audio
- 04-7-TTS-Voice-Benchmark : Comparaison des modeles TTS
- 03-Texte : Techniques avancees de traitement du texte