Remplace la voicebank générée par Kokoro (timbre anglais sur français phonémisé -> accent que Qwen3 clonait) par 41 vraies voix FR issues de CML-TTS (livres audio studio) : 1 narrateur dédié, 18F/14M nommées, 4F/4M anonymes réservées. - scripts/import_voices.py : import multi-shards parquet, 1 clip/locuteur (le plus propre via levenshtein), genre estimé par F0 (YIN, anti-octave), filtre débit de parole (ref_text aligné sur l'audio). - VoiceEntry.anonymous + assign_voices : les figurants « anonyme (...) » tirent dans un pool réservé, jamais mélangé avec les voix nommées ; narrateur dédié (fr_narrator remplace fr_f_siwis). - dedup._anon_attrs : genre/âge déduits du nom anonyme (bon genre de voix). - tts/qwen3.py : garde-fou anti-dérive (rejette/réessaie les sorties en boucle ou coupées en estimant la durée plausible du chunk). Limite connue : Qwen3 ne sait pas synthétiser les fragments d'1-2 mots (incises, titres) -> trous ; à traiter (repli Kokoro ou fusion des incises). Inclut aussi du travail en cours antérieur (refacto backend LLM pluggable mlx/lmstudio, benchmark, ajustements frontend/API). Claude-Session: https://claude.ai/code/session_01XSVvcy1mfb4k1xDgib9vVU
11 KiB
CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
InkFlow transforme un EPUB en livre audio (1 dossier/livre, 1 MP3/chapitre, tags ID3 + cover), 100 % en local sur Mac Apple Silicon via MLX. Analyse de texte par Gemma (mlx-lm), synthèse vocale par backend pluggable Kokoro (rapide, previews/mono-narrateur) ou Qwen3-TTS (qualité + clonage, rendu final multi-voix). Optimisé pour le français.
Commandes
# Installation (Python >= 3.11, macOS arm64)
brew install ffmpeg espeak-ng # prérequis système
python3.13 -m venv .venv && source .venv/bin/activate
pip install -e backend # installe le package `inkflow`
python backend/scripts/setup_models.py # vérifie l'env + télécharge les modèles MLX
# Pipeline CLI (point d'entrée : `inkflow`, défini dans backend/inkflow/cli.py)
inkflow parse "samples/livre.epub" # EPUB -> data/<slug>/{book.json, chapters/}
inkflow analyze <slug> --chapter 5 # un chapitre ; sans --chapter = tous
inkflow analyze <slug> --force # ré-analyse même si l'artefact existe
inkflow cast <slug> --dedup # voicebank + auto-assignation des voix
inkflow pronounce <slug> # dictionnaire de prononciation (Gemma)
inkflow render <slug> 5 --backend kokoro # rendu rapide mono-narrateur
inkflow render <slug> 5 --backend qwen3 --no-mono # rendu final multi-voix
inkflow render <slug> 5 --max-paragraphs 10 # test rapide (tronque)
inkflow info <slug> # structure d'un livre parsé
inkflow serve # API + UI sur http://127.0.0.1:8000
# Sans `pip install -e`, lancer depuis backend/ : python -m inkflow.cli ...
# Tests (pytest ; lancer depuis backend/ pour résoudre l'import `inkflow`)
cd backend && python -m pytest # toute la suite
cd backend && python -m pytest tests/test_incises.py # un fichier
cd backend && python -m pytest tests/test_incises.py::test_inversion_au_milieu # un test
# Frontend (React + Vite + Tailwind)
cd frontend && npm install && npm run build # build -> dist/ (servi par `inkflow serve`)
cd frontend && npm run dev # dev sur :5173, proxy /api+/ws vers :8000
Le développement frontend nécessite inkflow serve (backend :8000) et npm run dev (UI :5173) en parallèle. En production, inkflow serve sert frontend/dist/ sur le même port que l'API.
Architecture
Pipeline orienté artefacts (reprenable)
Chaque étape lit l'artefact JSON de la précédente et écrit le sien dans data/<slug>/. Le contrat entre étapes = les modèles pydantic v2 de backend/inkflow/models.py (sérialisés tels quels). C'est ce qui rend le pipeline reprenable : l'existence d'un fichier signale qu'une étape est faite.
parse → book.json + chapters/chNN.json (epub/parser.py)
analyze → analysis/chNN.json + cast.json (analysis/segmenter.py)
cast → cast.json enrichi (voix) (casting/assign.py + voicebank.py)
pronounce → pronunciation.json (analysis/pronunciation.py)
render → output/<titre>/NN-....mp3 (pipeline/render.py + audio/postprocess.py)
store/artifacts.py centralise toute lecture/écriture de ces artefacts (load_*/save_*). Ne pas lire/écrire ces JSON ailleurs.
Deux niveaux de configuration (distinction importante)
config.py: constantes figées lues à l'import, surchargeables uniquement par variables d'environnement au démarrage (INKFLOW_*). Chemins, IDs de modèles MLX par défaut, params audio.settings.py: objetSettingspersisté dansdata/settings.json, éditable au runtime depuis l'UI. Contient aussi les prompts système Gemma (éditables). Le pipeline consulteget_settings()au moment de l'exécution.save_settings()invalide les caches modèles (get_backend.cache_clear(),analysis.llm.factory.reset_llm_cache()) pour appliquer les changements sans redémarrage.
Orchestration (UI temps réel)
pipeline/orchestrator.py expose un singleton orchestrator avec un unique worker thread (un Mac = une charge MLX à la fois) : les jobs sont enfilés et rendent la main immédiatement à l'API. L'état (ProjectState) est persisté dans data/<slug>/state.json et diffusé par WebSocket à chaque changement via un broadcaster injecté par la couche API (l'orchestrateur reste indépendant de FastAPI).
load_state() appelle _reconcile() : il réaligne l'état sur les artefacts présents sur disque, donc le travail fait en CLI (ou après redémarrage) est reflété dans l'UI sans le rejouer. Quand on ajoute une étape, penser à étendre _reconcile().
api/app.py : routes lourdes (analyze/cast/pronounce/render) → orchestrator.run_* (enfilées, renvoient {queued: True}) ; routes rapides (preview de voix) → threadpool ; lecture/écriture directe pour cast/pronunciation/settings. WebSocket sur /ws/{slug}.
Détection d'incises — déterministe et cast-aware (cœur de l'attribution)
analysis/segmenter.py est le module le plus subtil. La segmentation narration/dialogue est déterministe (un paragraphe commençant par cadratin — est une réplique). L'attribution du locuteur est hybride :
- Détection d'incises déterministe (
detect_incises) : deux passes regex — inversion verbe-pronom (dit-il) et nominale consciente du casting (compatit Holden,informa le soldat). Les incises sont des bornes (offsets) annotées sur la réplique persistée entière, non destructif ; le découpage en voix narrateur/personnage se fait au rendu (iter_incise_pieces). - Seeding : une incise nominale qui nomme un personnage fixe le locuteur avant l'appel LLM (corrige les ratés du petit modèle).
- Attribution LLM (
attribute_speakers) : Gemma résout les répliques restantes par chunks, avec contexte narratif avant/après et confidence. - Passe rétroactive (
_refine_unknown_speakers) : re-résout les répliquesinconnu/lowvia l'alternance des tours. Coût nul s'il ne reste aucun doute.
Cette logique est pure et testée (tests/test_incises.py, 30+ cas sans Gemma). Toute modification des regexes/verbes/rôles doit garder ces tests verts. Préférer rater une incise qu'en inventer une (les listes _SPEECH_VERBS/_ROLE_NOUNS sont curées en ce sens).
TTS pluggable
tts/base.py définit TTSBackend.synthesize(text, VoiceSpec) -> (audio mono float32, sample_rate) et VoiceSpec (preset pour Kokoro, ref_audio/ref_text pour le clonage Qwen3). tts/factory.get_backend(name) est lru_cache par nom (pas par id de modèle — d'où l'invalidation explicite dans settings). pipeline/render.py construit des RenderUnit (mono ou multi-voix), où glued_to_prev réduit le silence pour les incises rattachées à la réplique précédente.
LLM d'analyse pluggable
analysis/llm/ suit le même pattern que le TTS. La façade client.LLM (anciennement Gemma) expose generate/generate_json consommés par tout le pipeline ; elle porte toute la logique agnostique du moteur (calcul des params depuis Settings, retrait de la pensée des modèles à raisonnement, extraction JSON tolérante — helpers dans _text.py, testés purs dans tests/test_gemma_reasoning.py). Sous elle, base.LLMBackend.complete(messages, ...) -> str (texte brut) a deux implémentations : mlx_backend (mlx-lm, défaut) et lmstudio_backend (API OpenAI locale de LM Studio, sert GGUF et MLX). Sélection par nom via factory.get_llm_backend(backend, model_ref) (lru_cache, reset_llm_cache() pour invalider). Backend choisi dans settings.gemma_backend (mlx/lmstudio), surchargeable par --backend/--model sur les commandes CLI analyze/pronounce/cast/benchmark. LM Studio doit tourner avec son serveur local actif (onglet Developer).
Qui possède la config de génération ? Les réglages gemma_temperature/gemma_max_tokens/gemma_reasoning* pilotent le backend MLX (seule source de config pour mlx-lm). Pour LM Studio, c'est la config du modèle dans LM Studio qui prime : par défaut (settings.lmstudio_defer_config=True) le backend n'impose ni temperature ni max_tokens dans la requête — imposer max_tokens tronquait la réponse des modèles à raisonnement (pensée non terminée → « aucun JSON »). Le contexte se règle aussi côté LM Studio (au chargement : lms load <m> --context-length N ou l'UI) — InkFlow ne peut pas le porter, d'où l'erreur « context length » si le modèle est JIT-chargé avec un contexte trop court pour un chapitre. Mettre lmstudio_defer_config=False pour réimposer les réglages InkFlow (benchmarks reproductibles). LM Studio sépare déjà la pensée (reasoning_content) de la réponse (content) : le backend ne renvoie que content.
Fichiers de référence (vérité terrain pour l'attribution)
data/<slug>/reference/chNN.json contient des versions corrigées à la main de la sortie d'analyse analysis/chNN.json — même schéma ChapterAnalysis (index, title, segments avec type/text/speaker/incises). Ce sont des fixtures de vérité terrain servant à juger la qualité de la segmentation, des incises et de l'attribution des locuteurs : on compare la sortie réelle du pipeline à la référence.
- Fixture canonique :
data/la-colere-de-tiamat/reference/ch05.json(PROLOGUE - HOLDEN), plusch06.json. - Aucun code ne les charge : ce sont des références manuelles, distinctes des tests unitaires purs de
tests/test_incises.py. Elles vérifient le comportement bout-en-bout avec Gemma, que les tests purs ne couvrent pas. - Sous
data/→ git-ignored : présents localement, non versionnés. Les régénérer/corriger à la main quand on retravaille la logique d'attribution, puis comparer manuellement àanalyze --force.
Pièges d'environnement
- espeak-ng : Kokoro en français en dépend via phonemizer.
config.setup_espeak()localise automatiquementlibespeak-ng.dylib(homebrew) ; sinon exporterPHONEMIZER_ESPEAK_LIBRARY. - Modèle Gemma = goulot d'étranglement :
gemma-3-4b-it-4bitpar défaut. C'est le petit modèle qui rate des attributions — d'où le seeding déterministe et la dedup heuristique-first. dedup_use_gemma = Falsepar défaut : la dedup du casting est heuristique (sûre) ; la passe Gemma (opt-in--llm) rattache les variantes non évidentes mais produit des fusions erronées avec un petit modèle local.- Encodage MP3 via ffmpeg CLI (pas pydub pour l'encodage final) ; tags/cover via mutagen.
- Les répertoires
data/,output/,samples/,node_modules/sont git-ignored.voicebank/(clips de référence) est versionné.