Files
InkFlow/CLAUDE.md
colgora ba1813c583 Voicebank : vraies voix françaises (CML-TTS) + pool anonyme + garde-fou Qwen3
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
2026-06-21 21:32:31 +02:00

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 : objet Settings persisté dans data/settings.json, éditable au runtime depuis l'UI. Contient aussi les prompts système Gemma (éditables). Le pipeline consulte get_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 :

  1. 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).
  2. Seeding : une incise nominale qui nomme un personnage fixe le locuteur avant l'appel LLM (corrige les ratés du petit modèle).
  3. Attribution LLM (attribute_speakers) : Gemma résout les répliques restantes par chunks, avec contexte narratif avant/après et confidence.
  4. Passe rétroactive (_refine_unknown_speakers) : re-résout les répliques inconnu/low via 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.jsonmê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), plus ch06.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 automatiquement libespeak-ng.dylib (homebrew) ; sinon exporter PHONEMIZER_ESPEAK_LIBRARY.
  • Modèle Gemma = goulot d'étranglement : gemma-3-4b-it-4bit par 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 = False par 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é.