From 7d54c64bfe953e5282b6edb3df3a69a1f1d31d69 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 25 Jul 2026 12:47:24 +0000 Subject: [PATCH 01/98] Add REST API server, standalone web interface, and missing pieces - server.py: FastAPI REST API with /api/tts (voice design, controllable cloning, ultimate cloning), OpenAI-compatible /v1/audio/speech endpoint, /api/health, thread-safe lazy model loading, and wav/flac/ogg output - web/index.html: standalone French/English web interface (VoxCPM Studio) with the three generation modes, advanced settings (CFG, diffusion steps, seed), drag-and-drop reference audio, session history, and WAV download - app.py: make the funasr import optional so the Gradio demo starts without it (ASR auto-transcription degrades gracefully with a clear message) - examples/input.txt: add the batch-mode sample file referenced by README - pyproject.toml: add [server] optional dependency group - Dockerfile: GPU-ready image serving the API and web UI on port 8000 - docs/GUIDE_FR.md: full usage guide in French (web UI, REST API, CLI, Python API, Docker, troubleshooting) - README.md: document the new web interface and REST API - tests/test_server.py: 13 endpoint tests using a stub engine (no torch or model weights required) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01MeWfqpDWJs9veHKHRzQqG9 --- Dockerfile | 40 +++ README.md | 24 ++ app.py | 18 +- docs/GUIDE_FR.md | 213 ++++++++++++++ examples/input.txt | 5 + pyproject.toml | 5 + server.py | 392 ++++++++++++++++++++++++++ tests/test_server.py | 168 ++++++++++++ web/index.html | 640 +++++++++++++++++++++++++++++++++++++++++++ 9 files changed, 1501 insertions(+), 4 deletions(-) create mode 100644 Dockerfile create mode 100644 docs/GUIDE_FR.md create mode 100644 examples/input.txt create mode 100644 server.py create mode 100644 tests/test_server.py create mode 100644 web/index.html diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..4faa8ae1 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,40 @@ +# VoxCPM API server image (GPU-ready, also runs on CPU) +# +# Build: +# docker build -t voxcpm-server . +# +# Run (GPU): +# docker run --gpus all -p 8000:8000 -v voxcpm-cache:/root/.cache voxcpm-server +# +# Run (CPU only — slow, for testing): +# docker run -p 8000:8000 -e VOXCPM_DEVICE=cpu -v voxcpm-cache:/root/.cache voxcpm-server +# +# Then open http://localhost:8000 + +FROM pytorch/pytorch:2.5.1-cuda12.4-cudnn9-runtime + +ENV PYTHONUNBUFFERED=1 \ + TOKENIZERS_PARALLELISM=false \ + HF_HUB_ENABLE_HF_TRANSFER=0 + +RUN apt-get update \ + && apt-get install -y --no-install-recommends git libsndfile1 ffmpeg \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +COPY pyproject.toml README.md LICENSE ./ +COPY src ./src +COPY server.py app.py ./ +COPY web ./web +COPY assets ./assets + +# SETUPTOOLS_SCM_PRETEND_VERSION: .git is not copied into the image, so +# setuptools_scm cannot derive the version from tags. +RUN SETUPTOOLS_SCM_PRETEND_VERSION=0.0.0 pip install --no-cache-dir -e ".[server]" + +EXPOSE 8000 + +# Model weights are downloaded on first request and cached in /root/.cache — +# mount a volume there to persist them across container restarts. +CMD ["python", "server.py", "--host", "0.0.0.0", "--port", "8000"] diff --git a/README.md b/README.md index 8b201997..af20b44e 100644 --- a/README.md +++ b/README.md @@ -258,6 +258,30 @@ voxcpm --help ### Web Demo +Two web interfaces are available: + +**VoxCPM Studio — lightweight web UI + REST API** (no funasr required): + +```bash +pip install -e ".[server]" +python server.py --port 8000 # then open in browser: http://localhost:8000 +``` + +A modern standalone interface (English/French) covering all three generation modes, +backed by a REST API with an **OpenAI-compatible** `/v1/audio/speech` endpoint +(interactive docs at `http://localhost:8000/docs`). See the +[Guide en français](docs/GUIDE_FR.md) for a full walkthrough. + +```bash +# REST API example +curl -X POST http://localhost:8000/api/tts \ + -F "text=Hello from the VoxCPM API!" \ + -F "control=warm female voice" \ + -o out.wav +``` + +**Gradio demo** (with automatic reference-audio transcription via funasr): + ```bash python app.py --port 8808 # then open in browser: http://localhost:8808 ``` diff --git a/app.py b/app.py index 15448281..5317e0fc 100644 --- a/app.py +++ b/app.py @@ -5,10 +5,14 @@ import random import numpy as np import gradio as gr -from typing import Optional, Tuple -from funasr import AutoModel +from typing import Any, Optional, Tuple from pathlib import Path +try: + from funasr import AutoModel +except ImportError: # funasr is optional — only needed for auto-transcription (ASR) + AutoModel = None + os.environ["TOKENIZERS_PARALLELISM"] = "false" import voxcpm @@ -232,7 +236,7 @@ def __init__(self, model_id: str = "openbmb/VoxCPM2", device: str = "auto") -> N self.asr_model_id = "iic/SenseVoiceSmall" self.asr_device = "cuda:0" if self.device.startswith("cuda") else "cpu" - self.asr_model: Optional[AutoModel] = None + self.asr_model: Optional[Any] = None self.voxcpm_model: Optional[voxcpm.VoxCPM] = None self._model_id = model_id @@ -249,7 +253,13 @@ def get_or_load_voxcpm(self) -> voxcpm.VoxCPM: logger.info("Model loaded successfully.") return self.voxcpm_model - def get_or_load_asr_model(self) -> AutoModel: + def get_or_load_asr_model(self) -> "AutoModel": + if AutoModel is None: + raise RuntimeError( + "funasr is not installed — automatic transcription of the reference audio " + "is unavailable. Install it with `pip install funasr`, or type the " + "transcript manually in the prompt text field." + ) if self.asr_model is not None: return self.asr_model logger.info(f"Loading ASR model: {self.asr_model_id} on device: {self.asr_device}") diff --git a/docs/GUIDE_FR.md b/docs/GUIDE_FR.md new file mode 100644 index 00000000..3f2bc593 --- /dev/null +++ b/docs/GUIDE_FR.md @@ -0,0 +1,213 @@ +# Guide d'utilisation VoxCPM (Français) + +VoxCPM est un système de synthèse vocale (TTS) **sans tokenizer** qui génère une parole +très naturelle dans **30 langues** (dont le français). Ce guide couvre l'installation et +les différentes façons d'utiliser l'application. + +## Sommaire + +- [Prérequis](#prérequis) +- [Installation](#installation) +- [Interface web (recommandé)](#interface-web-recommandé) +- [Les trois modes de génération](#les-trois-modes-de-génération) +- [API REST](#api-rest) +- [Ligne de commande (CLI)](#ligne-de-commande-cli) +- [API Python](#api-python) +- [Docker](#docker) +- [Conseils et dépannage](#conseils-et-dépannage) + +--- + +## Prérequis + +| Composant | Version | +|-----------|---------| +| Python | ≥ 3.10 et < 3.13 | +| PyTorch | ≥ 2.5.0 | +| GPU | NVIDIA avec CUDA ≥ 12.0 (~8 Go de VRAM pour VoxCPM2) — fonctionne aussi sur CPU ou Apple Silicon (MPS), plus lentement | + +Le modèle **VoxCPM2** (~2B paramètres) est téléchargé automatiquement depuis +Hugging Face au premier lancement (plusieurs Go — prévoyez du temps et de l'espace disque). + +## Installation + +```bash +# Depuis PyPI +pip install voxcpm + +# Ou depuis ce dépôt (mode développement, avec le serveur web) +git clone https://github.com/eddyosas008/voxcpm.git +cd voxcpm +pip install -e ".[server]" +``` + +## Interface web (recommandé) + +Le dépôt fournit **deux interfaces web** : + +### 1. VoxCPM Studio (interface légère + API REST) + +```bash +pip install -e ".[server]" +python server.py --port 8000 +# puis ouvrez http://localhost:8000 +``` + +Interface moderne en **français/anglais** avec les trois modes de génération, +les réglages avancés (CFG, étapes de diffusion, seed), l'historique de session +et le téléchargement des fichiers WAV. Le serveur expose aussi une API REST +documentée sur `http://localhost:8000/docs`. + +Options utiles : + +```bash +python server.py --device cpu # forcer le CPU +python server.py --preload # charger le modèle au démarrage +python server.py --model-id ./chemin/local/VoxCPM2 # modèle local +python server.py --host 0.0.0.0 # exposer sur le réseau (attention : pas d'authentification) +``` + +### 2. Démo Gradio (interface officielle) + +```bash +python app.py --port 8808 +# puis ouvrez http://localhost:8808 +``` + +Cette interface propose en plus la **transcription automatique** de l'audio de +référence (nécessite `pip install funasr`). + +## Les trois modes de génération + +| Mode | Ce qu'il faut fournir | Résultat | +|------|----------------------|----------| +| 🎨 **Création de voix** (Voice Design) | Une description de la voix (« voix féminine jeune, douce, débit posé ») + le texte | Une voix inédite créée à partir de la description | +| 🎛️ **Clonage contrôlable** | Un extrait audio de référence (5–15 s) + le texte, avec en option une consigne de style | La voix du clip est clonée ; le style peut être ajusté | +| 🎙️ **Clonage ultime** | L'extrait audio **et** sa transcription exacte + le texte | Reproduction fidèle de chaque nuance vocale (timbre, rythme, émotion) | + +**Conseils pour la description de voix** : indiquez le genre, l'âge, le ton, l'émotion +et le débit. Fonctionne en français, anglais, chinois… Exemples : + +- *« Voix masculine grave et posée, ton de narrateur de documentaire »* +- *« Jeune femme enjouée, débit rapide, ton enthousiaste »* + +## API REST + +Le serveur (`python server.py`) expose : + +### `POST /api/tts` — génération complète (multipart/form-data) + +```bash +# Création de voix +curl -X POST http://localhost:8000/api/tts \ + -F "text=Bonjour, ceci est une démonstration de VoxCPM en français." \ + -F "control=Voix féminine chaleureuse, débit naturel" \ + -F "seed=42" \ + -o sortie.wav + +# Clonage à partir d'un extrait audio +curl -X POST http://localhost:8000/api/tts \ + -F "text=Cette phrase sera prononcée avec la voix clonée." \ + -F "reference_audio=@ma_voix.wav" \ + -o clone.wav + +# Clonage ultime (audio + transcription) +curl -X POST http://localhost:8000/api/tts \ + -F "text=Reproduction fidèle de la voix." \ + -F "prompt_audio=@ma_voix.wav" \ + -F "prompt_text=Transcription exacte de l'extrait audio." \ + -F "reference_audio=@ma_voix.wav" \ + -o clone_ultime.wav +``` + +Paramètres : `cfg_value` (1.0–3.0, défaut 2.0), `inference_timesteps` (4–30, défaut 10), +`seed`, `normalize`, `denoise`, `response_format` (`wav`, `flac`, `ogg`). + +### `POST /v1/audio/speech` — compatible OpenAI + +```python +from openai import OpenAI + +client = OpenAI(base_url="http://localhost:8000/v1", api_key="non-requis") +response = client.audio.speech.create( + model="voxcpm", + voice="voix féminine douce et posée", # description libre de la voix + input="Bonjour le monde !", + response_format="wav", +) +response.write_to_file("sortie.wav") +``` + +### `GET /api/health` — état du serveur et du modèle + +## Ligne de commande (CLI) + +```bash +# Création de voix +voxcpm design --text "Bonjour tout le monde" \ + --control "voix masculine chaleureuse" --output sortie.wav + +# Clonage +voxcpm clone --text "Texte à prononcer" \ + --reference-audio ma_voix.wav --output clone.wav + +# Clonage ultime +voxcpm clone --text "Texte à prononcer" \ + --prompt-audio ma_voix.wav --prompt-text "transcription de l'extrait" \ + --reference-audio ma_voix.wav --output clone.wav + +# Traitement par lots (une ligne du fichier = un fichier audio) +voxcpm batch --input examples/input.txt --output-dir sorties/ +``` + +## API Python + +```python +from voxcpm import VoxCPM +import soundfile as sf + +model = VoxCPM.from_pretrained("openbmb/VoxCPM2", load_denoiser=False) + +wav = model.generate( + text="(voix féminine douce)Bonjour, bienvenue dans VoxCPM !", + cfg_value=2.0, + inference_timesteps=10, + seed=42, +) +sf.write("demo.wav", wav, model.tts_model.sample_rate) +``` + +Streaming : + +```python +for chunk in model.generate_streaming(text="Synthèse en continu…"): + ... # traiter chaque morceau d'audio au fil de l'eau +``` + +## Docker + +```bash +docker build -t voxcpm-server . +docker run --gpus all -p 8000:8000 -v voxcpm-cache:/root/.cache voxcpm-server +# puis ouvrez http://localhost:8000 +``` + +Le volume `voxcpm-cache` conserve les poids du modèle entre les redémarrages. + +## Conseils et dépannage + +- **Le premier lancement est long** : les poids du modèle (~plusieurs Go) sont téléchargés, + puis le modèle est compilé (`torch.compile`). Les requêtes suivantes sont rapides. +- **Mémoire insuffisante** : utilisez `--no-optimize` pour désactiver la compilation, + ou le modèle plus petit `--model-id openbmb/VoxCPM1.5`. +- **Résultat instable en création de voix** : c'est connu — relancez la génération + 1 à 3 fois (avec un seed différent) pour obtenir la voix souhaitée. +- **Qualité du clonage** : utilisez un extrait propre de 5 à 15 secondes, sans musique + de fond ; activez le **débruitage** si l'enregistrement est bruité. +- **Apple Silicon** : `--device mps` (ou `auto`). +- **Éthique** : le clonage de voix ne doit jamais servir à l'usurpation d'identité, + la fraude ou la désinformation. Signalez clairement tout contenu généré par IA. + +--- + +Documentation complète (en anglais) : [voxcpm.readthedocs.io](https://voxcpm.readthedocs.io/en/latest/) diff --git a/examples/input.txt b/examples/input.txt new file mode 100644 index 00000000..61c3d843 --- /dev/null +++ b/examples/input.txt @@ -0,0 +1,5 @@ +VoxCPM is a tokenizer-free text-to-speech system that generates highly natural speech. +Welcome to the VoxCPM batch processing demo. Each line of this file becomes one audio file. +The quick brown fox jumps over the lazy dog. +Bonjour et bienvenue ! Ceci est une démonstration de synthèse vocale en français. +今天天气真不错,我们一起去公园散步吧。 diff --git a/pyproject.toml b/pyproject.toml index 95659faa..4c0d38b1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -62,6 +62,11 @@ dev = [ timestamps = [ "stable-ts>=2.19.1", ] +server = [ + "fastapi>=0.110", + "uvicorn[standard]>=0.27", + "python-multipart>=0.0.9", +] [project.scripts] voxcpm = "voxcpm.cli:main" diff --git a/server.py b/server.py new file mode 100644 index 00000000..cdbca56a --- /dev/null +++ b/server.py @@ -0,0 +1,392 @@ +#!/usr/bin/env python3 +"""VoxCPM REST API server. + +A lightweight FastAPI server exposing VoxCPM speech generation over HTTP, +plus a standalone web interface (see ``web/index.html``). + +Endpoints +--------- +- ``GET /`` → web interface +- ``GET /api/health`` → server / model status +- ``POST /api/tts`` → speech generation (multipart form, supports file upload) +- ``POST /v1/audio/speech`` → OpenAI-compatible text-to-speech endpoint (JSON) + +Usage +----- + pip install -e ".[server]" + python server.py --port 8000 + # then open http://localhost:8000 + +The model is loaded lazily on the first request by default; pass ``--preload`` +to load it at startup instead. +""" + +import argparse +import io +import logging +import os +import sys +import tempfile +import threading +from pathlib import Path +from typing import Optional + +import numpy as np +from fastapi import FastAPI, File, Form, HTTPException, UploadFile +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import FileResponse, JSONResponse, Response +from pydantic import BaseModel, Field + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(levelname)s - %(message)s", + handlers=[logging.StreamHandler(sys.stdout)], +) +logger = logging.getLogger("voxcpm.server") + +WEB_DIR = Path(__file__).resolve().parent / "web" + +SUPPORTED_FORMATS = {"wav", "flac", "ogg"} + + +# --------------------------------------------------------------------------- +# Engine wrapper +# --------------------------------------------------------------------------- + + +class TTSEngine: + """Thread-safe lazy wrapper around the VoxCPM model.""" + + def __init__( + self, + model_id: str = "openbmb/VoxCPM2", + device: str = "auto", + load_denoiser: bool = True, + optimize: bool = True, + ) -> None: + self.model_id = model_id + self.device = device + self.load_denoiser = load_denoiser + self.optimize = optimize + self._model = None + self._load_lock = threading.Lock() + self._generate_lock = threading.Lock() + + @property + def loaded(self) -> bool: + return self._model is not None + + def load(self): + if self._model is None: + with self._load_lock: + if self._model is None: + import voxcpm + + logger.info("Loading VoxCPM model: %s (device=%s)", self.model_id, self.device) + self._model = voxcpm.VoxCPM.from_pretrained( + self.model_id, + load_denoiser=self.load_denoiser, + device=None if self.device == "auto" else self.device, + optimize=self.optimize, + ) + logger.info("Model loaded (sample rate: %d Hz)", self.sample_rate) + return self._model + + @property + def sample_rate(self) -> int: + if self._model is None: + raise RuntimeError("Model not loaded yet") + return self._model.tts_model.sample_rate + + def generate(self, **kwargs) -> np.ndarray: + model = self.load() + # The underlying model is not thread-safe; serialize generation. + with self._generate_lock: + return model.generate(**kwargs) + + +engine: Optional[TTSEngine] = None + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def build_final_text(text: str, control: Optional[str]) -> str: + """Prefix the text with a ``(control instruction)`` if provided. + + Parentheses are stripped from the control text to avoid breaking the + ``(control)text`` prompt format expected by the model. + """ + control = (control or "").strip() + control = control.replace("(", "").replace(")", "").replace("(", "").replace(")", "").strip() + return f"({control}){text}" if control else text + + +def encode_audio(wav: np.ndarray, sample_rate: int, fmt: str) -> bytes: + import soundfile as sf + + fmt = (fmt or "wav").lower() + if fmt not in SUPPORTED_FORMATS: + raise HTTPException( + status_code=400, + detail=f"Unsupported response_format '{fmt}'. Supported: {sorted(SUPPORTED_FORMATS)}", + ) + buf = io.BytesIO() + sf.write(buf, wav, sample_rate, format=fmt.upper()) + return buf.getvalue() + + +MEDIA_TYPES = {"wav": "audio/wav", "flac": "audio/flac", "ogg": "audio/ogg"} + + +async def save_upload(upload: Optional[UploadFile], temp_files: list) -> Optional[str]: + if upload is None or not upload.filename: + return None + suffix = Path(upload.filename).suffix or ".wav" + with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp: + tmp.write(await upload.read()) + temp_files.append(tmp.name) + return tmp.name + + +def cleanup(temp_files: list) -> None: + for path in temp_files: + try: + os.unlink(path) + except OSError: + pass + + +def run_generation( + *, + text: str, + control: Optional[str] = None, + reference_wav_path: Optional[str] = None, + prompt_wav_path: Optional[str] = None, + prompt_text: Optional[str] = None, + cfg_value: float = 2.0, + inference_timesteps: int = 10, + normalize: bool = False, + denoise: bool = False, + seed: Optional[int] = None, + response_format: str = "wav", +) -> Response: + text = (text or "").strip() + if not text: + raise HTTPException(status_code=400, detail="'text' must be a non-empty string") + if not (0.1 <= cfg_value <= 10.0): + raise HTTPException(status_code=400, detail="'cfg_value' must be between 0.1 and 10.0") + if not (1 <= inference_timesteps <= 100): + raise HTTPException(status_code=400, detail="'inference_timesteps' must be between 1 and 100") + if prompt_wav_path and not (prompt_text or "").strip(): + raise HTTPException(status_code=400, detail="'prompt_audio' requires 'prompt_text'") + if (prompt_text or "").strip() and not prompt_wav_path: + raise HTTPException(status_code=400, detail="'prompt_text' requires 'prompt_audio'") + + final_text = build_final_text(text, control) + + try: + wav = engine.generate( + text=final_text, + reference_wav_path=reference_wav_path, + prompt_wav_path=prompt_wav_path, + prompt_text=(prompt_text or "").strip() or None, + cfg_value=float(cfg_value), + inference_timesteps=int(inference_timesteps), + normalize=normalize, + denoise=denoise and (reference_wav_path is not None or prompt_wav_path is not None), + seed=seed, + ) + except HTTPException: + raise + except (ValueError, FileNotFoundError) as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + except Exception as exc: # noqa: BLE001 — surface model errors as 500s + logger.exception("Generation failed") + raise HTTPException(status_code=500, detail=f"Generation failed: {exc}") from exc + + audio_bytes = encode_audio(wav, engine.sample_rate, response_format) + return Response( + content=audio_bytes, + media_type=MEDIA_TYPES[response_format], + headers={ + "X-Sample-Rate": str(engine.sample_rate), + "X-Duration-Seconds": f"{len(wav) / engine.sample_rate:.2f}", + "Content-Disposition": f'inline; filename="voxcpm_output.{response_format}"', + }, + ) + + +# --------------------------------------------------------------------------- +# App & routes +# --------------------------------------------------------------------------- + +app = FastAPI( + title="VoxCPM API", + description="REST API for VoxCPM tokenizer-free text-to-speech: voice design, " + "controllable cloning, and ultimate cloning.", + version="1.0.0", +) + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_methods=["*"], + allow_headers=["*"], +) + + +@app.get("/", include_in_schema=False) +def index(): + index_path = WEB_DIR / "index.html" + if index_path.exists(): + return FileResponse(index_path) + return JSONResponse({"message": "VoxCPM API is running. See /docs for the API reference."}) + + +@app.get("/api/health") +def health(): + return { + "status": "ok", + "model_id": engine.model_id if engine else None, + "model_loaded": engine.loaded if engine else False, + "device": engine.device if engine else None, + "sample_rate": engine.sample_rate if engine and engine.loaded else None, + } + + +@app.post("/api/tts") +async def tts( + text: str = Form(..., description="Text to synthesize"), + control: Optional[str] = Form(None, description="Voice/style description, e.g. 'young female voice, warm'"), + prompt_text: Optional[str] = Form(None, description="Transcript of the prompt audio (ultimate cloning)"), + cfg_value: float = Form(2.0, description="CFG guidance scale (1.0–3.0 recommended)"), + inference_timesteps: int = Form(10, description="Diffusion steps (4–30 recommended)"), + normalize: bool = Form(False, description="Normalize numbers/dates/abbreviations before synthesis"), + denoise: bool = Form(False, description="Denoise the reference/prompt audio before cloning"), + seed: Optional[int] = Form(None, description="Random seed for reproducible generation"), + response_format: str = Form("wav", description="Audio format: wav, flac or ogg"), + reference_audio: Optional[UploadFile] = File(None, description="Reference audio for voice cloning"), + prompt_audio: Optional[UploadFile] = File(None, description="Prompt audio for ultimate cloning (with transcript)"), +): + """Generate speech. Three modes: + + - **Voice design**: only `text` (+ optional `control`) — creates a new voice from the description. + - **Controllable cloning**: `text` + `reference_audio` (+ optional `control`). + - **Ultimate cloning**: `text` + `prompt_audio` + `prompt_text` (+ optional `reference_audio`). + """ + temp_files: list = [] + try: + reference_path = await save_upload(reference_audio, temp_files) + prompt_path = await save_upload(prompt_audio, temp_files) + return run_generation( + text=text, + control=control, + reference_wav_path=reference_path, + prompt_wav_path=prompt_path, + prompt_text=prompt_text, + cfg_value=cfg_value, + inference_timesteps=inference_timesteps, + normalize=normalize, + denoise=denoise, + seed=seed, + response_format=response_format, + ) + finally: + cleanup(temp_files) + + +class SpeechRequest(BaseModel): + """OpenAI-compatible /v1/audio/speech request body.""" + + model: str = Field(default="voxcpm", description="Ignored — the server's loaded model is used") + input: str = Field(..., description="Text to synthesize") + voice: str = Field( + default="", + description="Voice description used as a VoxCPM control instruction " + "(e.g. 'a calm mature male voice'). 'default' or empty for none.", + ) + response_format: str = Field(default="wav", description="wav, flac or ogg") + speed: Optional[float] = Field(default=None, description="Unsupported — included for API compatibility") + cfg_value: float = Field(default=2.0, description="VoxCPM extension: CFG guidance scale") + inference_timesteps: int = Field(default=10, description="VoxCPM extension: diffusion steps") + seed: Optional[int] = Field(default=None, description="VoxCPM extension: random seed") + + +@app.post("/v1/audio/speech") +def openai_speech(req: SpeechRequest): + """OpenAI-compatible text-to-speech endpoint. + + Works with any OpenAI client: + + client.audio.speech.create(model="voxcpm", voice="a warm female voice", + input="Hello!", response_format="wav") + """ + voice = req.voice.strip() + control = "" if voice.lower() in {"", "default", "alloy"} else voice + return run_generation( + text=req.input, + control=control, + cfg_value=req.cfg_value, + inference_timesteps=req.inference_timesteps, + seed=req.seed, + response_format=req.response_format, + ) + + +# --------------------------------------------------------------------------- +# Entrypoint +# --------------------------------------------------------------------------- + + +def create_engine_from_args(args) -> TTSEngine: + return TTSEngine( + model_id=args.model_id, + device=args.device, + load_denoiser=not args.no_denoiser, + optimize=not args.no_optimize, + ) + + +def main(): + global engine + + parser = argparse.ArgumentParser(description="VoxCPM REST API server") + parser.add_argument( + "--model-id", + type=str, + default=os.environ.get("VOXCPM_MODEL_ID", "openbmb/VoxCPM2"), + help="Local path or HuggingFace repo id (default: openbmb/VoxCPM2, env: VOXCPM_MODEL_ID)", + ) + parser.add_argument( + "--device", + type=str, + default=os.environ.get("VOXCPM_DEVICE", "auto"), + help="Runtime device: auto, cpu, mps, cuda, or cuda:N (default: auto, env: VOXCPM_DEVICE)", + ) + parser.add_argument( + "--host", + type=str, + default="127.0.0.1", + help="Bind address. Use 0.0.0.0 to expose the unauthenticated API to the network (default: 127.0.0.1)", + ) + parser.add_argument("--port", type=int, default=8000, help="Server port (default: 8000)") + parser.add_argument("--no-denoiser", action="store_true", help="Disable the ZipEnhancer denoiser") + parser.add_argument("--no-optimize", action="store_true", help="Disable torch.compile optimization") + parser.add_argument("--preload", action="store_true", help="Load the model at startup instead of on first request") + args = parser.parse_args() + + engine = create_engine_from_args(args) + if args.preload: + engine.load() + + import uvicorn + + logger.info("Starting VoxCPM API server at http://%s:%d", args.host, args.port) + uvicorn.run(app, host=args.host, port=args.port) + + +if __name__ == "__main__": + main() diff --git a/tests/test_server.py b/tests/test_server.py new file mode 100644 index 00000000..59a44312 --- /dev/null +++ b/tests/test_server.py @@ -0,0 +1,168 @@ +"""Tests for the FastAPI REST server (server.py). + +The VoxCPM model is replaced by a stub engine so these tests run without +torch or model weights. +""" + +from __future__ import annotations + +import importlib.util +import io +import sys +from pathlib import Path + +import numpy as np +import pytest +import soundfile as sf + +ROOT = Path(__file__).resolve().parents[1] +SERVER_PATH = ROOT / "server.py" + +spec = importlib.util.spec_from_file_location("voxcpm_server", SERVER_PATH) +server = importlib.util.module_from_spec(spec) +sys.modules["voxcpm_server"] = server +spec.loader.exec_module(server) + +from fastapi.testclient import TestClient # noqa: E402 + + +class StubEngine: + """Mimics server.TTSEngine without loading any model.""" + + model_id = "stub/VoxCPM2" + device = "cpu" + sample_rate = 16000 + + def __init__(self): + self.loaded = True + self.last_kwargs = None + + def generate(self, **kwargs): + self.last_kwargs = kwargs + return np.zeros(self.sample_rate, dtype=np.float32) # 1 second of silence + + +@pytest.fixture() +def client(monkeypatch): + stub = StubEngine() + monkeypatch.setattr(server, "engine", stub) + with TestClient(server.app) as c: + c.stub = stub + yield c + + +def _make_wav_bytes(duration_s: float = 0.5, sr: int = 16000) -> bytes: + buf = io.BytesIO() + sf.write(buf, np.zeros(int(duration_s * sr), dtype=np.float32), sr, format="WAV") + return buf.getvalue() + + +def test_health(client): + res = client.get("/api/health") + assert res.status_code == 200 + data = res.json() + assert data["status"] == "ok" + assert data["model_loaded"] is True + assert data["sample_rate"] == 16000 + + +def test_tts_basic(client): + res = client.post("/api/tts", data={"text": "Hello world"}) + assert res.status_code == 200 + assert res.headers["content-type"] == "audio/wav" + assert res.headers["X-Sample-Rate"] == "16000" + wav, sr = sf.read(io.BytesIO(res.content)) + assert sr == 16000 + assert len(wav) == 16000 + + +def test_tts_control_is_prefixed(client): + res = client.post("/api/tts", data={"text": "Hello", "control": "(warm) female (voice)"}) + assert res.status_code == 200 + # Parentheses inside the control text are stripped; the whole control is wrapped once. + assert client.stub.last_kwargs["text"] == "(warm female voice)Hello" + + +def test_tts_empty_text_rejected(client): + res = client.post("/api/tts", data={"text": " "}) + assert res.status_code == 400 + + +def test_tts_bad_cfg_rejected(client): + res = client.post("/api/tts", data={"text": "Hello", "cfg_value": "50"}) + assert res.status_code == 400 + + +def test_tts_bad_format_rejected(client): + res = client.post("/api/tts", data={"text": "Hello", "response_format": "mp3"}) + assert res.status_code == 400 + + +def test_tts_prompt_audio_requires_prompt_text(client): + res = client.post( + "/api/tts", + data={"text": "Hello"}, + files={"prompt_audio": ("ref.wav", _make_wav_bytes(), "audio/wav")}, + ) + assert res.status_code == 400 + assert "prompt_text" in res.json()["detail"] + + +def test_tts_with_reference_audio(client): + res = client.post( + "/api/tts", + data={"text": "Cloned speech", "denoise": "true", "seed": "42"}, + files={"reference_audio": ("ref.wav", _make_wav_bytes(), "audio/wav")}, + ) + assert res.status_code == 200 + kwargs = client.stub.last_kwargs + assert kwargs["reference_wav_path"] is not None + assert kwargs["denoise"] is True + assert kwargs["seed"] == 42 + + +def test_tts_ultimate_cloning(client): + wav_bytes = _make_wav_bytes() + res = client.post( + "/api/tts", + data={"text": "Ultimate clone", "prompt_text": "reference transcript"}, + files={ + "prompt_audio": ("p.wav", wav_bytes, "audio/wav"), + "reference_audio": ("r.wav", wav_bytes, "audio/wav"), + }, + ) + assert res.status_code == 200 + kwargs = client.stub.last_kwargs + assert kwargs["prompt_wav_path"] is not None + assert kwargs["reference_wav_path"] is not None + assert kwargs["prompt_text"] == "reference transcript" + + +def test_openai_endpoint(client): + res = client.post( + "/v1/audio/speech", + json={"model": "voxcpm", "input": "Hello!", "voice": "a warm female voice"}, + ) + assert res.status_code == 200 + assert res.headers["content-type"] == "audio/wav" + assert client.stub.last_kwargs["text"] == "(a warm female voice)Hello!" + + +def test_openai_endpoint_default_voice(client): + res = client.post("/v1/audio/speech", json={"input": "Hi", "voice": "default"}) + assert res.status_code == 200 + assert client.stub.last_kwargs["text"] == "Hi" + + +def test_openai_endpoint_flac(client): + res = client.post("/v1/audio/speech", json={"input": "Hi", "response_format": "flac"}) + assert res.status_code == 200 + assert res.headers["content-type"] == "audio/flac" + wav, sr = sf.read(io.BytesIO(res.content)) + assert sr == 16000 + + +def test_index_serves_web_ui(client): + res = client.get("/") + assert res.status_code == 200 + assert "VoxCPM Studio" in res.text diff --git a/web/index.html b/web/index.html new file mode 100644 index 00000000..03b56dd1 --- /dev/null +++ b/web/index.html @@ -0,0 +1,640 @@ + + + + + +VoxCPM Studio — Synthèse vocale + + + +
+ +
+
+

VoxCPM Studio

+

Synthèse vocale multilingue — création et clonage de voix

+
+
+ +
+ + +
+
+
+ +
+
+ 🎨 +

Création de voix

+

Créez une voix inédite à partir d'une simple description — aucun audio requis.

+
+
+ 🎛️ +

Clonage contrôlable

+

Clonez une voix depuis un extrait audio, avec contrôle du style et de l'émotion.

+
+
+ 🎙️ +

Clonage ultime

+

Reproduction fidèle de chaque nuance vocale via l'audio + sa transcription.

+
+
+ +
+ +
+
+
+ + + + +
+ + +
+ +
+ + +
+ +
+ Réglages avancés +
+
+ +
+ + 2.0 +
+
+
+ +
+ + 10 +
+
+
+ +
+ +
+ + +
+
+
+
+ +
+ +
Convertit nombres, dates et abréviations en toutes lettres
+
+
+ +
+
+ + +
+ +
+
+
Résultat
+
L'audio généré apparaîtra ici.
+ +
+ +
+
Historique de la session
+
Aucune génération pour l'instant.
+
+
+
+
+ + +
+ + + + From 13d2afdfac3937b69af2a381df5f810c60e267ac Mon Sep 17 00:00:00 2001 From: PaxHelios Date: Mon, 27 Jul 2026 21:54:52 +0200 Subject: [PATCH 02/98] feat(app): preset narration voices, French UI, long-text chunking & previews MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add PRESET_VOICES dropdown (7 French narration voices) that auto-fills the description, seed (editable, random-seed unchecked), CFG, diffusion steps and text-normalization. "Personnalisé / manuel" keeps the current free behavior. - Load voices from editable conf/preset_voices.json (falls back to built-in list). - Add full French i18n locale (fr) alongside en / zh-CN. - Long-text chunking for audiobooks: split on sentence boundaries, synthesize each segment with the same seed for a consistent voice, stitch with short silences, with a per-segment progress bar. Toggle in Advanced Settings. - On-demand voice preview button caching wavs to assets/voice_previews/. - Import a .txt chapter into the target text box (UploadButton). - Add --no-denoiser flag: skip the ZipEnhancer (ModelScope) denoiser, only needed for reference-audio cloning; avoids a slow/blocking download on CPU-only setups. - Add scripts/pregenerate_previews.py to warm the preview cache offline. - Add examples/texts/chapitre_exemple.txt and gitignore the preview cache. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01BYwW5KLZYovWstkx9aCwiq --- .gitignore | 3 + app.py | 371 +++++++++++++++++++++++++++- conf/preset_voices.json | 58 +++++ examples/texts/chapitre_exemple.txt | 3 + scripts/pregenerate_previews.py | 61 +++++ 5 files changed, 489 insertions(+), 7 deletions(-) create mode 100644 conf/preset_voices.json create mode 100644 examples/texts/chapitre_exemple.txt create mode 100644 scripts/pregenerate_previews.py diff --git a/.gitignore b/.gitignore index f7fa9812..64ab4f27 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,6 @@ voxcpm.egg-info .DS_Store ./pretrained_models/ app_local.py + +# Generated voice-preview cache (regenerate with scripts/pregenerate_previews.py) +assets/voice_previews/ diff --git a/app.py b/app.py index 5317e0fc..5b3facf5 100644 --- a/app.py +++ b/app.py @@ -1,11 +1,12 @@ import os import re import sys +import json import logging import random import numpy as np import gradio as gr -from typing import Any, Optional, Tuple +from typing import Any, List, Optional, Tuple from pathlib import Path try: @@ -99,6 +100,38 @@ "将普通话翻译为方言文本,再粘贴到 Target Text 中即可。 \n\n" ) +_USAGE_INSTRUCTIONS_FR = ( + "**VoxCPM2 — Trois modes de génération vocale :**\n\n" + "🎨 **Création de voix** — Créer une voix inédite \n" + "Aucun audio de référence requis. Décrivez les caractéristiques de la voix souhaitée " + "(genre, âge, timbre, émotion, débit…) dans **Description de la voix / style**, et VoxCPM2 " + "façonnera une voix unique à partir de votre seule description.\n\n" + "🎛️ **Clonage contrôlé** — Cloner une voix avec un guidage de style optionnel \n" + "Téléversez un extrait audio de référence, puis utilisez **Description de la voix / style** pour " + "orienter l'émotion, le débit et le style global tout en préservant le timbre d'origine.\n\n" + "🎙️ **Clonage ultime** — Reproduire chaque nuance vocale par continuation audio \n" + "Activez le **Mode clonage ultime** et fournissez (ou faites transcrire automatiquement) le texte " + "de l'audio de référence. Le modèle traite l'extrait comme un préfixe déjà prononcé et le **continue** " + "de façon fluide, en préservant fidèlement chaque détail vocal. " + "Note : ce mode désactive la Description de la voix / style." +) + +_EXAMPLES_FOOTER_FR = ( + "---\n" + "**💡 Exemples de description de voix :** \n" + "Essayez les descriptions suivantes pour explorer différentes voix : \n\n" + "**Exemple 1 — Jeune fille douce et mélancolique** \n" + '`Description`: *"Une jeune fille à la voix douce et suave. ' + 'Parle lentement, avec un ton mélancolique et légèrement boudeur."* \n' + "`Texte cible`: *\"Je ne t'ai jamais demandé de rester… Ce n'est pas comme si ça me faisait " + "quelque chose. Mais… pourquoi est-ce que ça fait encore aussi mal maintenant que tu es parti ?\"* \n\n" + "**Exemple 2 — Surfeur décontracté** \n" + '`Description`: *"Voix masculine jeune et relâchée, légèrement nasillarde, ' + 'débit traînant, très décontractée et cool."* \n' + '`Texte cible`: *"Mec, t\'as vu cette série de vagues ? La houle est totalement démente aujourd\'hui. ' + "J'ai enchaîné les tubes toute la matinée — c'est juste, genre, parfait, tu vois ce que je veux dire ?\"*" +) + _I18N_TRANSLATIONS = { "en": { "reference_audio_label": "🎤 Reference Audio (optional — upload for cloning)", @@ -124,9 +157,48 @@ "seed_info": "Seed used for reproducible generation. Updated with the actual successful seed after generation.", "random_seed_label": "Random Seed", "random_seed_info": "Generate a new seed before each inference run.", + "preset_voices_label": "🎭 Preset narration voices", + "preset_voices_info": "Pick a voice to auto-fill the description and seed.", + "preview_btn_label": "🔊 Preview this voice", + "chunking_label": "Split long texts (audiobooks)", + "chunking_info": "Automatically split long texts into sentence chunks and stitch the audio together.", + "load_txt_label": "📄 Load a .txt file", "usage_instructions": _USAGE_INSTRUCTIONS_EN, "examples_footer": _EXAMPLES_FOOTER_EN, }, + "fr": { + "reference_audio_label": "🎤 Audio de référence (optionnel — pour le clonage)", + "show_prompt_text_label": "🎙️ Mode clonage ultime (clonage guidé par le texte)", + "show_prompt_text_info": "Transcrit automatiquement l'audio de référence pour reproduire chaque nuance vocale. La Description de la voix / style sera désactivée quand ce mode est actif.", + "prompt_text_label": "Transcription de l'audio de référence (remplie via ASR, modifiable)", + "prompt_text_placeholder": "La transcription de votre audio de référence apparaîtra ici …", + "control_label": "🎛️ Description de la voix / style (optionnel — français, anglais, chinois)", + "control_placeholder": "ex. Voix masculine chaleureuse / Jeune femme douce / Rapide et enthousiaste", + "target_text_label": "✍️ Texte à synthétiser — le contenu à dire", + "generate_btn": "🔊 Générer la voix", + "generated_audio_label": "Audio généré", + "advanced_settings_title": "⚙️ Réglages avancés", + "ref_denoise_label": "Amélioration de l'audio de référence", + "ref_denoise_info": "Applique un débruitage ZipEnhancer à l'audio de référence avant le clonage", + "normalize_label": "Normalisation du texte", + "normalize_info": "Normalise les nombres, dates et abréviations (via wetext)", + "cfg_label": "CFG (intensité du guidage)", + "cfg_info": "Plus élevé → plus fidèle à la description / référence ; plus bas → variation plus créative", + "dit_steps_label": "Étapes de diffusion (LocDiT)", + "dit_steps_info": "Étapes de flow-matching LocDiT — plus d'étapes → qualité potentiellement meilleure, mais plus lent", + "seed_label": "Graine (seed)", + "seed_info": "Graine utilisée pour une génération reproductible. Mise à jour avec la graine réellement utilisée après génération.", + "random_seed_label": "Graine aléatoire", + "random_seed_info": "Génère une nouvelle graine avant chaque inférence.", + "preset_voices_label": "🎭 Voix prédéfinies (narration)", + "preset_voices_info": "Choisissez une voix pour remplir automatiquement la description et le seed.", + "preview_btn_label": "🔊 Écouter un aperçu de la voix", + "chunking_label": "Découper les longs textes (livres audio)", + "chunking_info": "Découpe automatiquement les longs textes en segments de phrases et assemble l'audio.", + "load_txt_label": "📄 Charger un fichier .txt", + "usage_instructions": _USAGE_INSTRUCTIONS_FR, + "examples_footer": _EXAMPLES_FOOTER_FR, + }, "zh-CN": { "reference_audio_label": "🎤 参考音频(可选 — 上传后用于克隆)", "show_prompt_text_label": "🎙️ 极致克隆模式(基于文本引导的极致克隆)", @@ -147,6 +219,12 @@ "cfg_info": "数值越高 → 越贴合提示/参考音色;数值越低 → 生成风格更自由", "dit_steps_label": "LocDiT 流匹配迭代步数", "dit_steps_info": "LocDiT 流匹配生成迭代步数 — 步数越多 → 可能生成更好的音频质量,但速度变慢", + "preset_voices_label": "🎭 预设旁白语音", + "preset_voices_info": "选择一个语音以自动填充描述和随机种子。", + "preview_btn_label": "🔊 试听该语音", + "chunking_label": "拆分长文本(有声书)", + "chunking_info": "自动将长文本按句子拆分并拼接音频。", + "load_txt_label": "📄 加载 .txt 文件", "usage_instructions": _USAGE_INSTRUCTIONS_ZH, "examples_footer": _EXAMPLES_FOOTER_ZH, }, @@ -167,6 +245,144 @@ "VoxCPM2 is a creative multilingual TTS model from ModelBest, " "designed to generate highly realistic speech." ) +# ---------- Preset voices for narration (Voice Design mode) ---------- +# Each entry regenerates the exact same voice when its (description, seed) pair is +# reused. Common defaults for all: CFG=2.0, diffusion steps=10, normalize=True. +# Built-in defaults below. To add/edit/remove voices WITHOUT touching this file, +# create conf/preset_voices.json (same keys) — it overrides the built-in list. +_BUILTIN_PRESET_VOICES = [ + { + "name": "Narrateur profond & calme", + "description": "Voix masculine française de narrateur pour livre audio, profonde, calme et posée, timbre chaleureux et rassurant, débit lent et immersif, diction claire et articulée", + "seed": 4110390676, + "cfg": 2.0, + "diffusion_steps": 10, + "normalize": True, + }, + { + "name": "Narratrice douce & naturelle", + "description": "Voix féminine française de narratrice pour livre audio, douce et naturelle, timbre chaleureux et authentique, débit fluide et posé, diction claire, ton captivant et apaisant", + "seed": 3227543575, + "cfg": 2.0, + "diffusion_steps": 10, + "normalize": True, + }, + { + "name": "Conteur jeune & dynamique", + "description": "Voix masculine française de jeune conteur d'environ vingt-cinq ans pour livre audio, dynamique et expressive, ton vivant et engageant, débit naturel, idéale pour la narration d'histoires", + "seed": 2151638728, + "cfg": 2.0, + "diffusion_steps": 10, + "normalize": True, + }, + { + "name": "Narratrice chaleureuse & conversationnelle", + "description": "Voix féminine française d'âge mûr pour livre audio, chaleureuse et engageante, style conversationnel et charmant, ton bienveillant et proche de l'auditeur, diction naturelle", + "seed": 3023399458, + "cfg": 2.0, + "diffusion_steps": 10, + "normalize": True, + }, + { + "name": "Narrateur documentaire velouté", + "description": "Voix masculine française de narrateur de documentaire, veloutée et posée, ton professionnel empreint de mystère et d'émerveillement, diction soignée, idéale pour nature, science et histoire", + "seed": 1468538221, + "cfg": 2.0, + "diffusion_steps": 10, + "normalize": True, + }, + { + "name": "Narrateur moderne & professionnel", + "description": "Voix masculine française moderne, claire et profonde, ton assuré et régulier, débit confiant et professionnel, idéale pour la narration, les podcasts et les livres audio contemporains", + "seed": 3515672692, + "cfg": 2.0, + "diffusion_steps": 10, + "normalize": True, + }, + { + "name": "Méditation guidée (grave & lente)", + "description": "Voix masculine française très grave et profonde pour méditation guidée, extrêmement lente et douce, ton chaud, apaisant et enveloppant, chuchoté et relaxant, longues pauses, respiration calme, idéale pour la détente et la relaxation", + "seed": 560505514, + "cfg": 2.0, + "diffusion_steps": 10, + "normalize": True, + }, +] + +# Optional external override: conf/preset_voices.json (a JSON list of objects with +# the same keys). Lets non-developers curate the voice list without editing code. +_PRESET_VOICES_JSON = Path(__file__).parent / "conf" / "preset_voices.json" + + +def _load_preset_voices() -> List[dict]: + """Return voices from conf/preset_voices.json if valid, else the built-in list.""" + if not _PRESET_VOICES_JSON.is_file(): + return _BUILTIN_PRESET_VOICES + try: + with open(_PRESET_VOICES_JSON, "r", encoding="utf-8") as f: + data = json.load(f) + voices = [ + { + "name": str(item["name"]), + "description": str(item["description"]), + "seed": int(item["seed"]), + "cfg": float(item.get("cfg", 2.0)), + "diffusion_steps": int(item.get("diffusion_steps", 10)), + "normalize": bool(item.get("normalize", True)), + } + for item in data + ] + if not voices: + raise ValueError("no voices found in JSON") + logger.info(f"Loaded {len(voices)} preset voices from {_PRESET_VOICES_JSON}") + return voices + except Exception as e: + logger.warning(f"Could not load {_PRESET_VOICES_JSON} ({e}); using built-in presets.") + return _BUILTIN_PRESET_VOICES + + +PRESET_VOICES = _load_preset_voices() + +# Label of the "leave everything free" option (current default behavior). +PRESET_CUSTOM_LABEL = "Personnalisé / manuel" +_PRESET_BY_NAME = {v["name"]: v for v in PRESET_VOICES} + +# ---------- Long-text chunking (audiobooks) ---------- +# Split on sentence boundaries so each generated chunk stays a reasonable length, +# then stitch the audio with a short silence between chunks. +_CHUNK_MAX_CHARS = 300 +_CHUNK_SILENCE_SEC = 0.3 +_SENTENCE_SPLIT_RE = re.compile(r"(?<=[.!?…。!?\n])\s+") + +# Short fixed phrase used to preview a preset voice on demand. +_PREVIEW_TEXT = "Bonjour, ceci est un aperçu de cette voix pour la narration de votre livre audio." +_PREVIEW_DIR = Path(__file__).parent / "assets" / "voice_previews" + + +def _split_text_into_chunks(text: str, max_chars: int = _CHUNK_MAX_CHARS) -> List[str]: + """Greedily pack whole sentences into chunks no longer than ``max_chars``. + A single sentence longer than the limit becomes its own chunk.""" + text = (text or "").strip() + if not text: + return [] + sentences = [s.strip() for s in _SENTENCE_SPLIT_RE.split(text) if s.strip()] + chunks: List[str] = [] + current = "" + for sentence in sentences: + if len(sentence) > max_chars: + if current: + chunks.append(current) + current = "" + chunks.append(sentence) + elif current and len(current) + 1 + len(sentence) > max_chars: + chunks.append(current) + current = sentence + else: + current = f"{current} {sentence}" if current else sentence + if current: + chunks.append(current) + return chunks + _CUSTOM_CSS = """ .logo-container { text-align: center; @@ -229,10 +445,20 @@ class VoxCPMDemo: - def __init__(self, model_id: str = "openbmb/VoxCPM2", device: str = "auto") -> None: + def __init__( + self, + model_id: str = "openbmb/VoxCPM2", + device: str = "auto", + load_denoiser: bool = True, + ) -> None: self.device = resolve_runtime_device(device, "cuda") logger.info(f"Running VoxCPM on device: {self.device}") self.optimize = self.device.startswith("cuda") + # The ZipEnhancer denoiser is a ModelScope model only needed to clean a + # reference audio clip. For narration (Voice Design, no reference audio) + # it is never used — disable it with --no-denoiser to skip a slow/blocking + # ModelScope download and start much faster (useful on CPU-only machines). + self.load_denoiser = load_denoiser self.asr_model_id = "iic/SenseVoiceSmall" self.asr_device = "cuda:0" if self.device.startswith("cuda") else "cpu" @@ -244,11 +470,12 @@ def __init__(self, model_id: str = "openbmb/VoxCPM2", device: str = "auto") -> N def get_or_load_voxcpm(self) -> voxcpm.VoxCPM: if self.voxcpm_model is not None: return self.voxcpm_model - logger.info(f"Loading model: {self._model_id}") + logger.info(f"Loading model: {self._model_id} (denoiser={'on' if self.load_denoiser else 'off'})") self.voxcpm_model = voxcpm.VoxCPM.from_pretrained( self._model_id, optimize=self.optimize, device=self.device, + load_denoiser=self.load_denoiser, ) logger.info("Model loaded successfully.") return self.voxcpm_model @@ -377,6 +604,32 @@ def _prepare_seed(use_random_seed: bool, seed_value): def _on_random_seed_toggle(checked): return gr.update(interactive=not checked) + def _on_preset_change(preset_name): + """Fill the Voice Design fields from a preset. 'Personnalisé' = no-op.""" + preset = _PRESET_BY_NAME.get(preset_name) + if preset is None: # "Personnalisé / manuel" → keep fields as-is (current behavior) + return (gr.update(), gr.update(), gr.update(), gr.update(), gr.update(), gr.update()) + return ( + gr.update(value=preset["description"]), # control_instruction + gr.update(value=preset["seed"], interactive=True), # seed_value (editable) + gr.update(value=False), # random_seed → unchecked + gr.update(value=preset["cfg"]), # cfg_value + gr.update(value=preset["diffusion_steps"]), # dit_steps + gr.update(value=preset["normalize"]), # DoNormalizeText + ) + + def _load_text_file(file_path: Optional[str]) -> str: + """Read a .txt file and return its contents to fill the target text box.""" + if not file_path: + return gr.update() + try: + content = Path(file_path).read_text(encoding="utf-8").strip() + logger.info(f"Loaded text file ({len(content)} chars) from {file_path}") + return content + except Exception as e: + logger.warning(f"Could not read text file {file_path}: {e}") + raise gr.Error(f"Impossible de lire le fichier : {e}") + def _generate( text: str, control_instruction: str, @@ -388,12 +641,14 @@ def _generate( denoise: bool, dit_steps: int, seed_value, + enable_chunking: bool, + progress=gr.Progress(), ): actual_prompt_text = prompt_text_value.strip() if use_prompt_text else "" actual_control = "" if use_prompt_text else control_instruction seed = _coerce_seed(seed_value) - sr, wav_np, last_successful_seed = demo.generate_tts_audio( - text_input=text, + + common = dict( control_instruction=actual_control, reference_wav_path_input=ref_wav, prompt_text=actual_prompt_text, @@ -401,9 +656,52 @@ def _generate( do_normalize=do_normalize, denoise=denoise, inference_timesteps=int(dit_steps), + seed=seed, # same seed for every chunk → consistent voice + ) + + # Only chunk plain Voice Design / control text — cloning modes keep a single pass. + chunks = _split_text_into_chunks(text) if enable_chunking else [] + if len(chunks) <= 1 or ref_wav or actual_prompt_text: + sr, wav_np, last_successful_seed = demo.generate_tts_audio(text_input=text, **common) + return (sr, wav_np), last_successful_seed + + logger.info(f"Chunked synthesis: {len(chunks)} segments.") + sr = None + parts: List[np.ndarray] = [] + last_successful_seed = seed + for i, chunk in enumerate(progress.tqdm(chunks, desc="Synthèse des segments")): + logger.info(f" segment {i + 1}/{len(chunks)}") + sr, wav_chunk, last_successful_seed = demo.generate_tts_audio(text_input=chunk, **common) + if i > 0: + parts.append(np.zeros(int(sr * _CHUNK_SILENCE_SEC), dtype=wav_chunk.dtype)) + parts.append(wav_chunk) + return (sr, np.concatenate(parts)), last_successful_seed + + def _preview_voice(description, seed_value, cfg, steps, normalize): + """Generate (and cache) a short sample of the currently selected voice.""" + seed = _coerce_seed(seed_value) + cache_path = None + if seed is not None: + _PREVIEW_DIR.mkdir(parents=True, exist_ok=True) + cache_path = _PREVIEW_DIR / f"preview_{seed}.wav" + if cache_path.is_file(): + return str(cache_path) + sr, wav_np, _ = demo.generate_tts_audio( + text_input=_PREVIEW_TEXT, + control_instruction=description or "", + cfg_value_input=cfg, + do_normalize=normalize, + inference_timesteps=int(steps), seed=seed, ) - return (sr, wav_np), last_successful_seed + if cache_path is not None: + try: + import soundfile as sf + sf.write(str(cache_path), wav_np, sr) + return str(cache_path) + except Exception as e: + logger.warning(f"Could not cache preview ({e}); returning in-memory audio.") + return (sr, wav_np) def _on_toggle_instant(checked): """Instant UI toggle — no ASR, no blocking.""" @@ -459,6 +757,14 @@ def _run_asr_if_needed(checked, audio_path): lines=2, visible=False, ) + preset_voice = gr.Dropdown( + choices=[PRESET_CUSTOM_LABEL] + [v["name"] for v in PRESET_VOICES], + value=PRESET_CUSTOM_LABEL, + label=I18N("preset_voices_label"), + info=I18N("preset_voices_info"), + ) + preview_btn = gr.Button(I18N("preview_btn_label"), size="sm") + preview_audio = gr.Audio(label=I18N("preview_btn_label"), visible=False) control_instruction = gr.Textbox( value="", label=I18N("control_label"), @@ -470,6 +776,11 @@ def _run_asr_if_needed(checked, audio_path): label=I18N("target_text_label"), lines=3, ) + load_txt_btn = gr.UploadButton( + I18N("load_txt_label"), + file_types=[".txt"], + size="sm", + ) with gr.Accordion(I18N("advanced_settings_title"), open=False): DoDenoisePromptAudio = gr.Checkbox( @@ -484,6 +795,12 @@ def _run_asr_if_needed(checked, audio_path): elem_classes=["switch-toggle"], info=I18N("normalize_info"), ) + enable_chunking = gr.Checkbox( + value=True, + label=I18N("chunking_label"), + elem_classes=["switch-toggle"], + info=I18N("chunking_info"), + ) cfg_value = gr.Slider( minimum=1.0, maximum=3.0, @@ -537,6 +854,36 @@ def _run_asr_if_needed(checked, audio_path): outputs=[seed_value], ) + preset_voice.change( + fn=_on_preset_change, + inputs=[preset_voice], + outputs=[ + control_instruction, + seed_value, + random_seed, + cfg_value, + dit_steps, + DoNormalizeText, + ], + ) + + load_txt_btn.upload( + fn=_load_text_file, + inputs=[load_txt_btn], + outputs=[text], + ) + + preview_btn.click( + fn=lambda: gr.update(visible=True), + outputs=[preview_audio], + show_progress=False, + ).then( + fn=_preview_voice, + inputs=[control_instruction, seed_value, cfg_value, dit_steps, DoNormalizeText], + outputs=[preview_audio], + show_progress=True, + ) + run_btn.click( fn=_prepare_seed, inputs=[random_seed, seed_value], @@ -555,6 +902,7 @@ def _run_asr_if_needed(checked, audio_path): DoDenoisePromptAudio, dit_steps, seed_value, + enable_chunking, ], outputs=[audio_output, seed_value], show_progress=True, @@ -570,8 +918,9 @@ def run_demo( show_error: bool = True, model_id: str = "openbmb/VoxCPM2", device: str = "auto", + load_denoiser: bool = True, ): - demo = VoxCPMDemo(model_id=model_id, device=device) + demo = VoxCPMDemo(model_id=model_id, device=device, load_denoiser=load_denoiser) interface = create_demo_interface(demo) interface.queue(max_size=10, default_concurrency_limit=1).launch( server_name=server_name, @@ -607,10 +956,18 @@ def run_demo( default="auto", help="Runtime device: auto, cpu, mps, cuda, or cuda:N (default: auto)", ) + parser.add_argument( + "--no-denoiser", + action="store_true", + help="Skip loading the ZipEnhancer (ModelScope) denoiser. It is only used to " + "clean reference audio for cloning; disabling it speeds up startup and " + "avoids a slow/blocking download — recommended for narration on CPU.", + ) args = parser.parse_args() run_demo( model_id=args.model_id, server_name=args.host, server_port=args.port, device=args.device, + load_denoiser=not args.no_denoiser, ) diff --git a/conf/preset_voices.json b/conf/preset_voices.json new file mode 100644 index 00000000..19a0875f --- /dev/null +++ b/conf/preset_voices.json @@ -0,0 +1,58 @@ +[ + { + "name": "Narrateur profond & calme", + "description": "Voix masculine française de narrateur pour livre audio, profonde, calme et posée, timbre chaleureux et rassurant, débit lent et immersif, diction claire et articulée", + "seed": 4110390676, + "cfg": 2.0, + "diffusion_steps": 10, + "normalize": true + }, + { + "name": "Narratrice douce & naturelle", + "description": "Voix féminine française de narratrice pour livre audio, douce et naturelle, timbre chaleureux et authentique, débit fluide et posé, diction claire, ton captivant et apaisant", + "seed": 3227543575, + "cfg": 2.0, + "diffusion_steps": 10, + "normalize": true + }, + { + "name": "Conteur jeune & dynamique", + "description": "Voix masculine française de jeune conteur d'environ vingt-cinq ans pour livre audio, dynamique et expressive, ton vivant et engageant, débit naturel, idéale pour la narration d'histoires", + "seed": 2151638728, + "cfg": 2.0, + "diffusion_steps": 10, + "normalize": true + }, + { + "name": "Narratrice chaleureuse & conversationnelle", + "description": "Voix féminine française d'âge mûr pour livre audio, chaleureuse et engageante, style conversationnel et charmant, ton bienveillant et proche de l'auditeur, diction naturelle", + "seed": 3023399458, + "cfg": 2.0, + "diffusion_steps": 10, + "normalize": true + }, + { + "name": "Narrateur documentaire velouté", + "description": "Voix masculine française de narrateur de documentaire, veloutée et posée, ton professionnel empreint de mystère et d'émerveillement, diction soignée, idéale pour nature, science et histoire", + "seed": 1468538221, + "cfg": 2.0, + "diffusion_steps": 10, + "normalize": true + }, + { + "name": "Narrateur moderne & professionnel", + "description": "Voix masculine française moderne, claire et profonde, ton assuré et régulier, débit confiant et professionnel, idéale pour la narration, les podcasts et les livres audio contemporains", + "seed": 3515672692, + "cfg": 2.0, + "diffusion_steps": 10, + "normalize": true + }, + { + "name": "Méditation guidée (grave & lente)", + "description": "Voix masculine française très grave et profonde pour méditation guidée, extrêmement lente et douce, ton chaud, apaisant et enveloppant, chuchoté et relaxant, longues pauses, respiration calme, idéale pour la détente et la relaxation", + "seed": 560505514, + "cfg": 2.0, + "diffusion_steps": 10, + "normalize": true + } +] diff --git a/examples/texts/chapitre_exemple.txt b/examples/texts/chapitre_exemple.txt new file mode 100644 index 00000000..806b1de4 --- /dev/null +++ b/examples/texts/chapitre_exemple.txt @@ -0,0 +1,3 @@ +Il était une fois, dans un village niché au creux des montagnes, une jeune fille nommée Élise. Chaque matin, elle gravissait le sentier escarpé qui menait à la forêt. +Les habitants disaient que cette forêt gardait un secret ancien. Personne n'osait s'y aventurer après le coucher du soleil. +Mais Élise n'avait pas peur. Sa curiosité était plus forte que toutes les légendes. diff --git a/scripts/pregenerate_previews.py b/scripts/pregenerate_previews.py new file mode 100644 index 00000000..c4019982 --- /dev/null +++ b/scripts/pregenerate_previews.py @@ -0,0 +1,61 @@ +"""Pre-generate the voice-preview .wav files for every preset voice. + +Run this ONCE to warm the preview cache so the "Écouter un aperçu" button in the +web UI returns instantly instead of synthesizing on demand. Safe to re-run — it +skips voices whose preview already exists. + +Usage (from the repo root, using the project venv): + ./.venv/Scripts/python.exe scripts/pregenerate_previews.py + ./.venv/Scripts/python.exe scripts/pregenerate_previews.py --device cpu --force + +The denoiser is never loaded here (previews use no reference audio), so startup is +fast and does not touch ModelScope. +""" +import argparse +import sys +from pathlib import Path + +import soundfile as sf + +# Make the repo root importable so we can reuse app.py's presets and constants. +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +import app # noqa: E402 + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--device", default="cpu", help="auto, cpu, mps, cuda, or cuda:N (default: cpu)") + parser.add_argument("--model-id", default="openbmb/VoxCPM2", help="Model path or HF repo id") + parser.add_argument("--force", action="store_true", help="Regenerate even if the preview already exists") + args = parser.parse_args() + + app._PREVIEW_DIR.mkdir(parents=True, exist_ok=True) + demo = app.VoxCPMDemo(model_id=args.model_id, device=args.device, load_denoiser=False) + + total = len(app.PRESET_VOICES) + for i, voice in enumerate(app.PRESET_VOICES, start=1): + seed = voice["seed"] + out = app._PREVIEW_DIR / f"preview_{seed}.wav" + tag = f"[{i}/{total}] seed={seed}" + if out.is_file() and not args.force: + print(f"{tag}: already exists, skipping -> {out.name}", flush=True) + continue + print(f"{tag}: generating (this is slow on CPU) ...", flush=True) + sr, wav, _ = demo.generate_tts_audio( + text_input=app._PREVIEW_TEXT, + control_instruction=voice["description"], + cfg_value_input=voice.get("cfg", 2.0), + do_normalize=voice.get("normalize", True), + inference_timesteps=int(voice.get("diffusion_steps", 10)), + seed=seed, + ) + sf.write(str(out), wav, sr) + print(f"{tag}: saved -> {out.name} ({len(wav) / sr:.2f}s)", flush=True) + + print("Done. Preview cache is warm.", flush=True) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From c61f509784097adbfbbf191289bf62a42882443f Mon Sep 17 00:00:00 2001 From: PaxHelios Date: Mon, 27 Jul 2026 22:04:48 +0200 Subject: [PATCH 03/98] feat(app): archive every generation to output/ with a descriptive filename Each generation is now written to output/ as narration__seed_.wav (voice = preset name or "custom") and the player serves that file, so the download button keeps a meaningful name. The output/ directory is gitignored. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01BYwW5KLZYovWstkx9aCwiq --- .gitignore | 3 +++ app.py | 54 +++++++++++++++++++++++++++++++++++++++++------------- 2 files changed, 44 insertions(+), 13 deletions(-) diff --git a/.gitignore b/.gitignore index 64ab4f27..b3c3954a 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,6 @@ app_local.py # Generated voice-preview cache (regenerate with scripts/pregenerate_previews.py) assets/voice_previews/ + +# Archived generations +output/ diff --git a/app.py b/app.py index 5b3facf5..a094c6da 100644 --- a/app.py +++ b/app.py @@ -2,6 +2,7 @@ import re import sys import json +import time import logging import random import numpy as np @@ -358,6 +359,28 @@ def _load_preset_voices() -> List[dict]: _PREVIEW_TEXT = "Bonjour, ceci est un aperçu de cette voix pour la narration de votre livre audio." _PREVIEW_DIR = Path(__file__).parent / "assets" / "voice_previews" +# Every generation is also archived here with a descriptive filename. +_OUTPUT_DIR = Path(__file__).parent / "output" + + +def _sanitize_filename(name: str) -> str: + """Turn a voice name into a safe filename fragment.""" + name = re.sub(r"[^\w]+", "_", (name or "").strip(), flags=re.UNICODE) + return name.strip("_")[:60] or "custom" + + +def _save_output_wav(wav_np: np.ndarray, sr: int, seed: Optional[int], voice_name: str) -> str: + """Write the generated audio to output/ with a descriptive name; return the path.""" + import soundfile as sf + + _OUTPUT_DIR.mkdir(parents=True, exist_ok=True) + stamp = time.strftime("%Y%m%d_%H%M%S") + seed_part = f"seed{seed}" if seed is not None else "seedrandom" + out = _OUTPUT_DIR / f"narration_{_sanitize_filename(voice_name)}_{seed_part}_{stamp}.wav" + sf.write(str(out), wav_np, sr) + logger.info(f"Saved generated audio -> {out}") + return str(out) + def _split_text_into_chunks(text: str, max_chars: int = _CHUNK_MAX_CHARS) -> List[str]: """Greedily pack whole sentences into chunks no longer than ``max_chars``. @@ -642,11 +665,13 @@ def _generate( dit_steps: int, seed_value, enable_chunking: bool, + preset_name: str = "", progress=gr.Progress(), ): actual_prompt_text = prompt_text_value.strip() if use_prompt_text else "" actual_control = "" if use_prompt_text else control_instruction seed = _coerce_seed(seed_value) + voice_name = preset_name if preset_name and preset_name != PRESET_CUSTOM_LABEL else "custom" common = dict( control_instruction=actual_control, @@ -663,19 +688,21 @@ def _generate( chunks = _split_text_into_chunks(text) if enable_chunking else [] if len(chunks) <= 1 or ref_wav or actual_prompt_text: sr, wav_np, last_successful_seed = demo.generate_tts_audio(text_input=text, **common) - return (sr, wav_np), last_successful_seed - - logger.info(f"Chunked synthesis: {len(chunks)} segments.") - sr = None - parts: List[np.ndarray] = [] - last_successful_seed = seed - for i, chunk in enumerate(progress.tqdm(chunks, desc="Synthèse des segments")): - logger.info(f" segment {i + 1}/{len(chunks)}") - sr, wav_chunk, last_successful_seed = demo.generate_tts_audio(text_input=chunk, **common) - if i > 0: - parts.append(np.zeros(int(sr * _CHUNK_SILENCE_SEC), dtype=wav_chunk.dtype)) - parts.append(wav_chunk) - return (sr, np.concatenate(parts)), last_successful_seed + else: + logger.info(f"Chunked synthesis: {len(chunks)} segments.") + sr = None + parts: List[np.ndarray] = [] + last_successful_seed = seed + for i, chunk in enumerate(progress.tqdm(chunks, desc="Synthèse des segments")): + logger.info(f" segment {i + 1}/{len(chunks)}") + sr, wav_chunk, last_successful_seed = demo.generate_tts_audio(text_input=chunk, **common) + if i > 0: + parts.append(np.zeros(int(sr * _CHUNK_SILENCE_SEC), dtype=wav_chunk.dtype)) + parts.append(wav_chunk) + wav_np = np.concatenate(parts) + + out_path = _save_output_wav(wav_np, sr, last_successful_seed, voice_name) + return out_path, last_successful_seed def _preview_voice(description, seed_value, cfg, steps, normalize): """Generate (and cache) a short sample of the currently selected voice.""" @@ -903,6 +930,7 @@ def _run_asr_if_needed(checked, audio_path): dit_steps, seed_value, enable_chunking, + preset_voice, ], outputs=[audio_output, seed_value], show_progress=True, From a26dc0ebb1dfd93effeb81f3dcb394bf626e5728 Mon Sep 17 00:00:00 2001 From: PaxHelios Date: Mon, 27 Jul 2026 22:25:03 +0200 Subject: [PATCH 04/98] feat(app): expose max-characters-per-chunk as a UI slider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a "Caractères max par segment" slider (100-600, default 300) in Advanced Settings so the long-text chunk size is tunable from the UI instead of being hard-coded. Wired through _generate into _split_text_into_chunks. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01BYwW5KLZYovWstkx9aCwiq --- app.py | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/app.py b/app.py index a094c6da..40f9a008 100644 --- a/app.py +++ b/app.py @@ -163,6 +163,8 @@ "preview_btn_label": "🔊 Preview this voice", "chunking_label": "Split long texts (audiobooks)", "chunking_info": "Automatically split long texts into sentence chunks and stitch the audio together.", + "chunk_size_label": "Max characters per chunk", + "chunk_size_info": "Target size of each chunk when splitting long texts (whole sentences are kept together).", "load_txt_label": "📄 Load a .txt file", "usage_instructions": _USAGE_INSTRUCTIONS_EN, "examples_footer": _EXAMPLES_FOOTER_EN, @@ -196,6 +198,8 @@ "preview_btn_label": "🔊 Écouter un aperçu de la voix", "chunking_label": "Découper les longs textes (livres audio)", "chunking_info": "Découpe automatiquement les longs textes en segments de phrases et assemble l'audio.", + "chunk_size_label": "Caractères max par segment", + "chunk_size_info": "Taille cible de chaque segment lors du découpage (les phrases entières restent groupées).", "load_txt_label": "📄 Charger un fichier .txt", "usage_instructions": _USAGE_INSTRUCTIONS_FR, "examples_footer": _EXAMPLES_FOOTER_FR, @@ -225,6 +229,8 @@ "preview_btn_label": "🔊 试听该语音", "chunking_label": "拆分长文本(有声书)", "chunking_info": "自动将长文本按句子拆分并拼接音频。", + "chunk_size_label": "每段最大字符数", + "chunk_size_info": "拆分长文本时每段的目标长度(整句会保持在一起)。", "load_txt_label": "📄 加载 .txt 文件", "usage_instructions": _USAGE_INSTRUCTIONS_ZH, "examples_footer": _EXAMPLES_FOOTER_ZH, @@ -665,6 +671,7 @@ def _generate( dit_steps: int, seed_value, enable_chunking: bool, + chunk_max_chars: int, preset_name: str = "", progress=gr.Progress(), ): @@ -685,7 +692,7 @@ def _generate( ) # Only chunk plain Voice Design / control text — cloning modes keep a single pass. - chunks = _split_text_into_chunks(text) if enable_chunking else [] + chunks = _split_text_into_chunks(text, int(chunk_max_chars)) if enable_chunking else [] if len(chunks) <= 1 or ref_wav or actual_prompt_text: sr, wav_np, last_successful_seed = demo.generate_tts_audio(text_input=text, **common) else: @@ -828,6 +835,14 @@ def _run_asr_if_needed(checked, audio_path): elem_classes=["switch-toggle"], info=I18N("chunking_info"), ) + chunk_max_chars = gr.Slider( + minimum=100, + maximum=600, + value=_CHUNK_MAX_CHARS, + step=20, + label=I18N("chunk_size_label"), + info=I18N("chunk_size_info"), + ) cfg_value = gr.Slider( minimum=1.0, maximum=3.0, @@ -930,6 +945,7 @@ def _run_asr_if_needed(checked, audio_path): dit_steps, seed_value, enable_chunking, + chunk_max_chars, preset_voice, ], outputs=[audio_output, seed_value], From a7a95e8ebff3e46d533a68e58ca85fd2f1901a79 Mon Sep 17 00:00:00 2001 From: PaxHelios Date: Mon, 27 Jul 2026 22:59:29 +0200 Subject: [PATCH 05/98] feat(app): optional per-language voice filtering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add a "lang" field to preset voices (defaults to "fr"); documented in conf/preset_voices.json. - Show a language selector above the voice dropdown ONLY when more than one language is present, so a single-language setup stays uncluttered. Changing the language filters the voice list and resets to "Personnalisé". Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01BYwW5KLZYovWstkx9aCwiq --- app.py | 42 ++++++++++++++++++++++++++++++++++++++++- conf/preset_voices.json | 21 ++++++++++++++------- 2 files changed, 55 insertions(+), 8 deletions(-) diff --git a/app.py b/app.py index 40f9a008..719596c3 100644 --- a/app.py +++ b/app.py @@ -158,6 +158,7 @@ "seed_info": "Seed used for reproducible generation. Updated with the actual successful seed after generation.", "random_seed_label": "Random Seed", "random_seed_info": "Generate a new seed before each inference run.", + "preset_lang_label": "🌐 Language", "preset_voices_label": "🎭 Preset narration voices", "preset_voices_info": "Pick a voice to auto-fill the description and seed.", "preview_btn_label": "🔊 Preview this voice", @@ -193,6 +194,7 @@ "seed_info": "Graine utilisée pour une génération reproductible. Mise à jour avec la graine réellement utilisée après génération.", "random_seed_label": "Graine aléatoire", "random_seed_info": "Génère une nouvelle graine avant chaque inférence.", + "preset_lang_label": "🌐 Langue", "preset_voices_label": "🎭 Voix prédéfinies (narration)", "preset_voices_info": "Choisissez une voix pour remplir automatiquement la description et le seed.", "preview_btn_label": "🔊 Écouter un aperçu de la voix", @@ -224,6 +226,7 @@ "cfg_info": "数值越高 → 越贴合提示/参考音色;数值越低 → 生成风格更自由", "dit_steps_label": "LocDiT 流匹配迭代步数", "dit_steps_info": "LocDiT 流匹配生成迭代步数 — 步数越多 → 可能生成更好的音频质量,但速度变慢", + "preset_lang_label": "🌐 语言", "preset_voices_label": "🎭 预设旁白语音", "preset_voices_info": "选择一个语音以自动填充描述和随机种子。", "preview_btn_label": "🔊 试听该语音", @@ -336,6 +339,7 @@ def _load_preset_voices() -> List[dict]: "cfg": float(item.get("cfg", 2.0)), "diffusion_steps": int(item.get("diffusion_steps", 10)), "normalize": bool(item.get("normalize", True)), + "lang": str(item.get("lang", "fr")), } for item in data ] @@ -349,11 +353,27 @@ def _load_preset_voices() -> List[dict]: PRESET_VOICES = _load_preset_voices() +for _v in PRESET_VOICES: # every voice has a language (defaults to French) + _v.setdefault("lang", "fr") # Label of the "leave everything free" option (current default behavior). PRESET_CUSTOM_LABEL = "Personnalisé / manuel" _PRESET_BY_NAME = {v["name"]: v for v in PRESET_VOICES} +# Distinct languages present, and human labels for the language selector. The +# selector only appears in the UI when more than one language is available. +_PRESET_LANGS = sorted({v["lang"] for v in PRESET_VOICES}) +_LANG_LABELS = {"fr": "Français", "en": "English", "zh": "中文", "es": "Español", "de": "Deutsch", "it": "Italiano"} + + +def _lang_label(code: str) -> str: + return _LANG_LABELS.get(code, code) + + +def _voice_names_for_lang(lang: Optional[str]) -> List[str]: + """Preset voice names for a language (all voices when lang is None).""" + return [v["name"] for v in PRESET_VOICES if lang is None or v["lang"] == lang] + # ---------- Long-text chunking (audiobooks) ---------- # Split on sentence boundaries so each generated chunk stays a reasonable length, # then stitch the audio with a short silence between chunks. @@ -633,6 +653,13 @@ def _prepare_seed(use_random_seed: bool, seed_value): def _on_random_seed_toggle(checked): return gr.update(interactive=not checked) + def _on_lang_change(lang): + """Restrict the voice dropdown to the chosen language and reset to Custom.""" + return gr.update( + choices=[PRESET_CUSTOM_LABEL] + _voice_names_for_lang(lang), + value=PRESET_CUSTOM_LABEL, + ) + def _on_preset_change(preset_name): """Fill the Voice Design fields from a preset. 'Personnalisé' = no-op.""" preset = _PRESET_BY_NAME.get(preset_name) @@ -791,8 +818,15 @@ def _run_asr_if_needed(checked, audio_path): lines=2, visible=False, ) + _default_lang = _PRESET_LANGS[0] if _PRESET_LANGS else None + preset_lang = gr.Dropdown( + choices=[(_lang_label(c), c) for c in _PRESET_LANGS], + value=_default_lang, + label=I18N("preset_lang_label"), + visible=len(_PRESET_LANGS) > 1, # only show when there is a choice to make + ) preset_voice = gr.Dropdown( - choices=[PRESET_CUSTOM_LABEL] + [v["name"] for v in PRESET_VOICES], + choices=[PRESET_CUSTOM_LABEL] + _voice_names_for_lang(_default_lang), value=PRESET_CUSTOM_LABEL, label=I18N("preset_voices_label"), info=I18N("preset_voices_info"), @@ -896,6 +930,12 @@ def _run_asr_if_needed(checked, audio_path): outputs=[seed_value], ) + preset_lang.change( + fn=_on_lang_change, + inputs=[preset_lang], + outputs=[preset_voice], + ) + preset_voice.change( fn=_on_preset_change, inputs=[preset_voice], diff --git a/conf/preset_voices.json b/conf/preset_voices.json index 19a0875f..00d4ef0f 100644 --- a/conf/preset_voices.json +++ b/conf/preset_voices.json @@ -5,7 +5,8 @@ "seed": 4110390676, "cfg": 2.0, "diffusion_steps": 10, - "normalize": true + "normalize": true, + "lang": "fr" }, { "name": "Narratrice douce & naturelle", @@ -13,7 +14,8 @@ "seed": 3227543575, "cfg": 2.0, "diffusion_steps": 10, - "normalize": true + "normalize": true, + "lang": "fr" }, { "name": "Conteur jeune & dynamique", @@ -21,7 +23,8 @@ "seed": 2151638728, "cfg": 2.0, "diffusion_steps": 10, - "normalize": true + "normalize": true, + "lang": "fr" }, { "name": "Narratrice chaleureuse & conversationnelle", @@ -29,7 +32,8 @@ "seed": 3023399458, "cfg": 2.0, "diffusion_steps": 10, - "normalize": true + "normalize": true, + "lang": "fr" }, { "name": "Narrateur documentaire velouté", @@ -37,7 +41,8 @@ "seed": 1468538221, "cfg": 2.0, "diffusion_steps": 10, - "normalize": true + "normalize": true, + "lang": "fr" }, { "name": "Narrateur moderne & professionnel", @@ -45,7 +50,8 @@ "seed": 3515672692, "cfg": 2.0, "diffusion_steps": 10, - "normalize": true + "normalize": true, + "lang": "fr" }, { "name": "Méditation guidée (grave & lente)", @@ -53,6 +59,7 @@ "seed": 560505514, "cfg": 2.0, "diffusion_steps": 10, - "normalize": true + "normalize": true, + "lang": "fr" } ] From 4250e2a6ddb9766371c5f38adf6a8398c3f911ec Mon Sep 17 00:00:00 2001 From: PaxHelios Date: Mon, 27 Jul 2026 23:17:20 +0200 Subject: [PATCH 06/98] feat(scripts): robust whole-book narration + long-form usage guide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - scripts/narrate_book.py: narrate a long .txt to per-chapter WAV files. Splits chapters on '---' (or --chapter-regex), chunks each chapter into sentences (reusing app._split_text_into_chunks), same seed for a consistent voice, saves one file per chapter. Memory-safe (one chapter in RAM at a time), resumable (skips existing chapter files), with a --dry-run planning mode. - docs/NARRATION.md: guide for books / guided meditation / podcast scripts — CPU vs GPU speed table, how to enable CUDA, the engine's ~8192-token limit and automatic chunking, recommended voice/settings per use case, and voice consistency via a fixed seed. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01BYwW5KLZYovWstkx9aCwiq --- docs/NARRATION.md | 119 ++++++++++++++++++++++++++++++ scripts/narrate_book.py | 158 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 277 insertions(+) create mode 100644 docs/NARRATION.md create mode 100644 scripts/narrate_book.py diff --git a/docs/NARRATION.md b/docs/NARRATION.md new file mode 100644 index 00000000..639b8e9d --- /dev/null +++ b/docs/NARRATION.md @@ -0,0 +1,119 @@ +# Guide de narration longue (livres, méditations, podcasts) + +Ce guide explique comment utiliser ce fork de VoxCPM2 pour narrer des **contenus +longs** en français : livres audio, scripts de méditation guidée, scripts de +podcast, etc. + +## Réponse courte + +**Oui, c'est fait pour ça** — mais deux réalités comptent : + +1. **La vitesse dépend du matériel.** Sur **GPU CUDA**, c'est rapide et pratique. Sur + **CPU seul**, c'est ~50× plus lent que le temps réel : utilisable pour des extraits + courts, impraticable pour un livre entier. +2. **Le découpage est automatique et obligatoire.** Le moteur ne peut pas traiter plus + de ~8 192 tokens d'un coup — au-delà il s'arrête sur une erreur « KV cache is full » + (voir `src/voxcpm/model/voxcpm2.py`). L'app et le script découpent le texte en + phrases pour rester bien en-dessous de cette limite, sans que tu aies à t'en soucier. + +## Vitesse : à quoi s'attendre + +| Matériel | Vitesse (RTF) | 10 min d'audio | Livre de 3 h | +|---|---|---|---| +| RTX 4090 (CUDA) | ~0.30 (≈3× plus rapide que le réel) | ~3 min | **< 1 h** | +| Apple M4 Pro (Metal) | ~1.76 | ~18 min | ~5 h | +| **CPU seul (cette machine)** | **~50** (50× plus lent) | **~8 h** | **plusieurs jours** | + +> RTF = *Real-Time Factor* : temps de calcul ÷ durée audio produite. Plus c'est bas, mieux c'est. + +### Activer le GPU (fortement recommandé pour les livres) + +1. Installe une version CUDA de PyTorch dans le venv (voir https://pytorch.org — CUDA ≥ 12.0). +2. Lance avec `--device cuda` : + ``` + .\.venv\Scripts\python.exe app.py --host 127.0.0.1 --port 8808 --device cuda --no-denoiser + ``` + Le modèle demande ~8 Go de VRAM. + +### Astuce vitesse CPU (à tester) + +Sur CPU, le modèle tourne actuellement en **bfloat16 émulé**, ce qui est lent +(`pick_runtime_dtype` dans `src/voxcpm/model/utils.py` ne force `float32` que sur MPS, +pas sur CPU). Forcer `float32` sur CPU utiliserait plus de RAM mais serait probablement +plus rapide. C'est une piste d'optimisation non encore intégrée — demande-la si tu veux +qu'on la teste/mesure. + +## Deux façons de narrer + +### 1. Interface web — pour des extraits / chapitre par chapitre + +Idéale pour tester des voix, générer une méditation, un segment de podcast, ou un +chapitre à la fois. + +- Charge ton texte avec **« 📄 Charger un fichier .txt »** (ou colle-le). +- Choisis une **voix prédéfinie** (le seed et le style se règlent automatiquement). +- Laisse **« Découper les longs textes »** activé (règle la taille de segment avec le + curseur si besoin, 100–600 caractères). +- Clique **« Générer la voix »**. Chaque génération est archivée dans `output/` sous un + nom explicite (`narration__seed_.wav`). + +### 2. Script `narrate_book.py` — pour un livre / long script entier + +Robuste pour les longs contenus : **sauvegarde par chapitre**, **reprise après +interruption**, **économe en mémoire** (un seul chapitre en RAM à la fois). + +Sépare les chapitres de ton `.txt` par une ligne contenant seulement `---` : + +``` +Chapitre premier. ... + +--- + +Chapitre deuxième. ... +``` + +Puis : +``` +# Aperçu du découpage, sans rien générer : +.\.venv\Scripts\python.exe scripts\narrate_book.py livre.txt --voice "Narrateur profond & calme" --dry-run + +# Génération (une .wav par chapitre dans output/book_/) : +.\.venv\Scripts\python.exe scripts\narrate_book.py livre.txt --voice "Narrateur profond & calme" + +# Sur GPU : +.\.venv\Scripts\python.exe scripts\narrate_book.py livre.txt --voice "..." --device cuda +``` + +- **Reprise** : si le script est interrompu, relance la même commande — les chapitres + déjà produits sont ignorés (utilise `--force` pour tout régénérer). +- Options utiles : `--chunk-max-chars`, `--silence`, `--cfg`, `--steps`, `--no-normalize`, + `--chapter-regex` (séparateur de chapitres personnalisé), `--description` + `--seed` + (voix personnalisée au lieu d'un preset). + +## Réglages recommandés par usage + +| Usage | Voix suggérée | Réglages | +|---|---|---| +| **Livre audio (fiction)** | *Narrateur profond & calme* / *Narratrice douce & naturelle* | défauts (CFG 2.0, 10 étapes) | +| **Documentaire / non-fiction** | *Narrateur documentaire velouté* / *Narrateur moderne & professionnel* | défauts | +| **Méditation guidée** | *Méditation guidée (grave & lente)* | augmente `--silence` (ex. 0.6–1.0 s) pour de longues pauses | +| **Podcast** | *Conteur jeune & dynamique* / *Narratrice chaleureuse & conversationnelle* | défauts | + +## Cohérence de la voix sur un long texte + +La voix reste identique d'un segment à l'autre parce que **le même seed est réutilisé +pour tous les segments** (une paire description + seed régénère exactement la même voix). +C'est ce qui garantit un narrateur constant sur tout un livre. + +> Note : les jointures entre segments sont de simples silences. Pour des transitions +> encore plus fluides (prosodie enchaînée via *prompt-cache*), une option expérimentale +> serait possible — demande-la si tu en as besoin. + +## Limites à connaître + +- **Longueur par appel** : ~8 192 tokens max (découpage automatique, donc transparent). +- **Durée par segment** : le moteur vise ~6× la longueur du texte et s'arrête tout seul ; + garde des segments de taille raisonnable (défaut 300 caractères). +- **Sortie** : WAV 48 kHz. Un livre entier concaténé en un seul fichier serait très + lourd en mémoire — c'est pourquoi le script écrit **un fichier par chapitre**. Assemble-les + ensuite avec ton outil audio (ex. `ffmpeg` concat) si tu veux un seul fichier. diff --git a/scripts/narrate_book.py b/scripts/narrate_book.py new file mode 100644 index 00000000..82cad2b6 --- /dev/null +++ b/scripts/narrate_book.py @@ -0,0 +1,158 @@ +"""Narrate a whole book / long script to per-chapter WAV files — robustly. + +Designed for long-form content (books, guided meditations, podcast scripts) where +a single generation call is not possible (the engine errors above ~8192 tokens) +and holding the whole audio in memory is wasteful. + +How it works +------------ +- Reads a UTF-8 ``.txt`` file. Chapters are separated by a line containing only + ``---`` (Markdown horizontal rule) by default, or by ``--chapter-regex``. If no + separator is found, the whole text is treated as a single chapter. +- Each chapter is split into sentence chunks (reusing ``app._split_text_into_chunks``) + so every call stays well under the engine's token limit. +- Chunks are synthesized with the SAME seed for a consistent voice, then stitched + per chapter with a short silence. +- **Memory-safe:** only one chapter is held in memory at a time, never the whole book. +- **Resumable:** a chapter whose output ``.wav`` already exists is skipped, so an + interrupted run continues where it left off. Use ``--force`` to regenerate. +- The denoiser is never loaded (narration uses no reference audio), so startup is + fast and does not touch ModelScope. + +Examples +-------- + # Preview segmentation without generating anything (fast, no model load): + ./.venv/Scripts/python.exe scripts/narrate_book.py livre.txt --voice "Narrateur profond & calme" --dry-run + + # Narrate with a preset voice: + ./.venv/Scripts/python.exe scripts/narrate_book.py livre.txt --voice "Narrateur profond & calme" + + # Narrate with a custom voice (description + seed): + ./.venv/Scripts/python.exe scripts/narrate_book.py livre.txt --description "Voix ..." --seed 123 + + # On a CUDA GPU (far faster): + ./.venv/Scripts/python.exe scripts/narrate_book.py livre.txt --voice "..." --device cuda +""" +import argparse +import re +import sys +import time +from pathlib import Path + +import numpy as np +import soundfile as sf + +# Make the repo root importable so we can reuse app.py's helpers. +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +import app # noqa: E402 + + +def split_chapters(text: str, chapter_regex: str | None) -> list[str]: + """Split the book text into chapters. Defaults to Markdown '---' rules.""" + pattern = chapter_regex if chapter_regex else r"(?m)^\s*---\s*$" + parts = re.split(pattern, text) + chapters = [p.strip() for p in parts if p.strip()] + return chapters or [text.strip()] + + +def resolve_voice(args) -> tuple[str, int | None]: + """Return (description, seed) from a preset name or explicit --description/--seed.""" + if args.voice: + preset = app._PRESET_BY_NAME.get(args.voice) + if preset is None: + names = ", ".join(repr(v["name"]) for v in app.PRESET_VOICES) + raise SystemExit(f"Unknown voice {args.voice!r}. Available presets: {names}") + return preset["description"], preset["seed"] + return (args.description or ""), args.seed + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("input", help="Path to the .txt file to narrate") + parser.add_argument("--voice", help="Preset voice name (see conf/preset_voices.json)") + parser.add_argument("--description", help="Custom voice description (if not using --voice)") + parser.add_argument("--seed", type=int, help="Seed for the custom voice (fixes the voice identity)") + parser.add_argument("--outdir", help="Output directory (default: output/book_)") + parser.add_argument("--device", default="cpu", help="auto, cpu, mps, cuda, or cuda:N (default: cpu)") + parser.add_argument("--model-id", default="openbmb/VoxCPM2", help="Model path or HF repo id") + parser.add_argument("--chunk-max-chars", type=int, default=app._CHUNK_MAX_CHARS, + help=f"Max characters per chunk (default: {app._CHUNK_MAX_CHARS})") + parser.add_argument("--silence", type=float, default=app._CHUNK_SILENCE_SEC, + help=f"Silence between chunks in seconds (default: {app._CHUNK_SILENCE_SEC})") + parser.add_argument("--cfg", type=float, default=2.0, help="CFG guidance scale (default: 2.0)") + parser.add_argument("--steps", type=int, default=10, help="Diffusion steps (default: 10)") + parser.add_argument("--no-normalize", action="store_true", help="Disable text normalization") + parser.add_argument("--chapter-regex", help="Regex (MULTILINE) that separates chapters (default: '^---$')") + parser.add_argument("--force", action="store_true", help="Regenerate chapters even if their .wav exists") + parser.add_argument("--dry-run", action="store_true", help="Show the segmentation plan, generate nothing") + args = parser.parse_args() + + if not args.voice and not args.description: + raise SystemExit("Provide either --voice or --description [--seed N].") + + in_path = Path(args.input) + if not in_path.is_file(): + raise SystemExit(f"Input file not found: {in_path}") + text = in_path.read_text(encoding="utf-8").strip() + if not text: + raise SystemExit(f"Input file is empty: {in_path}") + + description, seed = resolve_voice(args) + chapters = split_chapters(text, args.chapter_regex) + outdir = Path(args.outdir) if args.outdir else app._OUTPUT_DIR / f"book_{app._sanitize_filename(in_path.stem)}" + + # Plan: chunk every chapter up front so --dry-run can show the full picture. + plan = [(i, ch, app._split_text_into_chunks(ch, args.chunk_max_chars)) for i, ch in enumerate(chapters, 1)] + total_chunks = sum(len(chunks) for _, _, chunks in plan) + total_chars = sum(len(ch) for ch in chapters) + print(f"Input : {in_path}") + print(f"Voice : {args.voice or '(custom)'} | seed={seed}") + print(f"Chapters : {len(chapters)} | chunks: {total_chunks} | chars: {total_chars}") + print(f"Output dir : {outdir}") + for i, _, chunks in plan: + print(f" chapter {i:03d}: {len(chunks)} chunk(s)") + + if args.dry_run: + print("\nDry run — nothing generated.") + return 0 + + outdir.mkdir(parents=True, exist_ok=True) + demo = app.VoxCPMDemo(model_id=args.model_id, device=args.device, load_denoiser=False) + normalize = not args.no_normalize + + started = time.strftime("%H:%M:%S") + print(f"\nStarting narration at {started} (device={args.device}). This is slow on CPU.\n", flush=True) + + for i, _, chunks in plan: + out = outdir / f"chapitre_{i:03d}.wav" + if out.is_file() and not args.force: + print(f"[chapter {i:03d}/{len(plan)}] exists, skipping -> {out.name}", flush=True) + continue + print(f"[chapter {i:03d}/{len(plan)}] {len(chunks)} chunk(s) ...", flush=True) + parts: list[np.ndarray] = [] + sr = None + for j, chunk in enumerate(chunks): + sr, wav, _ = demo.generate_tts_audio( + text_input=chunk, + control_instruction=description, + cfg_value_input=args.cfg, + do_normalize=normalize, + inference_timesteps=args.steps, + seed=seed, + ) + if j > 0: + parts.append(np.zeros(int(sr * args.silence), dtype=wav.dtype)) + parts.append(wav) + print(f" chunk {j + 1}/{len(chunks)} done", flush=True) + sf.write(str(out), np.concatenate(parts), sr) + dur = len(np.concatenate(parts)) / sr + print(f"[chapter {i:03d}/{len(plan)}] saved -> {out.name} ({dur:.1f}s)", flush=True) + + print(f"\nDone. Chapter files are in: {outdir}", flush=True) + print("Tip: concatenate them into one file with your audio tool, e.g. ffmpeg concat.", flush=True) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 8c7e555f139b932ca5dfae0876c81b576289f654 Mon Sep 17 00:00:00 2001 From: PaxHelios Date: Mon, 27 Jul 2026 23:46:48 +0200 Subject: [PATCH 07/98] perf(engine): use float32 on CPU for low-precision checkpoints (~1.5x faster) CPU has no native bfloat16 acceleration, so the checkpoint's bfloat16 is emulated and slow. Mirror the existing MPS logic: upcast low-precision dtypes to float32 on CPU. Measured ~1.48x speedup (RTF 60.9 -> 41.1 on the same phrase/seed) at the cost of ~2x RAM. Opt back into bfloat16 with VOXCPM_CPU_DTYPE=bfloat16. Also document the result in docs/NARRATION.md. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01BYwW5KLZYovWstkx9aCwiq --- docs/NARRATION.md | 16 +++++++++------- src/voxcpm/model/utils.py | 23 +++++++++++++++-------- 2 files changed, 24 insertions(+), 15 deletions(-) diff --git a/docs/NARRATION.md b/docs/NARRATION.md index 639b8e9d..30aa1b51 100644 --- a/docs/NARRATION.md +++ b/docs/NARRATION.md @@ -22,7 +22,7 @@ podcast, etc. |---|---|---|---| | RTX 4090 (CUDA) | ~0.30 (≈3× plus rapide que le réel) | ~3 min | **< 1 h** | | Apple M4 Pro (Metal) | ~1.76 | ~18 min | ~5 h | -| **CPU seul (cette machine)** | **~50** (50× plus lent) | **~8 h** | **plusieurs jours** | +| **CPU seul (float32, cette machine)** | **~41** (41× plus lent) | **~7 h** | **plusieurs jours** | > RTF = *Real-Time Factor* : temps de calcul ÷ durée audio produite. Plus c'est bas, mieux c'est. @@ -35,13 +35,15 @@ podcast, etc. ``` Le modèle demande ~8 Go de VRAM. -### Astuce vitesse CPU (à tester) +### Vitesse CPU : float32 par défaut (~1,5× plus rapide) -Sur CPU, le modèle tourne actuellement en **bfloat16 émulé**, ce qui est lent -(`pick_runtime_dtype` dans `src/voxcpm/model/utils.py` ne force `float32` que sur MPS, -pas sur CPU). Forcer `float32` sur CPU utiliserait plus de RAM mais serait probablement -plus rapide. C'est une piste d'optimisation non encore intégrée — demande-la si tu veux -qu'on la teste/mesure. +Sur CPU, le `bfloat16` du checkpoint est **émulé** et lent. Ce fork force donc +`float32` sur CPU (voir `pick_runtime_dtype` dans `src/voxcpm/model/utils.py`), ce qui +est **~1,5× plus rapide** (mesuré : RTF 60,9 → 41,1 sur la même phrase, soit −33 % de +temps) au prix d'un peu plus de RAM. + +Pour revenir à l'ancien comportement bfloat16 : `set VOXCPM_CPU_DTYPE=bfloat16` avant de +lancer l'app (ou export sous bash). ## Deux façons de narrer diff --git a/src/voxcpm/model/utils.py b/src/voxcpm/model/utils.py index f6d7463c..97e08b3c 100644 --- a/src/voxcpm/model/utils.py +++ b/src/voxcpm/model/utils.py @@ -160,23 +160,30 @@ def _has_mps() -> bool: def pick_runtime_dtype(device: str, configured_dtype: str) -> str: - """Pick a safe runtime dtype for the resolved device. + """Pick a safe/efficient runtime dtype for the resolved device. On Apple Silicon (MPS), bfloat16/float16 produce enough numerical drift in the diffusion AR loop that the output is glitched and the model's - badcase detector triggers infinite retries. float32 is the only stable - option today. CUDA and CPU keep whatever the checkpoint was trained with. + badcase detector triggers infinite retries — float32 is the only stable + option today. - Users can override with ``VOXCPM_MPS_DTYPE`` (e.g. ``bfloat16``) when - they want to test future MPS improvements. + On CPU, bfloat16/float16 have no native acceleration and are emulated, which + is markedly slower than native float32; we therefore upcast low-precision + checkpoints to float32 for speed (at the cost of ~2x RAM). + + CUDA keeps whatever the checkpoint was trained with. + + Override per device with ``VOXCPM_MPS_DTYPE`` / ``VOXCPM_CPU_DTYPE`` + (e.g. ``bfloat16``) to opt back into the checkpoint dtype. """ - if device != "mps": + env_var = {"mps": "VOXCPM_MPS_DTYPE", "cpu": "VOXCPM_CPU_DTYPE"}.get(device) + if env_var is None: # cuda (and any other device): keep the checkpoint dtype return configured_dtype - override = os.environ.get("VOXCPM_MPS_DTYPE", "").strip().lower() + override = os.environ.get(env_var, "").strip().lower() if override: if override not in _VALID_DTYPE_OVERRIDES: - raise ValueError(f"VOXCPM_MPS_DTYPE='{override}' is not one of " f"{sorted(_VALID_DTYPE_OVERRIDES)}") + raise ValueError(f"{env_var}='{override}' is not one of {sorted(_VALID_DTYPE_OVERRIDES)}") return override if (configured_dtype or "").lower() in _LOW_PRECISION_DTYPES: From 08b992d0306154fc04385bcf80364c1317508fd0 Mon Sep 17 00:00:00 2001 From: PaxHelios Date: Tue, 28 Jul 2026 00:25:44 +0200 Subject: [PATCH 08/98] feat(narrate_book): experimental --continuity (prompt-cache chaining) Add an opt-in --continuity flag: each chunk after the first is generated as a continuation of the previous chunk (its audio + text as the prompt cache) for smoother prosodic joins, instead of same-seed-only. Uses a bounded 1-chunk window (never overflows the KV cache) and resets at each chapter boundary; temp prompt files are cleaned up. Mechanism validated as functional on CPU (the continuation path runs and makes normal progress, no badcase retries); it is markedly slower than plain Voice Design, so quality tuning is best done on a GPU. Default behavior is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01BYwW5KLZYovWstkx9aCwiq --- scripts/narrate_book.py | 68 +++++++++++++++++++++++++++++++---------- 1 file changed, 52 insertions(+), 16 deletions(-) diff --git a/scripts/narrate_book.py b/scripts/narrate_book.py index 82cad2b6..40f16b50 100644 --- a/scripts/narrate_book.py +++ b/scripts/narrate_book.py @@ -35,7 +35,9 @@ """ import argparse import re +import os import sys +import tempfile import time from pathlib import Path @@ -86,6 +88,10 @@ def main() -> int: parser.add_argument("--chapter-regex", help="Regex (MULTILINE) that separates chapters (default: '^---$')") parser.add_argument("--force", action="store_true", help="Regenerate chapters even if their .wav exists") parser.add_argument("--dry-run", action="store_true", help="Show the segmentation plan, generate nothing") + parser.add_argument("--continuity", action="store_true", + help="EXPERIMENTAL: chain each chunk from the previous one (prompt-cache " + "continuation) for smoother joins, instead of same-seed only. Slower; " + "resets at each chapter boundary. Tune on a GPU (slow to iterate on CPU).") args = parser.parse_args() if not args.voice and not args.description: @@ -132,22 +138,52 @@ def main() -> int: print(f"[chapter {i:03d}/{len(plan)}] {len(chunks)} chunk(s) ...", flush=True) parts: list[np.ndarray] = [] sr = None - for j, chunk in enumerate(chunks): - sr, wav, _ = demo.generate_tts_audio( - text_input=chunk, - control_instruction=description, - cfg_value_input=args.cfg, - do_normalize=normalize, - inference_timesteps=args.steps, - seed=seed, - ) - if j > 0: - parts.append(np.zeros(int(sr * args.silence), dtype=wav.dtype)) - parts.append(wav) - print(f" chunk {j + 1}/{len(chunks)} done", flush=True) - sf.write(str(out), np.concatenate(parts), sr) - dur = len(np.concatenate(parts)) / sr - print(f"[chapter {i:03d}/{len(plan)}] saved -> {out.name} ({dur:.1f}s)", flush=True) + # Continuity: chain each chunk from the immediately previous one only + # (bounded window → never overflows the KV cache). Reset per chapter. + prev_wav_path: str | None = None + prev_text: str | None = None + tmp_paths: list[str] = [] + try: + for j, chunk in enumerate(chunks): + if args.continuity and prev_wav_path is not None: + # Voice comes from the running audio, so drop the control text. + sr, wav, _ = demo.generate_tts_audio( + text_input=chunk, + control_instruction="", + reference_wav_path_input=prev_wav_path, + prompt_text=prev_text, + cfg_value_input=args.cfg, + do_normalize=normalize, + inference_timesteps=args.steps, + seed=seed, + ) + else: + sr, wav, _ = demo.generate_tts_audio( + text_input=chunk, + control_instruction=description, + cfg_value_input=args.cfg, + do_normalize=normalize, + inference_timesteps=args.steps, + seed=seed, + ) + if j > 0: + parts.append(np.zeros(int(sr * args.silence), dtype=wav.dtype)) + parts.append(wav) + if args.continuity: # stash this chunk as the prompt for the next one + with tempfile.NamedTemporaryFile(delete=False, suffix=".wav") as tmp: + tmp_paths.append(tmp.name) + sf.write(tmp_paths[-1], wav, sr) + prev_wav_path, prev_text = tmp_paths[-1], chunk + print(f" chunk {j + 1}/{len(chunks)} done", flush=True) + finally: + for p in tmp_paths: + try: + os.unlink(p) + except OSError: + pass + book = np.concatenate(parts) + sf.write(str(out), book, sr) + print(f"[chapter {i:03d}/{len(plan)}] saved -> {out.name} ({len(book) / sr:.1f}s)", flush=True) print(f"\nDone. Chapter files are in: {outdir}", flush=True) print("Tip: concatenate them into one file with your audio tool, e.g. ffmpeg concat.", flush=True) From e43c57daf297cafc361f6a0109466f191b4a9e42 Mon Sep 17 00:00:00 2001 From: PaxHelios Date: Tue, 28 Jul 2026 21:58:40 +0200 Subject: [PATCH 09/98] feat(narration): torch-free pipeline package for audiobook production MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extracts the long-form narration logic out of app.py and narrate_book.py into a `narration` package that depends only on the stdlib, numpy and soundfile — never torch or gradio. Model load alone takes minutes on a CPU-only machine, so keeping these stages importable without it is what makes them unit-testable at all. Stages, in pipeline order: text_fr French text preparation — numbers, abbreviations, roman numerals and a user lexicon (conf/pronunciation_fr.json) rewritten into what the engine should actually say chunking segmentation into engine-sized pieces plus a pause plan derived from punctuation cache content-addressed store, so an interrupted run resumes at the segment rather than restarting the chapter audio trimming, de-clicking and loudness mastering assemble chapters joined into a single MP3/M4B with markers Covered by 192 tests. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01UqWxj2j9bdavcLn25ckX8X --- conf/pronunciation_fr.json | 13 + narration/__init__.py | 18 ++ narration/assemble.py | 296 +++++++++++++++++++ narration/audio.py | 386 +++++++++++++++++++++++++ narration/cache.py | 195 +++++++++++++ narration/chunking.py | 163 +++++++++++ narration/text_fr.py | 470 +++++++++++++++++++++++++++++++ tests/test_narration_assemble.py | 175 ++++++++++++ tests/test_narration_audio.py | 216 ++++++++++++++ tests/test_narration_cache.py | 120 ++++++++ tests/test_narration_chunking.py | 101 +++++++ tests/test_narration_text_fr.py | 272 ++++++++++++++++++ 12 files changed, 2425 insertions(+) create mode 100644 conf/pronunciation_fr.json create mode 100644 narration/__init__.py create mode 100644 narration/assemble.py create mode 100644 narration/audio.py create mode 100644 narration/cache.py create mode 100644 narration/chunking.py create mode 100644 narration/text_fr.py create mode 100644 tests/test_narration_assemble.py create mode 100644 tests/test_narration_audio.py create mode 100644 tests/test_narration_cache.py create mode 100644 tests/test_narration_chunking.py create mode 100644 tests/test_narration_text_fr.py diff --git a/conf/pronunciation_fr.json b/conf/pronunciation_fr.json new file mode 100644 index 00000000..bf7b51fe --- /dev/null +++ b/conf/pronunciation_fr.json @@ -0,0 +1,13 @@ +{ + "_comment": "Lexique de prononciation. Clé = ce qui est écrit dans le texte, valeur = ce qui doit être prononcé. Le remplacement est insensible à la casse et ne s'applique qu'à des mots entiers. Les clés commençant par _ sont ignorées (commentaires). Utile surtout pour les noms propres, les sigles et les mots étrangers d'un livre donné.", + "_exemple_sigles": "--- sigles lus lettre par lettre ---", + "SNCF": "S N C F", + "RATP": "R A T P", + "ONU": "O N U", + "URSS": "U R S S", + "_exemple_etrangers": "--- mots étrangers ---", + "Wi-Fi": "wifi", + "email": "i-mail", + "_exemple_noms": "--- noms propres à adapter à votre livre ---", + "Nietzsche": "Nitche" +} diff --git a/narration/__init__.py b/narration/__init__.py new file mode 100644 index 00000000..bf3f8dee --- /dev/null +++ b/narration/__init__.py @@ -0,0 +1,18 @@ +"""Audiobook narration toolkit built on top of the VoxCPM engine. + +This package deliberately depends only on the standard library, ``numpy`` and +``soundfile`` — never on ``torch`` or ``gradio``. That keeps every stage of the +production chain (text preparation, segmentation, audio mastering, assembly) +importable and unit-testable without loading a multi-gigabyte model, which +matters a lot on a CPU-only machine where model load alone takes minutes. + +Stages, in pipeline order:: + + text_fr prepare raw French prose for a TTS engine + chunking cut prepared text into engine-sized segments + pause plan + cache content-addressed store so an interrupted run resumes per chunk + audio trim, master and stitch the generated segments + assemble join chapters into a single MP3/M4B with chapter markers +""" + +__all__ = ["assemble", "audio", "cache", "chunking", "text_fr"] diff --git a/narration/assemble.py b/narration/assemble.py new file mode 100644 index 00000000..ad724a17 --- /dev/null +++ b/narration/assemble.py @@ -0,0 +1,296 @@ +"""Join per-chapter WAVs into one deliverable audiobook file. + +A directory of forty WAV files is not an audiobook. A listener expects a single +file that remembers where they stopped, with chapters they can skip between — +which means chapter markers, and in practice M4B or a chaptered MP3. + +Two responsibilities, deliberately split: + +* Concatenation is done here, streaming, with ``soundfile`` alone. It always + works, it never loads a ten-hour book into memory, and it writes 16-bit PCM + because that is both the delivery format and a quarter the size of float32. +* Encoding to MP3/M4B needs ``ffmpeg``, which may not be installed. When it is + missing the concatenated WAV and the chapter-marker file are still produced, + and the exact command to run later is returned — a missing encoder must not + cost the hours of synthesis that went into the audio. +""" +from __future__ import annotations + +import shutil +import subprocess +from dataclasses import dataclass, field +from pathlib import Path +from typing import Dict, List, Optional, Sequence + +import soundfile as sf + +__all__ = [ + "Chapter", + "AssemblyResult", + "assemble", + "build_ffmetadata", + "concat_chapters", + "ffmpeg_command", + "find_ffmpeg", +] + +#: Encoder settings per container. Audiobook speech does not benefit from more. +_ENCODERS: Dict[str, Dict[str, str]] = { + "m4b": {"codec": "aac", "bitrate": "64k"}, + "m4a": {"codec": "aac", "bitrate": "64k"}, + "mp3": {"codec": "libmp3lame", "bitrate": "96k"}, +} + +#: Silence inserted between chapters in the concatenated file, in seconds. +DEFAULT_CHAPTER_GAP_SEC = 1.5 + +_WRITE_BLOCK_FRAMES = 1 << 16 + + +@dataclass +class Chapter: + """A source file plus where it lands in the finished book.""" + + path: Path + title: str + start_sec: float = 0.0 + duration_sec: float = 0.0 + + @property + def end_sec(self) -> float: + return self.start_sec + self.duration_sec + + +@dataclass +class AssemblyResult: + """What assembly produced, and what is left to do by hand.""" + + wav_path: Path + metadata_path: Optional[Path] + output_path: Optional[Path] + chapters: List[Chapter] = field(default_factory=list) + duration_sec: float = 0.0 + sample_rate: int = 0 + #: Set when ffmpeg was unavailable or failed — run this to finish the job. + pending_command: Optional[List[str]] = None + message: str = "" + + +def find_ffmpeg() -> Optional[str]: + """Path to an ffmpeg binary, or None if it is not installed.""" + return shutil.which("ffmpeg") + + +def _title_from_path(path: Path, index: int) -> str: + """Readable chapter title from a filename like ``chapitre_003.wav``.""" + stem = path.stem.replace("_", " ").replace("-", " ").strip() + return stem[:1].upper() + stem[1:] if stem else f"Chapitre {index}" + + +def concat_chapters( + chapter_paths: Sequence[str | Path], + out_wav: str | Path, + *, + gap_sec: float = DEFAULT_CHAPTER_GAP_SEC, + titles: Optional[Sequence[str]] = None, + subtype: str = "PCM_16", +) -> tuple[List[Chapter], int]: + """Stream chapter WAVs into a single file and record chapter boundaries. + + Streaming rather than concatenating arrays keeps memory flat regardless of + book length. Returns the chapters with their start times filled in, and the + sample rate. + """ + paths = [Path(p) for p in chapter_paths] + if not paths: + raise ValueError("No chapter files to assemble.") + missing = [p for p in paths if not p.is_file()] + if missing: + raise FileNotFoundError(f"Chapter file(s) not found: {', '.join(str(p) for p in missing)}") + + infos = [sf.info(str(p)) for p in paths] + rates = {info.samplerate for info in infos} + if len(rates) > 1: + raise ValueError( + f"Chapters have different sample rates ({sorted(rates)}); " + "they must all be generated with the same model settings." + ) + sample_rate = infos[0].samplerate + + out_path = Path(out_wav) + out_path.parent.mkdir(parents=True, exist_ok=True) + gap_frames = max(0, int(round(sample_rate * max(0.0, gap_sec)))) + + chapters: List[Chapter] = [] + cursor = 0 # frames written so far + with sf.SoundFile( + str(out_path), mode="w", samplerate=sample_rate, channels=1, subtype=subtype + ) as sink: + import numpy as np # local: only needed for the inter-chapter silence + + gap = np.zeros(gap_frames, dtype="float32") + for index, (path, info) in enumerate(zip(paths, infos), start=1): + if index > 1 and gap_frames: + sink.write(gap) + cursor += gap_frames + start_frames = cursor + with sf.SoundFile(str(path)) as source: + while True: + block = source.read(_WRITE_BLOCK_FRAMES, dtype="float32", always_2d=False) + if not len(block): + break + if block.ndim > 1: + block = block.mean(axis=1) + sink.write(block) + cursor += len(block) + title = titles[index - 1] if titles and index <= len(titles) else _title_from_path(path, index) + chapters.append( + Chapter( + path=path, + title=title, + start_sec=start_frames / sample_rate, + duration_sec=(cursor - start_frames) / sample_rate, + ) + ) + return chapters, sample_rate + + +def build_ffmetadata( + chapters: Sequence[Chapter], + *, + title: str = "", + author: str = "", + album: str = "", + year: str = "", + genre: str = "Audiobook", +) -> str: + """Render an ffmpeg FFMETADATA document carrying the chapter markers.""" + lines = [";FFMETADATA1"] + for key, value in ( + ("title", title), + ("artist", author), + ("album", album or title), + ("date", year), + ("genre", genre), + ): + if value: + lines.append(f"{key}={_escape_metadata(value)}") + + # Each marker runs up to the next chapter's start rather than to the end of + # its own audio, so the inter-chapter silence belongs to the chapter that + # precedes it. Leaving those gaps unclaimed makes players show "no chapter" + # while the book is still playing. + for index, chapter in enumerate(chapters): + is_last = index == len(chapters) - 1 + end_sec = chapter.end_sec if is_last else chapters[index + 1].start_sec + lines += [ + "", + "[CHAPTER]", + "TIMEBASE=1/1000", + f"START={int(round(chapter.start_sec * 1000))}", + f"END={int(round(end_sec * 1000))}", + f"title={_escape_metadata(chapter.title)}", + ] + return "\n".join(lines) + "\n" + + +def _escape_metadata(value: str) -> str: + """FFMETADATA treats ``= ; # \\`` and newlines as syntax.""" + for char in ("\\", "=", ";", "#"): + value = value.replace(char, f"\\{char}") + return value.replace("\n", " ").strip() + + +def ffmpeg_command( + wav_path: str | Path, + metadata_path: str | Path, + out_path: str | Path, + *, + cover_path: Optional[str | Path] = None, +) -> List[str]: + """The ffmpeg invocation that turns the WAV into the final chaptered file.""" + out = Path(out_path) + encoder = _ENCODERS.get(out.suffix.lstrip(".").lower(), _ENCODERS["m4b"]) + + command = ["ffmpeg", "-y", "-i", str(wav_path), "-i", str(metadata_path)] + if cover_path: + command += ["-i", str(cover_path)] + command += ["-map", "0:a", "-map_metadata", "1"] + if cover_path: + command += ["-map", "2:v", "-disposition:v", "attached_pic", "-c:v", "copy"] + command += ["-c:a", encoder["codec"], "-b:a", encoder["bitrate"], "-ac", "1"] + if out.suffix.lower() in (".m4b", ".m4a"): + command += ["-movflags", "+faststart"] + command.append(str(out)) + return command + + +def assemble( + chapter_paths: Sequence[str | Path], + out_path: str | Path, + *, + title: str = "", + author: str = "", + titles: Optional[Sequence[str]] = None, + gap_sec: float = DEFAULT_CHAPTER_GAP_SEC, + cover_path: Optional[str | Path] = None, + keep_wav: bool = True, +) -> AssemblyResult: + """Build a single chaptered audiobook from per-chapter WAVs. + + The concatenated WAV and the chapter-marker file are written first and + unconditionally, so that an absent or failing ffmpeg costs only the encoding + step — never the synthesis. + """ + out = Path(out_path) + out.parent.mkdir(parents=True, exist_ok=True) + wav_path = out.with_suffix(".wav") if out.suffix.lower() != ".wav" else out + + chapters, sample_rate = concat_chapters( + chapter_paths, wav_path, gap_sec=gap_sec, titles=titles + ) + duration = chapters[-1].end_sec if chapters else 0.0 + + metadata_path = out.with_suffix(".chapters.txt") + metadata_path.write_text( + build_ffmetadata(chapters, title=title or out.stem, author=author), + encoding="utf-8", + ) + + result = AssemblyResult( + wav_path=wav_path, + metadata_path=metadata_path, + output_path=None, + chapters=chapters, + duration_sec=duration, + sample_rate=sample_rate, + ) + + if wav_path == out: + result.output_path = out + result.message = "Assembled to WAV (chapter markers written alongside)." + return result + + command = ffmpeg_command(wav_path, metadata_path, out, cover_path=cover_path) + if not find_ffmpeg(): + result.pending_command = command + result.message = ( + "ffmpeg not found — the concatenated WAV and chapter markers are ready. " + "Install ffmpeg and run the reported command to produce " + f"{out.name} with chapter markers." + ) + return result + + completed = subprocess.run(command, capture_output=True, text=True) + if completed.returncode != 0: + result.pending_command = command + tail = (completed.stderr or "").strip().splitlines()[-3:] + result.message = "ffmpeg failed: " + " / ".join(tail) + return result + + result.output_path = out + result.message = f"Wrote {out.name} with {len(chapters)} chapter marker(s)." + if not keep_wav and wav_path.is_file(): + wav_path.unlink(missing_ok=True) + result.wav_path = out + return result diff --git a/narration/audio.py b/narration/audio.py new file mode 100644 index 00000000..63882854 --- /dev/null +++ b/narration/audio.py @@ -0,0 +1,386 @@ +"""Master and stitch generated speech into audiobook-grade audio. + +Raw TTS output is not publishable as-is. Each generated segment carries a little +silence at its edges, its level drifts from one segment to the next, and butting +segments together end-to-end produces audible clicks and a robotic, pause-less +read. This module fixes all three, and measures the result against the levels +audiobook distributors actually check. + +The reference target is the ACX specification, which every major audiobook +platform mirrors: RMS between -23 and -18 dBFS, peak no higher than -3 dBFS, and +a noise floor below -60 dBFS. :func:`acx_report` reports all three so a chapter +can be checked before it is ever uploaded. + +Pure ``numpy`` on purpose — no resampling library, no loudness package. Frame +energies are computed from a cumulative sum rather than a sliding window so that +a one-hour chapter costs O(n) memory instead of tens of gigabytes. +""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import Iterable, List, Optional, Sequence, Tuple + +import numpy as np + +__all__ = [ + "ACX_PEAK_CEILING_DB", + "ACX_RMS_MAX_DB", + "ACX_RMS_MIN_DB", + "ACX_NOISE_FLOOR_DB", + "MasteringSettings", + "acx_report", + "as_float_mono", + "fade_edges", + "master_segment", + "noise_floor_db", + "normalize_level", + "peak_db", + "remove_dc", + "silence", + "speech_rms_db", + "stitch", + "trim_silence", +] + +#: ACX / audiobook distribution limits, in dBFS. +ACX_RMS_MIN_DB = -23.0 +ACX_RMS_MAX_DB = -18.0 +ACX_PEAK_CEILING_DB = -3.0 +ACX_NOISE_FLOOR_DB = -60.0 + +_EPS = 1e-12 +#: Below this peak level a signal carries no usable level to correct. +_SILENCE_FLOOR_DB = -120.0 +#: Below this, a frame is silence for any purpose. Mirrors the BS.1770 absolute gate. +_ABSOLUTE_GATE_DB = -70.0 +#: Frames quieter than the ungated mean by this much do not count as speech. +_RELATIVE_GATE_DB = 10.0 + + +@dataclass(frozen=True) +class MasteringSettings: + """How a segment and a finished chapter should be treated. + + Defaults aim at the middle of the ACX window (-20 dBFS RMS), which leaves + room on both sides for the level drift between separately generated segments. + """ + + target_rms_db: float = -20.0 + peak_ceiling_db: float = ACX_PEAK_CEILING_DB + trim_silence: bool = True + #: A segment edge is considered silent this far below its own speech level. + trim_relative_db: float = 25.0 + #: Silence deliberately kept at each edge, so words never start abruptly. + trim_keep_ms: float = 60.0 + #: Click-free ramp applied to every segment edge. + fade_ms: float = 8.0 + #: Silence before the first word and after the last one, in seconds. + lead_sec: float = 0.3 + tail_sec: float = 0.6 + + +# -------------------------------------------------------------------------- +# Measurement +# -------------------------------------------------------------------------- + + +def as_float_mono(wav: np.ndarray) -> np.ndarray: + """Return the signal as 1-D float32, averaging channels. + + Conversion only — no level or offset is touched here, so that a measurement + reports what is actually in the file. + """ + data = np.asarray(wav) + if data.ndim > 1: + # soundfile hands back (frames, channels); anything else is already flat. + data = data.mean(axis=1) if data.shape[0] >= data.shape[-1] else data.mean(axis=0) + return data.astype(np.float32, copy=False) + + +def remove_dc(wav: np.ndarray) -> np.ndarray: + """Centre the waveform on zero. + + A constant offset eats headroom and makes every join click, but it is not + audible in itself, so it is easy to ship by accident. + """ + data = as_float_mono(wav) + if data.size == 0: + return data + return data - np.float32(data.mean()) + + +def _to_db(amplitude: float) -> float: + return 20.0 * float(np.log10(max(float(amplitude), _EPS))) + + +def _frame_power(wav: np.ndarray, sr: int, frame_ms: float, hop_ms: float) -> np.ndarray: + """Mean square of every frame, computed from a cumulative sum. + + A sliding-window view would allocate frame_length x n_frames floats — over + 100 GB for a long chapter — so the energies come from prefix sums instead. + """ + frame = max(1, int(sr * frame_ms / 1000.0)) + hop = max(1, int(sr * hop_ms / 1000.0)) + if wav.size < frame: + return np.array([float(np.mean(np.square(wav, dtype=np.float64)))]) if wav.size else np.zeros(0) + + cumulative = np.concatenate(([0.0], np.cumsum(np.square(wav, dtype=np.float64)))) + starts = np.arange(0, wav.size - frame + 1, hop) + return (cumulative[starts + frame] - cumulative[starts]) / frame + + +def speech_rms_db(wav: np.ndarray, sr: int, frame_ms: float = 400.0, hop_ms: float = 100.0) -> float: + """RMS level in dBFS, measured over speech only. + + Silence between sentences must not count: a chapter with generous pauses + would otherwise measure several dB quieter than it sounds, and normalising + against that figure would push the actual speech above the peak ceiling. + """ + wav = as_float_mono(wav) + if wav.size == 0: + return -np.inf + + power = _frame_power(wav, sr, frame_ms, hop_ms) + if power.size == 0: + return -np.inf + + absolute_gate = 10.0 ** (_ABSOLUTE_GATE_DB / 10.0) + kept = power[power > absolute_gate] + if kept.size == 0: + return _to_db(float(np.sqrt(power.mean()))) + + # Second, relative pass: drop everything well below the ungated average. + relative_gate = kept.mean() * 10.0 ** (-_RELATIVE_GATE_DB / 10.0) + speech = kept[kept > relative_gate] + if speech.size == 0: + speech = kept + return _to_db(float(np.sqrt(speech.mean()))) + + +def peak_db(wav: np.ndarray) -> float: + """Sample peak in dBFS.""" + wav = as_float_mono(wav) + if wav.size == 0: + return -np.inf + return _to_db(float(np.max(np.abs(wav)))) + + +def noise_floor_db(wav: np.ndarray, sr: int, percentile: float = 10.0) -> float: + """Level of the quietest part of the signal, in dBFS. + + Taken as a low percentile of short-frame energies, so a single clean gap is + enough to characterise the floor without a silence detector. + """ + wav = as_float_mono(wav) + if wav.size == 0: + return -np.inf + power = _frame_power(wav, sr, frame_ms=50.0, hop_ms=25.0) + if power.size == 0: + return -np.inf + return _to_db(float(np.sqrt(max(np.percentile(power, percentile), 0.0)))) + + +def acx_report(wav: np.ndarray, sr: int) -> dict: + """Measure a chapter against the ACX limits and say which ones it meets.""" + rms = speech_rms_db(wav, sr) + peak = peak_db(wav) + floor = noise_floor_db(wav, sr) + checks = { + "rms_ok": ACX_RMS_MIN_DB <= rms <= ACX_RMS_MAX_DB, + "peak_ok": peak <= ACX_PEAK_CEILING_DB, + "noise_floor_ok": floor <= ACX_NOISE_FLOOR_DB, + } + return { + "rms_db": rms, + "peak_db": peak, + "noise_floor_db": floor, + "duration_sec": float(np.asarray(wav).shape[0]) / sr if sr else 0.0, + **checks, + "compliant": all(checks.values()), + } + + +# -------------------------------------------------------------------------- +# Processing +# -------------------------------------------------------------------------- + + +def silence(sr: int, seconds: float) -> np.ndarray: + """A block of digital silence.""" + return np.zeros(max(0, int(round(sr * max(0.0, seconds)))), dtype=np.float32) + + +def trim_silence( + wav: np.ndarray, + sr: int, + *, + relative_db: float = 25.0, + keep_ms: float = 60.0, + frame_ms: float = 20.0, +) -> np.ndarray: + """Cut leading and trailing silence, keeping a short margin. + + The threshold is relative to the segment's own speech level rather than an + absolute dBFS value, because segments arrive un-normalised and a fixed + threshold would either clip the start of a quiet segment or trim nothing at + all from a loud one. + """ + wav = as_float_mono(wav) + if wav.size == 0: + return wav + + hop_ms = frame_ms / 2.0 + power = _frame_power(wav, sr, frame_ms, hop_ms) + if power.size == 0: + return wav + + threshold = 10.0 ** ((speech_rms_db(wav, sr) - relative_db) / 10.0) + loud = np.flatnonzero(power > threshold) + if loud.size == 0: + return wav + + hop = max(1, int(sr * hop_ms / 1000.0)) + frame = max(1, int(sr * frame_ms / 1000.0)) + margin = int(sr * max(0.0, keep_ms) / 1000.0) + + start = max(0, int(loud[0]) * hop - margin) + end = min(wav.size, int(loud[-1]) * hop + frame + margin) + return wav[start:end] + + +def fade_edges(wav: np.ndarray, sr: int, fade_ms: float = 8.0) -> np.ndarray: + """Ramp the first and last few milliseconds so joins do not click. + + Cutting a waveform at a non-zero sample leaves a step discontinuity, which is + exactly the click heard at every segment boundary in naive concatenation. + """ + wav = as_float_mono(wav).copy() + length = int(sr * max(0.0, fade_ms) / 1000.0) + if length <= 0 or wav.size == 0: + return wav + length = min(length, wav.size // 2) + if length <= 0: + return wav + ramp = np.linspace(0.0, 1.0, length, dtype=np.float32) + wav[:length] *= ramp + wav[-length:] *= ramp[::-1] + return wav + + +def normalize_level( + wav: np.ndarray, + sr: int, + *, + target_rms_db: float = -20.0, + peak_ceiling_db: float = ACX_PEAK_CEILING_DB, +) -> Tuple[np.ndarray, float]: + """Scale to the target speech RMS without breaching the peak ceiling. + + Returns ``(audio, applied_gain_db)``. When the RMS target would push peaks + above the ceiling the gain is reduced to respect the ceiling instead: a + breached ceiling is a hard rejection at distribution, a slightly quiet + chapter is not. + """ + wav = as_float_mono(wav) + if wav.size == 0: + return wav, 0.0 + + current_peak = peak_db(wav) + current_rms = speech_rms_db(wav, sr) + # Silence carries no level to correct. Without this guard, an all-zero + # segment measures around -240 dBFS and asks for 220 dB of gain — harmless + # on true digital silence, but it would explode any dither or DC residue. + if not np.isfinite(current_rms) or current_peak < _SILENCE_FLOOR_DB: + return wav, 0.0 + + gain_db = target_rms_db - current_rms + if np.isfinite(current_peak): + gain_db = min(gain_db, peak_ceiling_db - current_peak) + + gain = float(10.0 ** (gain_db / 20.0)) + return (wav * np.float32(gain)).astype(np.float32), float(gain_db) + + +def master_segment( + wav: np.ndarray, + sr: int, + settings: MasteringSettings = MasteringSettings(), +) -> np.ndarray: + """Trim and de-click a single generated segment. + + Level is deliberately *not* set here. Normalising each segment separately + would flatten the natural dynamics between a whispered line and a shouted + one; the chapter is normalised once, as a whole, in :func:`stitch`. + """ + wav = remove_dc(wav) + if wav.size == 0: + return wav + if settings.trim_silence: + wav = trim_silence( + wav, + sr, + relative_db=settings.trim_relative_db, + keep_ms=settings.trim_keep_ms, + ) + return fade_edges(wav, sr, settings.fade_ms) + + +def stitch( + segments: Sequence[Tuple[np.ndarray, float]], + sr: int, + settings: MasteringSettings = MasteringSettings(), + *, + normalize: bool = True, +) -> np.ndarray: + """Assemble ``(audio, pause_after_seconds)`` pairs into one mastered chapter. + + Each segment is trimmed and faded, the requested pause is inserted after it, + and the finished chapter is normalised once so the level is consistent from + the first word to the last. + """ + if not segments: + return np.zeros(0, dtype=np.float32) + + pieces: List[np.ndarray] = [] + if settings.lead_sec > 0: + pieces.append(silence(sr, settings.lead_sec)) + + last = len(segments) - 1 + for index, (wav, pause_after) in enumerate(segments): + processed = master_segment(wav, sr, settings) + if processed.size: + pieces.append(processed) + if index != last and pause_after > 0: + pieces.append(silence(sr, pause_after)) + + if settings.tail_sec > 0: + pieces.append(silence(sr, settings.tail_sec)) + + chapter = np.concatenate(pieces) if pieces else np.zeros(0, dtype=np.float32) + if normalize and chapter.size: + chapter, _ = normalize_level( + chapter, + sr, + target_rms_db=settings.target_rms_db, + peak_ceiling_db=settings.peak_ceiling_db, + ) + return chapter + + +def concatenate( + parts: Iterable[np.ndarray], + sr: int, + gap_sec: float = 0.0, + dtype: Optional[np.dtype] = None, +) -> np.ndarray: + """Join already-mastered blocks with an optional gap between them.""" + blocks: List[np.ndarray] = [] + for index, part in enumerate(parts): + data = as_float_mono(part) + if index and gap_sec > 0: + blocks.append(silence(sr, gap_sec)) + blocks.append(data) + if not blocks: + return np.zeros(0, dtype=np.float32) + joined = np.concatenate(blocks) + return joined.astype(dtype) if dtype is not None else joined diff --git a/narration/cache.py b/narration/cache.py new file mode 100644 index 00000000..5a657fa7 --- /dev/null +++ b/narration/cache.py @@ -0,0 +1,195 @@ +"""Content-addressed store of generated segments, so a run resumes where it died. + +On a CPU-only machine a single chapter takes hours, and any interruption — a +closed laptop, a killed shell, a crash on one bad segment — used to throw away +every segment of that chapter. This cache makes the unit of lost work a single +segment instead of a whole chapter. + +The key is a hash of everything that determines the audio: the text itself and +the full voice specification. Two consequences follow, and both are the point: +re-running an unchanged book regenerates nothing, and editing one paragraph +invalidates only the segments of that paragraph. + +Writes go through a temporary file and an atomic replace. A half-written WAV +left behind by a process killed mid-write would otherwise be indistinguishable +from a valid cache hit on the next run, and would be silently stitched into the +finished chapter. +""" +from __future__ import annotations + +import hashlib +import json +import os +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import Optional, Tuple + +import numpy as np +import soundfile as sf + +__all__ = ["CacheStats", "ChunkCache", "VoiceSpec"] + +#: Bumped when a change to generation would make existing entries wrong. +CACHE_VERSION = 1 + + +@dataclass(frozen=True) +class VoiceSpec: + """Everything that determines how a piece of text will sound. + + Anything that changes the audio belongs here; anything applied afterwards + (pauses, trimming, level) deliberately does not, so that re-mastering a book + does not force it to be re-synthesized. + """ + + description: str = "" + seed: Optional[int] = None + cfg: float = 2.0 + steps: int = 10 + normalize: bool = True + model_id: str = "" + + def fingerprint(self) -> str: + payload = {"version": CACHE_VERSION, **asdict(self)} + canonical = json.dumps(payload, sort_keys=True, ensure_ascii=False) + return hashlib.sha256(canonical.encode("utf-8")).hexdigest()[:12] + + +@dataclass +class CacheStats: + hits: int = 0 + misses: int = 0 + writes: int = 0 + + @property + def total(self) -> int: + return self.hits + self.misses + + def describe(self) -> str: + if not self.total: + return "cache: unused" + return f"cache: {self.hits}/{self.total} hits, {self.writes} written" + + +class ChunkCache: + """A directory of generated segments, addressed by content. + + Set ``enabled=False`` to bypass it entirely without the calling code needing + to branch on every lookup. + """ + + def __init__(self, root: str | Path, enabled: bool = True) -> None: + self.root = Path(root) + self.enabled = enabled + self.stats = CacheStats() + if self.enabled: + self.root.mkdir(parents=True, exist_ok=True) + + # -- addressing -------------------------------------------------------- + + def key(self, text: str, voice: VoiceSpec, parent: Optional[str] = None) -> str: + """Address of a segment. + + ``parent`` chains a segment to the one it was continued from. Under + continuity mode a segment's audio depends on its predecessor, so without + that link the cache would serve an entry generated from a different + starting point. + """ + digest = hashlib.sha256() + digest.update(voice.fingerprint().encode("utf-8")) + digest.update(b"\x00") + if parent: + digest.update(parent.encode("utf-8")) + digest.update(b"\x00") + digest.update((text or "").encode("utf-8")) + return digest.hexdigest()[:24] + + def path(self, key: str) -> Path: + return self.root / f"{key}.wav" + + # -- access ------------------------------------------------------------ + + def get(self, key: str) -> Optional[Tuple[int, np.ndarray]]: + """Return ``(sample_rate, audio)`` for a cached segment, or None.""" + if not self.enabled: + return None + target = self.path(key) + if not target.is_file(): + self.stats.misses += 1 + return None + try: + data, sample_rate = sf.read(str(target), dtype="float32", always_2d=False) + except (RuntimeError, OSError): + # A corrupt entry is a miss, not a crash: drop it and regenerate. + target.unlink(missing_ok=True) + self.stats.misses += 1 + return None + self.stats.hits += 1 + return int(sample_rate), np.asarray(data, dtype=np.float32) + + def put(self, key: str, sample_rate: int, wav: np.ndarray, text: str = "") -> Optional[Path]: + """Store a generated segment. Returns the path, or None when disabled.""" + if not self.enabled: + return None + target = self.path(key) + temporary = target.with_suffix(".wav.tmp") + try: + # The temporary name ends in ".tmp", so the container has to be + # stated explicitly — soundfile otherwise infers it from the suffix. + sf.write( + str(temporary), + np.asarray(wav, dtype=np.float32), + int(sample_rate), + format="WAV", + ) + os.replace(temporary, target) + except (RuntimeError, OSError): + temporary.unlink(missing_ok=True) + return None + if text: + self._write_sidecar(key, text, sample_rate, wav) + self.stats.writes += 1 + return target + + def _write_sidecar(self, key: str, text: str, sample_rate: int, wav: np.ndarray) -> None: + """Record what a cache file contains, so the directory stays readable. + + Never fatal: losing a debugging aid must not lose the audio it describes. + """ + try: + self.path(key).with_suffix(".json").write_text( + json.dumps( + { + "text": text, + "duration_sec": round(len(wav) / float(sample_rate or 1), 3), + "sample_rate": int(sample_rate), + }, + ensure_ascii=False, + indent=1, + ), + encoding="utf-8", + ) + except OSError: + pass + + # -- maintenance ------------------------------------------------------- + + def clear(self) -> int: + """Delete every entry. Returns how many audio files were removed.""" + if not self.root.is_dir(): + return 0 + removed = 0 + for entry in self.root.iterdir(): + if entry.suffix in (".wav", ".json", ".tmp"): + try: + entry.unlink() + if entry.suffix == ".wav": + removed += 1 + except OSError: + pass + return removed + + def size_bytes(self) -> int: + if not self.root.is_dir(): + return 0 + return sum(f.stat().st_size for f in self.root.glob("*.wav") if f.is_file()) diff --git a/narration/chunking.py b/narration/chunking.py new file mode 100644 index 00000000..cc7b3a6c --- /dev/null +++ b/narration/chunking.py @@ -0,0 +1,163 @@ +"""Cut prepared text into engine-sized segments, with a pause plan. + +The engine cannot synthesize an arbitrarily long passage — it errors out above +roughly 8192 tokens — so long-form text must be segmented no matter what. That +constraint turns out to be an opportunity: the boundary between two segments is +exactly where a narrator would draw breath, so each segment carries how long the +silence after it should be. + +A uniform gap between segments is what makes machine narration sound mechanical. +Here the pause follows the punctuation that caused the split: a paragraph break +breathes longer than a full stop, which breathes longer than a comma. + +Segments never span a paragraph boundary, which keeps the pacing honest and +gives the resume cache stable keys — reflowing one paragraph does not invalidate +the segments of every paragraph after it. +""" +from __future__ import annotations + +import re +from dataclasses import dataclass +from typing import List, Optional, Sequence + +__all__ = [ + "DEFAULT_MAX_CHARS", + "PauseProfile", + "Segment", + "split_chapters", + "split_into_segments", + "split_text_into_chunks", +] + +#: Characters per segment. Well under the engine limit: shorter segments also +#: fail less often and cost less to regenerate when one comes out badly. +DEFAULT_MAX_CHARS = 300 + +_PARAGRAPH_SPLIT_RE = re.compile(r"\n\s*\n") + +# Sentence boundaries, in two parts: +# * end punctuation followed by whitespace — including when a closing quote or +# bracket sits between them, as in `Il dit "oui." Puis...`, where a bare +# lookbehind sees `"` rather than `.` and finds no boundary at all; +# * any line break, even without surrounding spaces, so hard-wrapped prose, +# verse and dialogue lines split where they visibly break. +# Each lookbehind alternative is separately fixed-width, which is what Python's +# re module requires. +_SENTENCE_SPLIT_RE = re.compile( + r"(?:(?<=[.!?…。!?])|(?<=[.!?…。!?][\"'»)\]]))\s+" + r"|\s*\n\s*" +) +_SENTENCE_END_RE = re.compile(r"[.!?…。!?][\"'»)\]]*$") +_CHAPTER_SPLIT_RE = r"(?m)^\s*---\s*$" + + +@dataclass(frozen=True) +class PauseProfile: + """Silence inserted after a segment, by the reason the split happened. + + Values are seconds. The defaults are on the generous side of natural speech + because listeners forgive a slow narrator far more readily than a breathless + one, and because audiobooks are usually heard at increased playback speed. + """ + + #: Split inside a sentence — the segment hit the character limit. + clause: float = 0.25 + #: Segment ends on a full stop, question or exclamation mark. + sentence: float = 0.45 + #: Segment ends a paragraph. + paragraph: float = 0.9 + + def for_segment(self, text: str, ends_paragraph: bool) -> float: + if ends_paragraph: + return self.paragraph + return self.sentence if _SENTENCE_END_RE.search(text.rstrip()) else self.clause + + +@dataclass(frozen=True) +class Segment: + """One unit of synthesis, plus the silence that should follow it.""" + + text: str + pause_after: float + #: Index of the source paragraph, kept for progress reporting and debugging. + paragraph: int = 0 + + +def _pack_sentences(text: str, max_chars: int) -> List[str]: + """Greedily pack whole sentences into chunks no longer than ``max_chars``. + + A single sentence longer than the limit becomes its own chunk: splitting it + further would cut mid-clause, which is far more audible than a slightly long + segment. + """ + text = (text or "").strip() + if not text: + return [] + sentences = [s.strip() for s in _SENTENCE_SPLIT_RE.split(text) if s.strip()] + chunks: List[str] = [] + current = "" + for sentence in sentences: + if len(sentence) > max_chars: + if current: + chunks.append(current) + current = "" + chunks.append(sentence) + elif current and len(current) + 1 + len(sentence) > max_chars: + chunks.append(current) + current = sentence + else: + current = f"{current} {sentence}" if current else sentence + if current: + chunks.append(current) + return chunks + + +def split_text_into_chunks(text: str, max_chars: int = DEFAULT_MAX_CHARS) -> List[str]: + """Plain list of segment texts, without the pause plan. + + Kept for callers that only need the segmentation (the single-shot UI path + and anything written against the original helper in ``app.py``). + """ + return _pack_sentences(text, max_chars) + + +def split_into_segments( + text: str, + max_chars: int = DEFAULT_MAX_CHARS, + profile: PauseProfile = PauseProfile(), +) -> List[Segment]: + """Segment a chapter and decide how long the silence after each part is.""" + text = (text or "").strip() + if not text: + return [] + + paragraphs = [p.strip() for p in _PARAGRAPH_SPLIT_RE.split(text) if p.strip()] + segments: List[Segment] = [] + for paragraph_index, paragraph in enumerate(paragraphs): + chunks = _pack_sentences(paragraph, max_chars) + for chunk_index, chunk in enumerate(chunks): + ends_paragraph = chunk_index == len(chunks) - 1 + segments.append( + Segment( + text=chunk, + pause_after=profile.for_segment(chunk, ends_paragraph), + paragraph=paragraph_index, + ) + ) + return segments + + +def split_chapters(text: str, pattern: Optional[str] = None) -> List[str]: + """Split a book into chapters on a separator line (``---`` by default). + + Text with no separator at all is a single chapter rather than an error — a + one-chapter book is a perfectly ordinary thing to narrate. + """ + parts = re.split(pattern or _CHAPTER_SPLIT_RE, text or "") + chapters = [p.strip() for p in parts if p and p.strip()] + return chapters or ([text.strip()] if (text or "").strip() else []) + + +def total_characters(segments: Sequence[Segment]) -> int: + """Characters that will actually be sent to the engine.""" + return sum(len(segment.text) for segment in segments) diff --git a/narration/text_fr.py b/narration/text_fr.py new file mode 100644 index 00000000..708e3562 --- /dev/null +++ b/narration/text_fr.py @@ -0,0 +1,470 @@ +"""Prepare raw French prose for a TTS engine. + +A TTS model reads what it is given. Left alone it will stumble on ``1789``, +``M. Dupont``, ``XIVe siècle`` or ``14h30`` — and a single mispronounced number +in the middle of a chapter is enough to break the spell of an audiobook. This +module rewrites those forms as the words a human narrator would actually say, +before the text is ever segmented or synthesized. + +Everything here is pure text-in / text-out with no dependencies, so it is cheap +to test exhaustively — which matters, because French number agreement has more +edge cases than it looks (``quatre-vingts`` but ``quatre-vingt-un``, ``deux +cents`` but ``deux cent mille``). + +Typical use:: + + from narration.text_fr import normalize_french, load_lexicon + spoken = normalize_french(raw_chapter, lexicon=load_lexicon("conf/pronunciation_fr.json")) + +Ordering inside :func:`normalize_french` is significant: currency, times, +percentages and ordinal marks each consume their digits before the generic +number rule can reach them, and abbreviations are expanded before segmentation +so that ``M.`` no longer looks like the end of a sentence. +""" +from __future__ import annotations + +import json +import re +import unicodedata +from pathlib import Path +from typing import Dict, Iterable, Mapping, Optional + +__all__ = [ + "normalize_french", + "cardinal", + "ordinal", + "roman_to_int", + "load_lexicon", + "DEFAULT_ROMAN_TRIGGERS", +] + + +# -------------------------------------------------------------------------- +# Numbers +# -------------------------------------------------------------------------- + +_UNITS = [ + "zéro", "un", "deux", "trois", "quatre", "cinq", "six", "sept", "huit", "neuf", + "dix", "onze", "douze", "treize", "quatorze", "quinze", "seize", + "dix-sept", "dix-huit", "dix-neuf", +] +_TENS = {2: "vingt", 3: "trente", 4: "quarante", 5: "cinquante", 6: "soixante"} + +# Ordered high to low. ``mille`` is invariable; ``million``/``milliard`` are +# nouns and take a plural s. +_SCALES = ((10**9, "milliard"), (10**6, "million"), (10**3, "mille")) + + +def _below_100(n: int, final: bool) -> str: + """``final`` is False when another numeral word follows, which suppresses the + plural s of ``quatre-vingts`` (``quatre-vingt mille``, not ``quatre-vingts mille``).""" + if n < 20: + return _UNITS[n] + if n < 70: + tens, unit = divmod(n, 10) + word = _TENS[tens] + if unit == 0: + return word + if unit == 1: + return f"{word} et un" + return f"{word}-{_UNITS[unit]}" + if n < 80: + rest = n - 60 # 10..19 + if rest == 11: + return "soixante et onze" + return f"soixante-{_UNITS[rest]}" + rest = n - 80 # 0..19 + if rest == 0: + return "quatre-vingts" if final else "quatre-vingt" + return f"quatre-vingt-{_UNITS[rest]}" + + +def _below_1000(n: int, final: bool) -> str: + hundreds, rest = divmod(n, 100) + if hundreds == 0: + return _below_100(rest, final) + head = "cent" if hundreds == 1 else f"{_UNITS[hundreds]} cent" + if rest == 0: + # ``cent`` agrees only when it is multiplied and ends the number. + return f"{head}s" if (hundreds > 1 and final) else head + return f"{head} {_below_100(rest, final)}" + + +def cardinal(n: int, final: bool = True) -> str: + """Spell out an integer in French. + + ``final=False`` suppresses the plural s on ``cent`` and ``quatre-vingt``, as + required when a numeral word follows (``deux cent mille``). + """ + if n < 0: + return f"moins {cardinal(-n, final)}" + if n < 1000: + return _below_1000(n, final) + + parts: list[str] = [] + remainder = n + for value, name in _SCALES: + count, remainder = divmod(remainder, value) + if count == 0: + continue + if name == "mille": + # ``mille`` is invariable and drops its ``un``: 1000 is just "mille". + parts.append("mille" if count == 1 else f"{_below_1000(count, False)} mille") + else: + plural = "s" if count > 1 else "" + parts.append(f"{_below_1000(count, True)} {name}{plural}") + if remainder: + parts.append(_below_1000(remainder, final)) + return " ".join(parts) + + +def ordinal(n: int, feminine: bool = False) -> str: + """Spell out an ordinal: ``1`` -> premier/première, ``21`` -> vingt et unième.""" + if n == 1: + return "première" if feminine else "premier" + # Built with final=False so that 80 yields "quatre-vingt" -> "quatre-vingtième". + base = cardinal(n, final=False) + if base.endswith("un"): + base = f"{base[:-2]}unième" + elif base.endswith("cinq"): + base = f"{base[:-4]}cinquième" + elif base.endswith("neuf"): + base = f"{base[:-4]}neuvième" + elif base.endswith("e"): + base = f"{base[:-1]}ième" + else: + base = f"{base}ième" + return base + + +# -------------------------------------------------------------------------- +# Roman numerals +# -------------------------------------------------------------------------- + +_ROMAN_VALUES = {"I": 1, "V": 5, "X": 10, "L": 50, "C": 100, "D": 500, "M": 1000} +_ROMAN_STRICT = re.compile(r"^M{0,3}(CM|CD|D?C{0,3})(XC|XL|L?X{0,3})(IX|IV|V?I{0,3})$") + + +def roman_to_int(s: str) -> Optional[int]: + """Return the value of a well-formed Roman numeral, or None if malformed. + + Strict on purpose: loose parsing would happily read an initial or an acronym + as a number. + """ + s = (s or "").strip().upper() + if not s or not _ROMAN_STRICT.match(s): + return None + total = 0 + previous = 0 + for char in reversed(s): + value = _ROMAN_VALUES[char] + total += value if value >= previous else -value + previous = max(previous, value) + return total or None + + +#: Words after which a Roman numeral is unambiguous. Expanding Roman numerals +#: everywhere would wreck initials and acronyms, so a trigger is required. +DEFAULT_ROMAN_TRIGGERS = ( + "chapitre", "chapitres", "partie", "parties", "livre", "livres", "tome", "tomes", + "acte", "actes", "scène", "scènes", "section", "sections", "annexe", "annexes", + "appendice", "volume", "volumes", "épisode", "épisodes", "titre", "article", + "articles", "figure", "planche", "leçon", "chant", +) + + +# -------------------------------------------------------------------------- +# Abbreviations +# -------------------------------------------------------------------------- + +# (pattern, replacement), applied in order. Entries that could collide with an +# ordinary word require a following capitalised token. +_ABBREVIATIONS: tuple[tuple[str, str], ...] = ( + # These end in a period that may also be the end of the sentence, so the + # period is matched by lookahead and left in place — consuming it would + # silently merge two sentences and destroy a pause. + (r"\bav\.\s*J\.-?\s*C(?=\.)", "avant Jésus-Christ"), + (r"\bapr\.\s*J\.-?\s*C(?=\.)", "après Jésus-Christ"), + (r"\betc(?=\.)", "et cetera"), + (r"\bc\.-à-d\.", "c'est-à-dire"), + (r"\bp\.\s*ex\.", "par exemple"), + (r"\bMM\.(?=\s)", "Messieurs"), + (r"\bM\.(?=\s+[A-ZÀ-Þ])", "Monsieur"), + (r"\bMmes\.?(?!\w)", "Mesdames"), + (r"\bMme\.?(?!\w)", "Madame"), + (r"\bMlles\.?(?!\w)", "Mesdemoiselles"), + (r"\bMlle\.?(?!\w)", "Mademoiselle"), + (r"\bDr\.?(?=\s+[A-ZÀ-Þ])", "Docteur"), + (r"\bPr\.?(?=\s+[A-ZÀ-Þ])", "Professeur"), + (r"\bMe\.?(?=\s+[A-ZÀ-Þ])", "Maître"), + (r"\bStes\.?(?=\s+[A-ZÀ-Þ])", "Saintes"), + (r"\bSts\.?(?=\s+[A-ZÀ-Þ])", "Saints"), + (r"\bSte\.?(?=\s+[A-ZÀ-Þ])", "Sainte"), + (r"\bSt\.?(?=\s+[A-ZÀ-Þ])", "Saint"), + (r"\bchap\.", "chapitre"), + (r"\bvol\.", "volume"), + (r"\béd\.", "édition"), + (r"\benv\.(?=\s)", "environ"), + (r"\bcf\.", "voir"), + (r"\bart\.(?=\s*\d)", "article"), + (r"\bpp\.(?=\s*\d)", "pages"), + (r"\bp\.(?=\s*\d)", "page"), + (r"\bn[°º]\s*(?=\d)", "numéro "), + (r"[°º](?=\s|$)", " degrés"), +) + + +# -------------------------------------------------------------------------- +# Compiled patterns for the rewriting passes +# -------------------------------------------------------------------------- + +# Thousands may be grouped with a plain, non-breaking or narrow no-break space. +# The pattern is anchored on groups of exactly three digits: a looser class such +# as ``\d[\d ]*`` would swallow the space *after* a number, turning "12 et" into +# a match on "12 " and gluing the next word to the spelled-out number. +_SEP = "    " +_NUM = rf"\d{{1,3}}(?:[{_SEP}]\d{{3}})+|\d+" + +# A single-letter Roman ordinal is restricted to I, V and X. L, C, D and M would +# otherwise turn the extremely common "Le", "Ce", "De" and "Me" into ordinals — +# "Le manuscrit" read aloud as "cinquantième manuscrit". +_RE_ROMAN_ORDINAL = re.compile(r"\b([IVX]|[IVXLCDM]{2,15})(?:e|è?me|ᵉ)\b") +_RE_TIME = re.compile(r"\b(\d{1,2})\s*[hH]\s*(\d{2})?\b(?!\d)") +_RE_CURRENCY = re.compile(rf"({_NUM})(?:,(\d{{1,2}}))?\s*([€$£])") +_RE_CURRENCY_PREFIX = re.compile(rf"([€$£])\s*({_NUM})(?:,(\d{{1,2}}))?") +_RE_PERCENT = re.compile(rf"({_NUM}(?:,\d+)?)\s*%") +_RE_ORDINAL_MARK = re.compile(r"\b(\d+)(ers|er|res|re|èmes|ème|es|e)\b") +_RE_DECIMAL = re.compile(rf"\b({_NUM}),(\d+)\b") +_RE_INTEGER = re.compile(rf"\b(?:{_NUM})\b") +_RE_GROUPED = re.compile(rf"[{_SEP}]") + +_CURRENCY_NAMES = { + "€": ("euro", "euros", "centime", "centimes"), + "$": ("dollar", "dollars", "cent", "cents"), + "£": ("livre", "livres", "penny", "pennies"), +} + +#: Ordinal suffixes that mark a feminine ordinal (``1re``, ``1res``). +_FEMININE_SUFFIXES = {"re", "res"} + + +def _digits(raw: str) -> int: + """Parse an integer that may carry French thousands separators.""" + return int(_RE_GROUPED.sub("", raw)) + + +def _spell_decimal(whole: str, frac: str) -> str: + return f"{cardinal(_digits(whole))} virgule {' '.join(_UNITS[int(d)] for d in frac)}" + + +# -------------------------------------------------------------------------- +# Individual passes +# -------------------------------------------------------------------------- + + +def _clean_typography(text: str) -> str: + """Normalise Unicode punctuation to forms the engine handles predictably.""" + text = unicodedata.normalize("NFC", text) + text = text.replace("’", "'").replace("‘", "'") + text = text.replace("“", '"').replace("”", '"') + text = re.sub(r"[   ]", " ", text) + text = re.sub(r"\.{3,}", "…", text) + return text + + +def _strip_markdown(text: str) -> str: + """Remove markup that would otherwise be read aloud as punctuation noise.""" + text = re.sub(r"(?m)^\s{0,3}#{1,6}\s*", "", text) # ATX headings + text = re.sub(r"(?m)^\s{0,3}>\s?", "", text) # block quotes + text = re.sub(r"\*\*(.+?)\*\*", r"\1", text) # bold + text = re.sub(r"(? str: + """Apply user pronunciation overrides, longest key first so that multi-word + entries win over their own prefixes.""" + for source in sorted(lexicon, key=len, reverse=True): + replacement = lexicon[source] + pattern = re.compile(rf"(? str: + for pattern, replacement in _ABBREVIATIONS: + text = re.sub(pattern, replacement, text) + return text + + +def _expand_roman(text: str, triggers: Iterable[str]) -> str: + def _ordinal_sub(match: re.Match) -> str: + value = roman_to_int(match.group(1)) + return ordinal(value) if value is not None else match.group(0) + + # "XXe siècle" -> "vingtième siècle". Runs first: it is the most specific form. + text = _RE_ROMAN_ORDINAL.sub(_ordinal_sub, text) + + trigger_group = "|".join(sorted((re.escape(t) for t in triggers), key=len, reverse=True)) + if trigger_group: + def _after_trigger(match: re.Match) -> str: + value = roman_to_int(match.group(2)) + return f"{match.group(1)}{cardinal(value)}" if value is not None else match.group(0) + + # The trigger is matched case-insensitively via an inline group, but the + # numeral itself stays case-sensitive on purpose: with a global + # IGNORECASE the perfectly ordinary "chapitre dix" parses as the Roman + # numeral DIX and is read back as "cinq cent neuf". + text = re.sub( + rf"\b((?:(?i:{trigger_group}))\s+)([IVXLCDM]{{1,15}})\b", + _after_trigger, + text, + ) + + # A line containing nothing but a Roman numeral is a chapter heading. + def _heading(match: re.Match) -> str: + value = roman_to_int(match.group(1)) + return cardinal(value) if value is not None else match.group(0) + + return re.sub(r"(?m)^[ \t]*([IVXLCDM]{1,15})[ \t]*\.?[ \t]*$", _heading, text) + + +def _expand_times(text: str) -> str: + def _sub(match: re.Match) -> str: + hours = int(match.group(1)) + if hours > 23: + return match.group(0) + minutes = match.group(2) + hour_word = "une" if hours == 1 else cardinal(hours) + unit = "heure" if hours in (0, 1) else "heures" + if not minutes or int(minutes) == 0: + return f"{hour_word} {unit}" + return f"{hour_word} {unit} {cardinal(int(minutes))}" + + return _RE_TIME.sub(_sub, text) + + +def _expand_currency(text: str) -> str: + def _format(amount: str, cents: Optional[str], symbol: str) -> str: + singular, plural, cent_singular, cent_plural = _CURRENCY_NAMES[symbol] + value = _digits(amount) + words = f"{cardinal(value)} {singular if value <= 1 else plural}" + if cents: + cent_value = int(cents.ljust(2, "0")) + if cent_value: + unit = cent_singular if cent_value == 1 else cent_plural + words += f" {cardinal(cent_value)} {unit}" + return words + + text = _RE_CURRENCY.sub(lambda m: _format(m.group(1), m.group(2), m.group(3)), text) + return _RE_CURRENCY_PREFIX.sub(lambda m: _format(m.group(2), m.group(3), m.group(1)), text) + + +def _expand_percent(text: str) -> str: + def _sub(match: re.Match) -> str: + raw = match.group(1) + if "," in raw: + whole, frac = raw.split(",", 1) + return f"{_spell_decimal(whole, frac)} pour cent" + return f"{cardinal(_digits(raw))} pour cent" + + return _RE_PERCENT.sub(_sub, text) + + +def _expand_ordinal_marks(text: str) -> str: + def _sub(match: re.Match) -> str: + suffix = match.group(2) + word = ordinal(_digits(match.group(1)), feminine=suffix in _FEMININE_SUFFIXES) + return f"{word}s" if suffix.endswith("s") and not word.endswith("s") else word + + return _RE_ORDINAL_MARK.sub(_sub, text) + + +def _expand_numbers(text: str) -> str: + text = _RE_DECIMAL.sub(lambda m: _spell_decimal(m.group(1), m.group(2)), text) + return _RE_INTEGER.sub(lambda m: cardinal(_digits(m.group(0))), text) + + +def _clean_dialogue(text: str, strip_quotes: bool) -> str: + """Dialogue dashes and guillemets carry no sound of their own — the pause + around them is what a listener actually hears.""" + text = re.sub(r"(?m)^[ \t]*[—–-]{1,2}[ \t]+", "", text) + text = re.sub(r"\s*[—–]\s*", ", ", text) + if strip_quotes: + text = text.replace("«", "").replace("»", "").replace('"', "") + return text + + +def _tidy_whitespace(text: str) -> str: + text = re.sub(r"[ \t]+", " ", text) + text = re.sub(r" ([,.;:!?…])", r"\1", text) + text = re.sub(r",(\s*,)+", ",", text) + text = re.sub(r"(?m)^[ \t]+|[ \t]+$", "", text) + text = re.sub(r"\n{3,}", "\n\n", text) + return text.strip() + + +# -------------------------------------------------------------------------- +# Public entry point +# -------------------------------------------------------------------------- + + +def normalize_french( + text: str, + *, + lexicon: Optional[Mapping[str, str]] = None, + expand_roman: bool = True, + roman_triggers: Iterable[str] = DEFAULT_ROMAN_TRIGGERS, + strip_markdown: bool = True, + strip_quotes: bool = True, +) -> str: + """Rewrite French prose into the words a narrator would speak. + + The passes run in a fixed order because several of them compete for the same + digits: times, currency, percentages and ordinal marks each claim their + pattern before the generic number rule sees it. + """ + if not text or not text.strip(): + return "" + + text = _clean_typography(text) + if strip_markdown: + text = _strip_markdown(text) + if lexicon: + text = _apply_lexicon(text, lexicon) + text = _expand_abbreviations(text) + if expand_roman: + text = _expand_roman(text, roman_triggers) + text = _expand_times(text) + text = _expand_currency(text) + text = _expand_percent(text) + text = _expand_ordinal_marks(text) + text = _expand_numbers(text) + text = _clean_dialogue(text, strip_quotes) + return _tidy_whitespace(text) + + +def load_lexicon(path: str | Path) -> Dict[str, str]: + """Load a user pronunciation lexicon (``{"écrit": "prononcé"}``). + + A missing or malformed file is never fatal — a whole narration run must not + die because an optional override file has a typo — it yields an empty + lexicon instead. + """ + file_path = Path(path) + if not file_path.is_file(): + return {} + try: + data = json.loads(file_path.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError, UnicodeDecodeError): + return {} + if not isinstance(data, dict): + return {} + # Keys starting with "_" are comments — JSON has no other way to carry one. + return { + str(k): str(v) + for k, v in data.items() + if str(k).strip() and not str(k).startswith("_") + } diff --git a/tests/test_narration_assemble.py b/tests/test_narration_assemble.py new file mode 100644 index 00000000..281d4158 --- /dev/null +++ b/tests/test_narration_assemble.py @@ -0,0 +1,175 @@ +"""Tests for joining chapters into a single chaptered audiobook. + +ffmpeg is not assumed to be installed — the point of most of these tests is that +its absence costs only the encoding step, never the synthesized audio. +""" +import numpy as np +import pytest +import soundfile as sf + +from narration import assemble +from narration.assemble import Chapter + +SR = 24000 + + +@pytest.fixture +def chapter_files(tmp_path): + """Three chapters of 1.0 s, 2.0 s and 0.5 s.""" + paths = [] + for index, seconds in enumerate([1.0, 2.0, 0.5], start=1): + t = np.arange(int(SR * seconds)) / SR + path = tmp_path / f"chapitre_{index:03d}.wav" + sf.write(str(path), (0.1 * np.sin(2 * np.pi * 220 * t)).astype(np.float32), SR) + paths.append(path) + return paths + + +class TestConcat: + def test_durations_and_offsets(self, chapter_files, tmp_path): + chapters, sample_rate = assemble.concat_chapters( + chapter_files, tmp_path / "book.wav", gap_sec=1.0 + ) + assert sample_rate == SR + assert [round(c.duration_sec, 2) for c in chapters] == [1.0, 2.0, 0.5] + # Each chapter starts after the previous one plus the 1 s gap. + assert [round(c.start_sec, 2) for c in chapters] == [0.0, 2.0, 5.0] + + def test_total_length_includes_the_gaps(self, chapter_files, tmp_path): + out = tmp_path / "book.wav" + assemble.concat_chapters(chapter_files, out, gap_sec=1.0) + info = sf.info(str(out)) + assert info.frames / info.samplerate == pytest.approx(5.5, abs=0.01) + + def test_output_is_16_bit_pcm(self, chapter_files, tmp_path): + out = tmp_path / "book.wav" + assemble.concat_chapters(chapter_files, out) + assert sf.info(str(out)).subtype == "PCM_16" + + def test_titles_default_to_filenames(self, chapter_files, tmp_path): + chapters, _ = assemble.concat_chapters(chapter_files, tmp_path / "book.wav") + assert chapters[0].title == "Chapitre 001" + + def test_explicit_titles_win(self, chapter_files, tmp_path): + chapters, _ = assemble.concat_chapters( + chapter_files, tmp_path / "book.wav", titles=["Le début", "Le milieu", "La fin"] + ) + assert [c.title for c in chapters] == ["Le début", "Le milieu", "La fin"] + + def test_no_chapters_is_an_error(self, tmp_path): + with pytest.raises(ValueError): + assemble.concat_chapters([], tmp_path / "book.wav") + + def test_a_missing_chapter_is_reported_by_name(self, chapter_files, tmp_path): + with pytest.raises(FileNotFoundError, match="manquant"): + assemble.concat_chapters( + chapter_files + [tmp_path / "manquant.wav"], tmp_path / "book.wav" + ) + + def test_mismatched_sample_rates_are_refused(self, chapter_files, tmp_path): + odd = tmp_path / "chapitre_004.wav" + sf.write(str(odd), np.zeros(16000, dtype=np.float32), 16000) + with pytest.raises(ValueError, match="sample rate"): + assemble.concat_chapters(chapter_files + [odd], tmp_path / "book.wav") + + +class TestFfmetadata: + def test_header_and_tags(self): + text = assemble.build_ffmetadata( + [Chapter(path=None, title="Un", start_sec=0.0, duration_sec=1.0)], + title="Mon Livre", + author="Edwin", + ) + assert text.startswith(";FFMETADATA1") + assert "title=Mon Livre" in text + assert "artist=Edwin" in text + + def test_markers_are_contiguous_so_players_never_show_a_gap(self): + chapters = [ + Chapter(path=None, title="Un", start_sec=0.0, duration_sec=1.0), + Chapter(path=None, title="Deux", start_sec=2.5, duration_sec=1.0), + ] + text = assemble.build_ffmetadata(chapters) + # The first marker runs to where the second begins (2500 ms), not to the + # end of its own audio (1000 ms) — the silence belongs to chapter one. + assert "START=0\nEND=2500" in text + assert "START=2500\nEND=3500" in text + + def test_special_characters_are_escaped(self): + text = assemble.build_ffmetadata( + [Chapter(path=None, title="A=B; #1", start_sec=0.0, duration_sec=1.0)] + ) + assert r"title=A\=B\; \#1" in text + + def test_a_newline_in_a_title_cannot_break_the_format(self): + text = assemble.build_ffmetadata( + [Chapter(path=None, title="Deux\nlignes", start_sec=0.0, duration_sec=1.0)] + ) + assert "title=Deux lignes" in text + assert text.count("[CHAPTER]") == 1 + + +class TestFfmpegCommand: + def test_m4b_uses_aac_and_faststart(self, tmp_path): + command = assemble.ffmpeg_command(tmp_path / "b.wav", tmp_path / "b.txt", tmp_path / "b.m4b") + assert "aac" in command and "+faststart" in command + + def test_mp3_uses_lame(self, tmp_path): + command = assemble.ffmpeg_command(tmp_path / "b.wav", tmp_path / "b.txt", tmp_path / "b.mp3") + assert "libmp3lame" in command + assert "+faststart" not in command + + def test_metadata_is_mapped_from_the_chapter_file(self, tmp_path): + command = assemble.ffmpeg_command(tmp_path / "b.wav", tmp_path / "b.txt", tmp_path / "b.m4b") + assert command[command.index("-map_metadata") + 1] == "1" + + def test_a_cover_is_attached_as_a_picture(self, tmp_path): + command = assemble.ffmpeg_command( + tmp_path / "b.wav", tmp_path / "b.txt", tmp_path / "b.m4b", cover_path=tmp_path / "c.jpg" + ) + assert "attached_pic" in command + + +class TestAssemble: + def test_audio_and_markers_survive_a_missing_ffmpeg(self, chapter_files, tmp_path, monkeypatch): + monkeypatch.setattr(assemble, "find_ffmpeg", lambda: None) + result = assemble.assemble(chapter_files, tmp_path / "livre.m4b", title="Mon Livre") + + assert result.wav_path.is_file() + assert result.metadata_path.is_file() + assert result.output_path is None + assert result.pending_command and result.pending_command[0] == "ffmpeg" + assert "ffmpeg" in result.message + + def test_the_pending_command_is_the_one_that_would_have_run(self, chapter_files, tmp_path, monkeypatch): + monkeypatch.setattr(assemble, "find_ffmpeg", lambda: None) + result = assemble.assemble(chapter_files, tmp_path / "livre.m4b") + assert str(result.wav_path) in result.pending_command + assert str(result.metadata_path) in result.pending_command + + def test_a_wav_target_needs_no_encoder_at_all(self, chapter_files, tmp_path, monkeypatch): + monkeypatch.setattr(assemble, "find_ffmpeg", lambda: None) + result = assemble.assemble(chapter_files, tmp_path / "livre.wav") + assert result.output_path == tmp_path / "livre.wav" + assert result.pending_command is None + + def test_a_failing_ffmpeg_still_leaves_the_audio(self, chapter_files, tmp_path, monkeypatch): + import subprocess + + monkeypatch.setattr(assemble, "find_ffmpeg", lambda: "ffmpeg") + monkeypatch.setattr( + assemble.subprocess, + "run", + lambda *a, **k: subprocess.CompletedProcess(a, 1, "", "Encoder not found"), + ) + result = assemble.assemble(chapter_files, tmp_path / "livre.m4b") + assert result.wav_path.is_file() + assert result.output_path is None + assert "Encoder not found" in result.message + + def test_chapter_totals(self, chapter_files, tmp_path, monkeypatch): + monkeypatch.setattr(assemble, "find_ffmpeg", lambda: None) + result = assemble.assemble(chapter_files, tmp_path / "livre.m4b", gap_sec=1.0) + assert len(result.chapters) == 3 + assert result.duration_sec == pytest.approx(5.5, abs=0.01) + assert result.sample_rate == SR diff --git a/tests/test_narration_audio.py b/tests/test_narration_audio.py new file mode 100644 index 00000000..681b8a1d --- /dev/null +++ b/tests/test_narration_audio.py @@ -0,0 +1,216 @@ +"""Tests for measurement and mastering. + +Levels are checked against analytically known signals: a sine of amplitude ``a`` +has RMS ``a/sqrt(2)``, so the expected dBFS figures are exact rather than +recorded from a previous run. +""" +import numpy as np +import pytest + +from narration import audio + +SR = 24000 + + +def sine(seconds=1.0, amplitude=0.1, freq=220.0, sr=SR): + t = np.arange(int(sr * seconds)) / sr + return (amplitude * np.sin(2 * np.pi * freq * t)).astype(np.float32) + + +class TestMeasurement: + def test_rms_of_a_known_sine(self): + # 0.1 amplitude -> RMS 0.0707 -> -23.01 dBFS + assert audio.speech_rms_db(sine(amplitude=0.1), SR) == pytest.approx(-23.01, abs=0.05) + + def test_peak(self): + assert audio.peak_db(sine(amplitude=0.5)) == pytest.approx(-6.02, abs=0.05) + + def test_silence_between_speech_does_not_drag_the_measurement_down(self): + # The whole point of gating: a chapter with generous pauses must not + # measure quieter than the speech it contains. + speech = sine(seconds=2.0, amplitude=0.1) + padded = np.concatenate([speech, audio.silence(SR, 6.0)]) + assert audio.speech_rms_db(padded, SR) == pytest.approx( + audio.speech_rms_db(speech, SR), abs=0.5 + ) + + def test_empty_input_is_not_a_crash(self): + empty = np.zeros(0, dtype=np.float32) + assert audio.speech_rms_db(empty, SR) == -np.inf + assert audio.peak_db(empty) == -np.inf + assert audio.noise_floor_db(empty, SR) == -np.inf + + def test_noise_floor_of_digital_silence_is_very_low(self): + signal = np.concatenate([sine(1.0), audio.silence(SR, 1.0)]) + assert audio.noise_floor_db(signal, SR) < audio.ACX_NOISE_FLOOR_DB + + def test_input_shorter_than_one_analysis_frame(self): + tiny = sine(seconds=0.01) + assert np.isfinite(audio.speech_rms_db(tiny, SR)) + + +class TestAcxReport: + def test_a_correctly_mastered_signal_passes(self): + signal = np.concatenate([sine(2.0), audio.silence(SR, 1.5), sine(2.0)]) + mastered, _ = audio.normalize_level(signal, SR, target_rms_db=-20.0) + report = audio.acx_report(mastered, SR) + assert report["compliant"], report + + def test_a_too_loud_signal_is_flagged(self): + report = audio.acx_report(sine(2.0, amplitude=0.99), SR) + assert not report["rms_ok"] + assert not report["peak_ok"] + assert not report["compliant"] + + def test_duration_is_reported(self): + assert audio.acx_report(sine(3.0), SR)["duration_sec"] == pytest.approx(3.0, abs=0.01) + + +class TestNormalizeLevel: + def test_reaches_the_target(self): + normalized, gain = audio.normalize_level(sine(2.0, amplitude=0.02), SR, target_rms_db=-20.0) + assert audio.speech_rms_db(normalized, SR) == pytest.approx(-20.0, abs=0.1) + assert gain > 0 + + def test_the_peak_ceiling_wins_over_the_rms_target(self): + # A spiky signal: reaching -20 dBFS RMS would push the spike above 0 dBFS. + signal = np.concatenate([sine(2.0, amplitude=0.001), np.array([0.9], dtype=np.float32)]) + normalized, _ = audio.normalize_level( + signal, SR, target_rms_db=-20.0, peak_ceiling_db=-3.0 + ) + assert audio.peak_db(normalized) <= -3.0 + 0.01 + # ...and the result is therefore quieter than the RMS target asked for. + assert audio.speech_rms_db(normalized, SR) < -20.0 + + def test_digital_silence_is_left_alone_rather_than_amplified(self): + quiet = np.zeros(SR, dtype=np.float32) + normalized, gain = audio.normalize_level(quiet, SR) + assert gain == 0.0 + assert not np.any(normalized) + + def test_empty(self): + normalized, gain = audio.normalize_level(np.zeros(0, dtype=np.float32), SR) + assert normalized.size == 0 and gain == 0.0 + + +class TestTrimSilence: + def test_leading_and_trailing_silence_are_removed(self): + padded = np.concatenate([audio.silence(SR, 0.8), sine(1.0), audio.silence(SR, 0.8)]) + trimmed = audio.trim_silence(padded, SR, keep_ms=60.0) + # 1s of speech plus the 60 ms margin kept at each edge. + assert len(trimmed) / SR == pytest.approx(1.12, abs=0.06) + + def test_a_margin_is_kept_so_words_do_not_start_abruptly(self): + padded = np.concatenate([audio.silence(SR, 0.5), sine(1.0)]) + trimmed = audio.trim_silence(padded, SR, keep_ms=100.0) + assert len(trimmed) > SR # strictly longer than the speech alone + + def test_speech_without_silence_is_left_essentially_untouched(self): + speech = sine(1.0) + assert len(audio.trim_silence(speech, SR)) == pytest.approx(len(speech), abs=SR * 0.05) + + def test_pure_silence_is_returned_unchanged_rather_than_emptied(self): + quiet = np.zeros(SR, dtype=np.float32) + assert len(audio.trim_silence(quiet, SR)) == SR + + def test_threshold_is_relative_so_a_quiet_segment_is_not_erased(self): + quiet_speech = np.concatenate([audio.silence(SR, 0.5), sine(1.0, amplitude=0.005)]) + trimmed = audio.trim_silence(quiet_speech, SR) + assert len(trimmed) < len(quiet_speech) + assert len(trimmed) > SR * 0.9 + + +class TestFadeEdges: + def test_edges_start_and_end_at_zero(self): + faded = audio.fade_edges(np.ones(SR, dtype=np.float32), SR, fade_ms=10.0) + assert faded[0] == pytest.approx(0.0, abs=1e-6) + assert faded[-1] == pytest.approx(0.0, abs=1e-6) + + def test_the_middle_is_untouched(self): + source = np.ones(SR, dtype=np.float32) + faded = audio.fade_edges(source, SR, fade_ms=10.0) + assert faded[SR // 2] == pytest.approx(1.0) + + def test_the_input_is_not_modified_in_place(self): + source = np.ones(SR, dtype=np.float32) + audio.fade_edges(source, SR, fade_ms=10.0) + assert source[0] == pytest.approx(1.0) + + def test_a_segment_shorter_than_the_fade_is_handled(self): + short = np.ones(10, dtype=np.float32) + assert audio.fade_edges(short, SR, fade_ms=100.0).shape == short.shape + + +class TestStitch: + def test_pauses_land_between_segments(self): + settings = audio.MasteringSettings(lead_sec=0.0, tail_sec=0.0, trim_silence=False) + segments = [(sine(1.0), 0.5), (sine(1.0), 0.9)] + result = audio.stitch(segments, SR, settings, normalize=False) + # Two seconds of speech plus the 0.5 s pause; the pause after the LAST + # segment is the tail's job, not the gap's. + assert len(result) / SR == pytest.approx(2.5, abs=0.05) + + def test_lead_and_tail_are_added(self): + settings = audio.MasteringSettings(lead_sec=0.3, tail_sec=0.6, trim_silence=False) + result = audio.stitch([(sine(1.0), 0.0)], SR, settings, normalize=False) + assert len(result) / SR == pytest.approx(1.9, abs=0.05) + + def test_joins_do_not_click(self): + # A step discontinuity at a join shows up as a sample-to-sample jump far + # larger than anything inside the waveform; the edge fades exist + # precisely to prevent it. This segment stops mid-cycle, so without the + # fade the join would drop straight from ~0.4 to silence. + segment = sine(seconds=0.1013, amplitude=0.5, freq=317.0) + settings = audio.MasteringSettings(trim_silence=False, lead_sec=0.0, tail_sec=0.0) + result = audio.stitch([(segment, 0.2)] * 3, SR, settings, normalize=False) + + largest_jump_inside_a_segment = float(np.max(np.abs(np.diff(segment)))) + assert float(np.max(np.abs(np.diff(result)))) <= largest_jump_inside_a_segment * 1.5 + + def test_the_finished_chapter_is_normalized_once(self): + segments = [(sine(1.0, amplitude=0.01), 0.3), (sine(1.0, amplitude=0.01), 0.3)] + result = audio.stitch(segments, SR, audio.MasteringSettings(target_rms_db=-20.0)) + assert audio.speech_rms_db(result, SR) == pytest.approx(-20.0, abs=0.5) + + def test_normalization_can_be_skipped(self): + loud = sine(1.0, amplitude=0.5) + result = audio.stitch([(loud, 0.0)], SR, audio.MasteringSettings(trim_silence=False), + normalize=False) + assert audio.peak_db(result) == pytest.approx(audio.peak_db(loud), abs=0.1) + + def test_no_segments(self): + assert audio.stitch([], SR).size == 0 + + +class TestAsFloatMono: + def test_stereo_is_averaged(self): + stereo = np.stack([np.ones(100), np.zeros(100)], axis=1).astype(np.float32) + mono = audio.as_float_mono(stereo) + assert mono.ndim == 1 and len(mono) == 100 + assert np.allclose(mono, 0.5) + + def test_conversion_does_not_alter_the_level(self): + # Measurement must report what is in the file, so conversion stays pure. + offset = np.full(1000, 0.5, dtype=np.float32) + assert float(np.mean(audio.as_float_mono(offset))) == pytest.approx(0.5) + + def test_integer_input_is_converted(self): + assert audio.as_float_mono(np.zeros(10, dtype=np.int16)).dtype == np.float32 + + +class TestRemoveDc: + def test_offset_is_centred(self): + offset = np.full(1000, 0.5, dtype=np.float32) + assert float(np.mean(audio.remove_dc(offset))) == pytest.approx(0.0, abs=1e-6) + + def test_a_centred_signal_is_left_alone(self): + signal = sine(1.0) + assert np.allclose(audio.remove_dc(signal), signal, atol=1e-6) + + def test_mastering_removes_the_offset(self): + offset_speech = sine(1.0) + np.float32(0.2) + mastered = audio.master_segment(offset_speech, SR) + assert float(np.mean(mastered)) == pytest.approx(0.0, abs=1e-3) + + def test_empty(self): + assert audio.remove_dc(np.zeros(0, dtype=np.float32)).size == 0 diff --git a/tests/test_narration_cache.py b/tests/test_narration_cache.py new file mode 100644 index 00000000..3399109d --- /dev/null +++ b/tests/test_narration_cache.py @@ -0,0 +1,120 @@ +"""Tests for the segment cache that makes an interrupted narration resumable.""" +import numpy as np +import pytest +import soundfile as sf + +from narration.cache import ChunkCache, VoiceSpec + +SR = 24000 + + +@pytest.fixture +def voice(): + return VoiceSpec(description="Voix grave", seed=42, cfg=2.0, steps=10, model_id="test") + + +@pytest.fixture +def cache(tmp_path): + return ChunkCache(tmp_path / "cache") + + +def audio_block(value=0.1, length=1000): + return np.full(length, value, dtype=np.float32) + + +class TestKeying: + def test_same_input_same_key(self, cache, voice): + assert cache.key("Bonjour", voice) == cache.key("Bonjour", voice) + + def test_different_text_different_key(self, cache, voice): + assert cache.key("Bonjour", voice) != cache.key("Bonsoir", voice) + + @pytest.mark.parametrize( + "field,value", + [("seed", 43), ("description", "Autre voix"), ("cfg", 3.0), ("steps", 20), + ("normalize", False), ("model_id", "other")], + ) + def test_every_voice_parameter_affects_the_key(self, cache, voice, field, value): + other = VoiceSpec(**{**voice.__dict__, field: value}) + assert cache.key("Bonjour", voice) != cache.key("Bonjour", other) + + def test_continuity_parent_affects_the_key(self, cache, voice): + # Under continuity a segment's audio depends on its predecessor, so two + # identical texts continued from different points must not collide. + assert cache.key("suite", voice, parent="aaa") != cache.key("suite", voice, parent="bbb") + assert cache.key("suite", voice, parent="aaa") != cache.key("suite", voice) + + +class TestRoundTrip: + def test_miss_then_hit(self, cache, voice): + key = cache.key("Bonjour", voice) + assert cache.get(key) is None + cache.put(key, SR, audio_block()) + + result = cache.get(key) + assert result is not None + sample_rate, data = result + assert sample_rate == SR + assert np.allclose(data, 0.1, atol=1e-4) + + def test_statistics_are_tracked(self, cache, voice): + key = cache.key("Bonjour", voice) + cache.get(key) # miss + cache.put(key, SR, audio_block()) + cache.get(key) # hit + assert (cache.stats.hits, cache.stats.misses, cache.stats.writes) == (1, 1, 1) + assert "1/2 hits" in cache.stats.describe() + + def test_a_sidecar_records_what_the_entry_contains(self, cache, voice): + key = cache.key("Bonjour", voice) + cache.put(key, SR, audio_block(), text="Bonjour") + sidecar = cache.path(key).with_suffix(".json") + assert sidecar.is_file() + assert "Bonjour" in sidecar.read_text(encoding="utf-8") + + def test_survives_a_new_cache_object_on_the_same_directory(self, tmp_path, voice): + first = ChunkCache(tmp_path / "c") + key = first.key("Bonjour", voice) + first.put(key, SR, audio_block()) + + second = ChunkCache(tmp_path / "c") + assert second.get(second.key("Bonjour", voice)) is not None + + +class TestRobustness: + def test_a_corrupt_entry_is_a_miss_not_a_crash(self, cache, voice): + # A process killed mid-write must not poison the next run. + key = cache.key("Bonjour", voice) + cache.path(key).write_bytes(b"not a wav file at all") + assert cache.get(key) is None + assert not cache.path(key).exists() # dropped so it will be regenerated + + def test_no_temporary_file_is_left_behind(self, cache, voice): + key = cache.key("Bonjour", voice) + cache.put(key, SR, audio_block()) + assert list(cache.root.glob("*.tmp")) == [] + + def test_writes_are_atomic_enough_to_be_readable(self, cache, voice): + key = cache.key("Bonjour", voice) + path = cache.put(key, SR, audio_block(length=48000)) + assert sf.info(str(path)).frames == 48000 + + def test_disabled_cache_stores_and_returns_nothing(self, tmp_path, voice): + disabled = ChunkCache(tmp_path / "off", enabled=False) + key = disabled.key("Bonjour", voice) + assert disabled.put(key, SR, audio_block()) is None + assert disabled.get(key) is None + assert not (tmp_path / "off").exists() + + +class TestMaintenance: + def test_clear_removes_entries_and_counts_them(self, cache, voice): + for text in ("un", "deux", "trois"): + cache.put(cache.key(text, voice), SR, audio_block(), text=text) + assert cache.clear() == 3 + assert list(cache.root.glob("*.wav")) == [] + + def test_size_is_reported(self, cache, voice): + assert cache.size_bytes() == 0 + cache.put(cache.key("un", voice), SR, audio_block(length=48000)) + assert cache.size_bytes() > 0 diff --git a/tests/test_narration_chunking.py b/tests/test_narration_chunking.py new file mode 100644 index 00000000..ea88fd8a --- /dev/null +++ b/tests/test_narration_chunking.py @@ -0,0 +1,101 @@ +"""Tests for segmentation and the pause plan.""" +import pytest + +from narration.chunking import ( + PauseProfile, + split_chapters, + split_into_segments, + split_text_into_chunks, +) + + +class TestSplitTextIntoChunks: + def test_empty(self): + assert split_text_into_chunks("") == [] + assert split_text_into_chunks(" ") == [] + + def test_short_text_is_one_chunk(self): + assert split_text_into_chunks("Bonjour le monde.") == ["Bonjour le monde."] + + def test_sentences_are_packed_up_to_the_limit(self): + text = "Un. Deux. Trois. Quatre." + chunks = split_text_into_chunks(text, max_chars=12) + assert all(len(c) <= 12 for c in chunks) + assert " ".join(chunks) == text + + def test_no_sentence_is_ever_cut_in_half(self): + long_sentence = "mot " * 200 + chunks = split_text_into_chunks(long_sentence.strip(), max_chars=50) + # An over-long sentence stays whole rather than being cut mid-clause. + assert len(chunks) == 1 + + def test_every_word_survives(self): + text = "Première phrase ici. Deuxième phrase là. Troisième enfin." + assert " ".join(split_text_into_chunks(text, max_chars=25)) == text + + +class TestSplitIntoSegments: + def test_pause_is_longer_after_a_paragraph_than_after_a_sentence(self): + segments = split_into_segments("Une phrase. Une autre.\n\nNouveau paragraphe.", max_chars=15) + pauses = [s.pause_after for s in segments] + profile = PauseProfile() + assert profile.paragraph in pauses + assert profile.sentence in pauses + assert max(pauses) == profile.paragraph + + def test_a_split_that_lands_mid_sentence_gets_the_shortest_pause(self): + # A line break inside a paragraph (verse, dialogue, an address) is a + # split point that is not a sentence end. + segments = split_into_segments("Première ligne\nDeuxième ligne.", max_chars=20) + assert [s.text for s in segments] == ["Première ligne", "Deuxième ligne."] + assert segments[0].pause_after == PauseProfile().clause + + def test_segments_never_span_a_paragraph(self): + segments = split_into_segments("Court.\n\nAussi court.", max_chars=500) + assert [s.text for s in segments] == ["Court.", "Aussi court."] + assert [s.paragraph for s in segments] == [0, 1] + + @pytest.mark.parametrize("ending", ["Vraiment?", "Incroyable!", "Et alors…"]) + def test_question_and_exclamation_end_sentences(self, ending): + # A second sentence keeps the first one away from the paragraph end, + # where the longer paragraph pause would apply instead. + segments = split_into_segments(f"{ending} Puis il partit.", max_chars=12) + assert segments[0].pause_after == PauseProfile().sentence + + def test_closing_quote_after_the_full_stop_still_ends_the_sentence(self): + segments = split_into_segments('Il dit "oui." Puis il partit.', max_chars=14) + assert segments[0].pause_after == PauseProfile().sentence + + def test_the_last_segment_of_a_paragraph_gets_the_paragraph_pause(self): + segments = split_into_segments("Une phrase.") + assert segments[0].pause_after == PauseProfile().paragraph + + def test_custom_profile_is_honoured(self): + profile = PauseProfile(clause=0.1, sentence=0.2, paragraph=0.3) + segments = split_into_segments("Une phrase.\n\nUne autre.", profile=profile) + assert segments[0].pause_after == 0.3 + + def test_empty_text(self): + assert split_into_segments("") == [] + + +class TestSplitChapters: + def test_default_separator(self): + assert split_chapters("Un\n\n---\n\nDeux") == ["Un", "Deux"] + + def test_text_without_a_separator_is_a_single_chapter(self): + assert split_chapters("Un seul chapitre.") == ["Un seul chapitre."] + + def test_empty_text_yields_nothing(self): + assert split_chapters("") == [] + + def test_blank_chapters_are_dropped(self): + assert split_chapters("Un\n---\n\n\n---\nDeux") == ["Un", "Deux"] + + def test_custom_pattern(self): + chapters = split_chapters("A\nCHAPITRE\nB", pattern=r"(?m)^CHAPITRE$") + assert chapters == ["A", "B"] + + @pytest.mark.parametrize("separator", ["---", " --- ", "--- "]) + def test_separator_tolerates_surrounding_whitespace(self, separator): + assert split_chapters(f"Un\n{separator}\nDeux") == ["Un", "Deux"] diff --git a/tests/test_narration_text_fr.py b/tests/test_narration_text_fr.py new file mode 100644 index 00000000..9f348963 --- /dev/null +++ b/tests/test_narration_text_fr.py @@ -0,0 +1,272 @@ +"""Tests for the French text preprocessor. + +Number agreement and the Roman-numeral rules carry most of the risk here: they +are the parts that silently produce a *plausible but wrong* reading, which is +exactly what nobody notices until they hear it in a finished chapter. +""" +import json + +import pytest + +from narration.text_fr import ( + cardinal, + load_lexicon, + normalize_french, + ordinal, + roman_to_int, +) + + +class TestCardinal: + @pytest.mark.parametrize( + "value,expected", + [ + (0, "zéro"), + (1, "un"), + (16, "seize"), + (17, "dix-sept"), + (20, "vingt"), + (21, "vingt et un"), + (22, "vingt-deux"), + (31, "trente et un"), + (70, "soixante-dix"), + (71, "soixante et onze"), + (72, "soixante-douze"), + (79, "soixante-dix-neuf"), + (90, "quatre-vingt-dix"), + (91, "quatre-vingt-onze"), + (99, "quatre-vingt-dix-neuf"), + ], + ) + def test_the_awkward_tens(self, value, expected): + assert cardinal(value) == expected + + @pytest.mark.parametrize( + "value,expected", + [ + (80, "quatre-vingts"), + (81, "quatre-vingt-un"), + (100, "cent"), + (101, "cent un"), + (180, "cent quatre-vingts"), + (200, "deux cents"), + (201, "deux cent un"), + (280, "deux cent quatre-vingts"), + ], + ) + def test_plural_agreement_when_the_number_ends(self, value, expected): + assert cardinal(value) == expected + + @pytest.mark.parametrize( + "value,expected", + [ + (200_000, "deux cent mille"), # not "deux cents mille" + (80_000, "quatre-vingt mille"), # not "quatre-vingts mille" + (200_000_000, "deux cents millions"), # million is a noun: agreement returns + ], + ) + def test_agreement_is_suppressed_before_another_numeral(self, value, expected): + assert cardinal(value) == expected + + @pytest.mark.parametrize( + "value,expected", + [ + (1000, "mille"), + (1001, "mille un"), + (1234, "mille deux cent trente-quatre"), + (1789, "mille sept cent quatre-vingt-neuf"), + (1_000_000, "un million"), + (2_000_000, "deux millions"), + (1_000_000_000, "un milliard"), + ], + ) + def test_scales(self, value, expected): + assert cardinal(value) == expected + + def test_mille_never_takes_un_or_an_s(self): + assert cardinal(1000) == "mille" + assert "un mille" not in cardinal(1500) + + def test_negative(self): + assert cardinal(-5) == "moins cinq" + + +class TestOrdinal: + @pytest.mark.parametrize( + "value,expected", + [ + (1, "premier"), + (2, "deuxième"), + (4, "quatrième"), + (5, "cinquième"), + (9, "neuvième"), + (11, "onzième"), + (20, "vingtième"), + (21, "vingt et unième"), + (80, "quatre-vingtième"), + (100, "centième"), + (1000, "millième"), + ], + ) + def test_ordinals(self, value, expected): + assert ordinal(value) == expected + + def test_feminine_first(self): + assert ordinal(1, feminine=True) == "première" + + +class TestRoman: + @pytest.mark.parametrize("text,value", [("XIV", 14), ("MCMXCIV", 1994), ("IX", 9), ("i", 1)]) + def test_valid(self, text, value): + assert roman_to_int(text) == value + + @pytest.mark.parametrize("text", ["IIII", "VV", "", " ", "ABC", "XIIX"]) + def test_malformed_is_rejected(self, text): + assert roman_to_int(text) is None + + +class TestNormalizeFrench: + def test_empty(self): + assert normalize_french("") == "" + assert normalize_french(" \n ") == "" + + def test_numbers_are_spelled_out(self): + assert "mille sept cent quatre-vingt-neuf" in normalize_french("En 1789, tout bascula.") + + def test_thousands_separator_does_not_swallow_the_following_space(self): + # A greedy digits-and-spaces pattern used to match "12 " and glue the + # spelled-out number to the next word ("douzeet"). + assert normalize_french("Il y a 12 et 3 000 raisons.") == ( + "Il y a douze et trois mille raisons." + ) + + def test_common_words_are_not_roman_numerals(self): + # "Le" is L + e; without a guard it reads as the 50th. + for word in ("Le", "Ce", "De", "Me"): + result = normalize_french(f"{word} manuscrit") + assert result == f"{word} manuscrit", result + + def test_genuine_roman_ordinals_still_expand(self): + assert "dix-neuvième siècle" in normalize_french("au XIXe siècle") + assert "cinquième République" in normalize_french("la Ve République") + assert "vingtième" in normalize_french("le XXème anniversaire") + + def test_roman_numeral_after_a_trigger_word(self): + assert "chapitre quatorze" in normalize_french("Voir chapitre XIV pour la suite.") + + def test_a_spelled_out_number_after_a_trigger_is_left_alone(self): + # "dix" parses as the Roman numeral DIX (509) under a case-insensitive + # match — the numeral side must stay case-sensitive. + assert normalize_french("Au chapitre dix, rien.") == "Au chapitre dix, rien." + + def test_roman_numeral_alone_on_a_line_is_a_heading(self): + assert normalize_french("XIV\n\nIl arriva.").startswith("quatorze") + + @pytest.mark.parametrize( + "source,expected", + [ + ("M. Dupont arriva", "Monsieur Dupont arriva"), + ("Mme Leblanc", "Madame Leblanc"), + ("le Dr Martin", "le Docteur Martin"), + ("de Me Durand", "de Maître Durand"), + ("MM. Dupont et Durand", "Messieurs Dupont et Durand"), + ], + ) + def test_abbreviations(self, source, expected): + assert normalize_french(source) == expected + + def test_etc_keeps_its_sentence_ending_period(self): + # Consuming the period would merge two sentences and lose the pause. + assert normalize_french("des pommes, etc. Il partit.") == ( + "des pommes, et cetera. Il partit." + ) + + def test_before_christ_keeps_its_period(self): + assert normalize_french("En 52 av. J.-C. Les Romains.") == ( + "En cinquante-deux avant Jésus-Christ. Les Romains." + ) + + @pytest.mark.parametrize( + "source,expected", + [ + ("14h30", "quatorze heures trente"), + ("2h", "deux heures"), + ("1h", "une heure"), + ("8h00", "huit heures"), + ], + ) + def test_times(self, source, expected): + assert normalize_french(source) == expected + + def test_an_hour_like_number_is_not_a_time(self): + assert normalize_french("il y a 2 hommes") == "il y a deux hommes" + + @pytest.mark.parametrize( + "source,expected", + [ + ("1 250 €", "mille deux cent cinquante euros"), + ("1 €", "un euro"), + ("3,50 €", "trois euros cinquante centimes"), + ("$5", "cinq dollars"), + ], + ) + def test_currency(self, source, expected): + assert normalize_french(source) == expected + + def test_percentages(self): + assert normalize_french("3,5 %") == "trois virgule cinq pour cent" + assert normalize_french("50 %") == "cinquante pour cent" + + def test_ordinal_marks(self): + assert normalize_french("la 1re fois") == "la première fois" + assert normalize_french("le 1er jour") == "le premier jour" + assert normalize_french("la 2e chance") == "la deuxième chance" + assert normalize_french("les 3es places") == "les troisièmes places" + + def test_decimals(self): + assert normalize_french("3,5 litres") == "trois virgule cinq litres" + + def test_dialogue_dash_is_removed_and_guillemets_dropped(self): + assert normalize_french("— Bonjour, dit-il.") == "Bonjour, dit-il." + assert normalize_french("Il dit « bonjour ».") == "Il dit bonjour." + + def test_markdown_is_stripped(self): + assert normalize_french("## Titre\n\nUn **mot** important.") == ( + "Titre\n\nUn mot important." + ) + + def test_french_spacing_before_punctuation_is_removed(self): + assert normalize_french("Vraiment ?") == "Vraiment?" + + def test_lexicon_overrides_are_applied(self): + result = normalize_french("La SNCF annonce", lexicon={"SNCF": "S N C F"}) + assert result == "La S N C F annonce" + + def test_lexicon_matches_whole_words_only(self): + assert normalize_french("chat chatte", lexicon={"chat": "minou"}) == "minou chatte" + + def test_roman_expansion_can_be_disabled(self): + assert "XIV" in normalize_french("chapitre XIV", expand_roman=False) + + +class TestLoadLexicon: + def test_missing_file_yields_empty(self, tmp_path): + assert load_lexicon(tmp_path / "nope.json") == {} + + def test_malformed_file_yields_empty_rather_than_raising(self, tmp_path): + path = tmp_path / "bad.json" + path.write_text("{ not json", encoding="utf-8") + assert load_lexicon(path) == {} + + def test_comment_keys_are_ignored(self, tmp_path): + path = tmp_path / "lex.json" + path.write_text( + json.dumps({"_comment": "note", "SNCF": "S N C F"}, ensure_ascii=False), + encoding="utf-8", + ) + assert load_lexicon(path) == {"SNCF": "S N C F"} + + def test_a_json_list_is_not_a_lexicon(self, tmp_path): + path = tmp_path / "list.json" + path.write_text("[1, 2]", encoding="utf-8") + assert load_lexicon(path) == {} From 7031c554c89274167d785b72bea816486571661a Mon Sep 17 00:00:00 2001 From: PaxHelios Date: Tue, 28 Jul 2026 21:58:56 +0200 Subject: [PATCH 10/98] feat(app): "Livre audio" tab driving the full narration pipeline Adds a book-oriented tab alongside the existing Studio one: .txt chapter import (chapters split on a lone `---`), a dry-run preview showing the segmentation, the estimated duration and the prepared text before any audio is generated, per-chapter generation resumable through the segment cache, and assembly into a single file. The chunking that used to live here now comes from narration.chunking, which also supplies the pause plan; trimming and mastering come from narration.audio. Studio behaviour is unchanged. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01UqWxj2j9bdavcLn25ckX8X --- app.py | 697 +++++++++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 548 insertions(+), 149 deletions(-) diff --git a/app.py b/app.py index 719596c3..84c3b73f 100644 --- a/app.py +++ b/app.py @@ -6,6 +6,7 @@ import logging import random import numpy as np +import soundfile as sf import gradio as gr from typing import Any, List, Optional, Tuple from pathlib import Path @@ -20,6 +21,13 @@ import voxcpm from voxcpm.model.utils import resolve_runtime_device +# Audiobook production chain. These modules depend only on numpy/soundfile, so +# they stay importable (and testable) without the engine. +from narration import assemble as assembly +from narration import audio as audio_tools +from narration import cache as cache_tools +from narration import chunking, text_fr + logging.basicConfig( level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s", @@ -133,6 +141,30 @@ "J'ai enchaîné les tubes toute la matinée — c'est juste, genre, parfait, tu vois ce que je veux dire ?\"*" ) +_BOOK_INTRO_EN = ( + "### 📚 Narrate a whole book\n\n" + "Load a `.txt` file, pick a voice in the **Studio** tab, then narrate. Chapters are " + "separated by a line containing only `---`.\n\n" + "- Every chapter is written to `output/book_/` as it finishes, so nothing is lost " + "if you stop midway.\n" + "- Every segment is cached: restarting resumes at the segment it stopped on, not at the " + "beginning of the chapter.\n" + "- **On CPU this is slow** (roughly 40× slower than real time). Narrate a chapter or two " + "to check the voice before committing to a whole book." +) + +_BOOK_INTRO_FR = ( + "### 📚 Narrer un livre entier\n\n" + "Chargez un fichier `.txt`, choisissez une voix dans l'onglet **Studio**, puis lancez la " + "narration. Les chapitres sont séparés par une ligne contenant uniquement `---`.\n\n" + "- Chaque chapitre est écrit dans `output/book_/` dès qu'il est terminé : rien n'est " + "perdu si vous arrêtez en cours de route.\n" + "- Chaque segment est mis en cache : relancer reprend au segment interrompu, pas au début " + "du chapitre.\n" + "- **Sur CPU c'est lent** (environ 40× le temps réel). Narrez un ou deux chapitres pour " + "valider la voix avant de lancer un livre entier." +) + _I18N_TRANSLATIONS = { "en": { "reference_audio_label": "🎤 Reference Audio (optional — upload for cloning)", @@ -167,6 +199,30 @@ "chunk_size_label": "Max characters per chunk", "chunk_size_info": "Target size of each chunk when splitting long texts (whole sentences are kept together).", "load_txt_label": "📄 Load a .txt file", + "prepare_text_label": "Prepare French text", + "prepare_text_info": "Read numbers, abbreviations and Roman numerals as a narrator would (1789, M. Dupont, XIVe).", + "master_label": "Audiobook mastering", + "master_info": "Punctuation-aware pauses, trimmed segment edges, click-free joins and one loudness pass.", + "tab_studio": "🎙️ Studio", + "tab_book": "📚 Audiobook", + "book_intro": _BOOK_INTRO_EN, + "book_file_label": "📄 Load the book (.txt)", + "book_text_label": "Book text — separate chapters with a line containing only ---", + "book_title_label": "Book title", + "book_author_label": "Author / narrator", + "book_plan_btn": "🔍 Analyse without generating", + "book_plan_label": "Plan", + "book_generate_btn": "📖 Narrate the book", + "book_assemble_btn": "📦 Assemble the audiobook", + "book_format_label": "Format", + "book_status_label": "Progress", + "book_audio_label": "Last finished chapter", + "book_file_output_label": "Assembled file", + "book_settings_title": "⚙️ Narration settings", + "book_target_rms_label": "Loudness target (dBFS)", + "book_target_rms_info": "Audiobook platforms expect RMS between -23 and -18 dBFS.", + "book_pause_sentence_label": "Pause after a sentence (s)", + "book_pause_paragraph_label": "Pause after a paragraph (s)", "usage_instructions": _USAGE_INSTRUCTIONS_EN, "examples_footer": _EXAMPLES_FOOTER_EN, }, @@ -203,6 +259,30 @@ "chunk_size_label": "Caractères max par segment", "chunk_size_info": "Taille cible de chaque segment lors du découpage (les phrases entières restent groupées).", "load_txt_label": "📄 Charger un fichier .txt", + "prepare_text_label": "Préparation du texte français", + "prepare_text_info": "Fait lire les nombres, abréviations et chiffres romains comme un narrateur (1789, M. Dupont, XIVe).", + "master_label": "Mastering livre audio", + "master_info": "Pauses selon la ponctuation, bords des segments nettoyés, jointures sans clic et un seul passage de normalisation.", + "tab_studio": "🎙️ Studio", + "tab_book": "📚 Livre audio", + "book_intro": _BOOK_INTRO_FR, + "book_file_label": "📄 Charger le livre (.txt)", + "book_text_label": "Texte du livre — séparez les chapitres par une ligne contenant seulement ---", + "book_title_label": "Titre du livre", + "book_author_label": "Auteur / narrateur", + "book_plan_btn": "🔍 Analyser sans générer", + "book_plan_label": "Plan", + "book_generate_btn": "📖 Narrer le livre", + "book_assemble_btn": "📦 Assembler le livre audio", + "book_format_label": "Format", + "book_status_label": "Avancement", + "book_audio_label": "Dernier chapitre terminé", + "book_file_output_label": "Fichier assemblé", + "book_settings_title": "⚙️ Réglages de narration", + "book_target_rms_label": "Cible de sonie (dBFS)", + "book_target_rms_info": "Les plateformes de livres audio attendent un RMS entre -23 et -18 dBFS.", + "book_pause_sentence_label": "Pause après une phrase (s)", + "book_pause_paragraph_label": "Pause après un paragraphe (s)", "usage_instructions": _USAGE_INSTRUCTIONS_FR, "examples_footer": _EXAMPLES_FOOTER_FR, }, @@ -375,11 +455,14 @@ def _voice_names_for_lang(lang: Optional[str]) -> List[str]: return [v["name"] for v in PRESET_VOICES if lang is None or v["lang"] == lang] # ---------- Long-text chunking (audiobooks) ---------- -# Split on sentence boundaries so each generated chunk stays a reasonable length, -# then stitch the audio with a short silence between chunks. -_CHUNK_MAX_CHARS = 300 -_CHUNK_SILENCE_SEC = 0.3 -_SENTENCE_SPLIT_RE = re.compile(r"(?<=[.!?…。!?\n])\s+") +# Segmentation and the pause plan live in narration.chunking; the audio side +# (trimming, de-clicking, loudness) lives in narration.audio. +_CHUNK_MAX_CHARS = chunking.DEFAULT_MAX_CHARS +_CHUNK_SILENCE_SEC = chunking.PauseProfile().sentence + +# Where a book narrated from the UI keeps its chapters and its resume cache. +_BOOKS_DIR = Path(__file__).parent / "output" +_LEXICON_PATH = Path(__file__).parent / "conf" / "pronunciation_fr.json" # Short fixed phrase used to preview a preset voice on demand. _PREVIEW_TEXT = "Bonjour, ceci est un aperçu de cette voix pour la narration de votre livre audio." @@ -410,27 +493,12 @@ def _save_output_wav(wav_np: np.ndarray, sr: int, seed: Optional[int], voice_nam def _split_text_into_chunks(text: str, max_chars: int = _CHUNK_MAX_CHARS) -> List[str]: """Greedily pack whole sentences into chunks no longer than ``max_chars``. - A single sentence longer than the limit becomes its own chunk.""" - text = (text or "").strip() - if not text: - return [] - sentences = [s.strip() for s in _SENTENCE_SPLIT_RE.split(text) if s.strip()] - chunks: List[str] = [] - current = "" - for sentence in sentences: - if len(sentence) > max_chars: - if current: - chunks.append(current) - current = "" - chunks.append(sentence) - elif current and len(current) + 1 + len(sentence) > max_chars: - chunks.append(current) - current = sentence - else: - current = f"{current} {sentence}" if current else sentence - if current: - chunks.append(current) - return chunks + + Thin wrapper kept for the existing single-shot path and any external caller; + the segmentation itself now lives in ``narration.chunking``, alongside the + pause plan that long-form narration needs. + """ + return chunking.split_text_into_chunks(text, max_chars) _CUSTOM_CSS = """ .logo-container { @@ -700,6 +768,8 @@ def _generate( enable_chunking: bool, chunk_max_chars: int, preset_name: str = "", + prepare_text: bool = False, + master_audio: bool = True, progress=gr.Progress(), ): actual_prompt_text = prompt_text_value.strip() if use_prompt_text else "" @@ -707,6 +777,9 @@ def _generate( seed = _coerce_seed(seed_value) voice_name = preset_name if preset_name and preset_name != PRESET_CUSTOM_LABEL else "custom" + if prepare_text: + text = text_fr.normalize_french(text, lexicon=text_fr.load_lexicon(_LEXICON_PATH)) + common = dict( control_instruction=actual_control, reference_wav_path_input=ref_wav, @@ -719,21 +792,29 @@ def _generate( ) # Only chunk plain Voice Design / control text — cloning modes keep a single pass. - chunks = _split_text_into_chunks(text, int(chunk_max_chars)) if enable_chunking else [] - if len(chunks) <= 1 or ref_wav or actual_prompt_text: + segments = ( + chunking.split_into_segments(text, int(chunk_max_chars)) if enable_chunking else [] + ) + if len(segments) <= 1 or ref_wav or actual_prompt_text: sr, wav_np, last_successful_seed = demo.generate_tts_audio(text_input=text, **common) else: - logger.info(f"Chunked synthesis: {len(chunks)} segments.") + logger.info(f"Chunked synthesis: {len(segments)} segments.") sr = None - parts: List[np.ndarray] = [] + rendered: List[Tuple[np.ndarray, float]] = [] last_successful_seed = seed - for i, chunk in enumerate(progress.tqdm(chunks, desc="Synthèse des segments")): - logger.info(f" segment {i + 1}/{len(chunks)}") - sr, wav_chunk, last_successful_seed = demo.generate_tts_audio(text_input=chunk, **common) - if i > 0: - parts.append(np.zeros(int(sr * _CHUNK_SILENCE_SEC), dtype=wav_chunk.dtype)) - parts.append(wav_chunk) - wav_np = np.concatenate(parts) + for i, segment in enumerate(progress.tqdm(segments, desc="Synthèse des segments")): + logger.info(f" segment {i + 1}/{len(segments)}") + sr, wav_chunk, last_successful_seed = demo.generate_tts_audio( + text_input=segment.text, **common + ) + rendered.append((wav_chunk, segment.pause_after)) + if master_audio: + # Punctuation-aware pauses, de-clicked joins, one loudness pass. + wav_np = audio_tools.stitch(rendered, sr, audio_tools.MasteringSettings()) + else: + wav_np = audio_tools.concatenate( + (wav for wav, _ in rendered), sr, gap_sec=_CHUNK_SILENCE_SEC + ) out_path = _save_output_wav(wav_np, sr, last_successful_seed, voice_name) return out_path, last_successful_seed @@ -764,6 +845,192 @@ def _preview_voice(description, seed_value, cfg, steps, normalize): logger.warning(f"Could not cache preview ({e}); returning in-memory audio.") return (sr, wav_np) + # ---------- Audiobook tab ---------- + + def _book_dir(title: str) -> Path: + """Where a book's chapters and its resume cache live.""" + return _BOOKS_DIR / f"book_{_sanitize_filename(title or 'livre')}" + + def _book_prepared_chapters(book_text: str, prepare: bool) -> List[str]: + chapters = chunking.split_chapters(book_text) + if not prepare: + return chapters + lexicon = text_fr.load_lexicon(_LEXICON_PATH) + return [text_fr.normalize_french(chapter, lexicon=lexicon) for chapter in chapters] + + def _book_profile(pause_sentence: float, pause_paragraph: float) -> chunking.PauseProfile: + default = chunking.PauseProfile() + return chunking.PauseProfile( + clause=min(default.clause, float(pause_sentence)), + sentence=float(pause_sentence), + paragraph=float(pause_paragraph), + ) + + def _book_plan(book_text, chunk_max_chars_value, prepare, pause_sentence, pause_paragraph): + """Show what would be generated, without loading the model.""" + chapters = _book_prepared_chapters(book_text, prepare) + if not chapters: + return "*Aucun texte à analyser.*" + + profile = _book_profile(pause_sentence, pause_paragraph) + rows, total_segments, total_chars = [], 0, 0 + for index, chapter in enumerate(chapters, 1): + segments = chunking.split_into_segments(chapter, int(chunk_max_chars_value), profile) + characters = chunking.total_characters(segments) + total_segments += len(segments) + total_chars += characters + rows.append(f"| {index} | {len(segments)} | {characters} |") + + # ~14 characters of prose per second of finished narration, and roughly + # 40x real time to synthesize on CPU — both rough, but the difference + # between "an afternoon" and "several days" is worth knowing up front. + minutes = total_chars / 14.0 / 60.0 + preview = "" + first = chunking.split_into_segments(chapters[0], int(chunk_max_chars_value), profile) + if first: + preview = f"\n\n**Premier segment tel qu'il sera lu :**\n\n> {first[0].text}" + + return ( + f"**{len(chapters)} chapitre(s) · {total_segments} segment(s) · {total_chars} caractères**\n\n" + f"Durée de narration estimée : **~{minutes:.0f} min** " + f"(soit ~{minutes * 40 / 60:.1f} h de calcul sur CPU)\n\n" + "| Chapitre | Segments | Caractères |\n|---|---|---|\n" + "\n".join(rows) + preview + ) + + def _book_narrate( + book_text, + title, + author, + control_instruction, + cfg_value, + dit_steps, + do_normalize, + seed_value, + chunk_max_chars_value, + prepare, + target_rms, + pause_sentence, + pause_paragraph, + preset_name, + progress=gr.Progress(), + ): + """Narrate every chapter, writing each one to disk as soon as it is done. + + Yields after each chapter so the UI shows progress on a job that runs for + hours, and so a finished chapter is listenable before the book is. + """ + chapters = _book_prepared_chapters(book_text, prepare) + if not chapters: + raise gr.Error("Aucun texte à narrer. Chargez un fichier .txt ou collez le texte.") + + description = control_instruction or "" + if not description.strip(): + raise gr.Error( + "Choisissez d'abord une voix dans l'onglet Studio " + "(la description de la voix est vide)." + ) + + seed = _coerce_seed(seed_value) + outdir = _book_dir(title) + outdir.mkdir(parents=True, exist_ok=True) + profile = _book_profile(pause_sentence, pause_paragraph) + mastering = audio_tools.MasteringSettings(target_rms_db=float(target_rms)) + voice_spec = cache_tools.VoiceSpec( + description=description, + seed=seed, + cfg=float(cfg_value), + steps=int(dit_steps), + normalize=bool(do_normalize), + model_id=demo._model_id, + ) + cache = cache_tools.ChunkCache(outdir / ".cache") + + voice_label = preset_name if preset_name and preset_name != PRESET_CUSTOM_LABEL else "voix personnalisée" + lines = [ + f"### Narration en cours\n", + f"Voix : **{voice_label}** · graine `{seed}` · dossier `{outdir.name}`\n", + ] + last_chapter_path = None + yield "\n".join(lines), None + + for index, chapter in enumerate(chapters, 1): + out = outdir / f"chapitre_{index:03d}.wav" + if out.is_file(): + lines.append(f"- ⏭️ Chapitre {index}/{len(chapters)} — déjà généré, ignoré") + last_chapter_path = str(out) + yield "\n".join(lines), last_chapter_path + continue + + segments = chunking.split_into_segments(chapter, int(chunk_max_chars_value), profile) + if not segments: + lines.append(f"- ⚠️ Chapitre {index}/{len(chapters)} — vide, ignoré") + yield "\n".join(lines), last_chapter_path + continue + + rendered: List[Tuple[np.ndarray, float]] = [] + sr = None + for segment in progress.tqdm(segments, desc=f"Chapitre {index}/{len(chapters)}"): + key = cache.key(segment.text, voice_spec) + cached = cache.get(key) + if cached is not None: + sr, wav_chunk = cached + else: + sr, wav_chunk, _ = demo.generate_tts_audio( + text_input=segment.text, + control_instruction=description, + cfg_value_input=cfg_value, + do_normalize=do_normalize, + inference_timesteps=int(dit_steps), + seed=seed, + ) + cache.put(key, sr, wav_chunk, text=segment.text) + rendered.append((wav_chunk, segment.pause_after)) + + chapter_audio = audio_tools.stitch(rendered, sr, mastering) + sf.write(str(out), chapter_audio, sr, subtype="PCM_16") + report = audio_tools.acx_report(chapter_audio, sr) + last_chapter_path = str(out) + lines.append( + f"- ✅ Chapitre {index}/{len(chapters)} — {report['duration_sec'] / 60:.1f} min, " + f"RMS {report['rms_db']:.1f} dBFS → `{out.name}`" + ) + yield "\n".join(lines), last_chapter_path + + lines.append(f"\n**Terminé.** {cache.stats.describe()}") + lines.append(f"\nChapitres dans `{outdir}` — utilisez « Assembler » pour un fichier unique.") + yield "\n".join(lines), last_chapter_path + + def _book_assemble(title, author, output_format): + """Join the generated chapters into one chaptered file.""" + outdir = _book_dir(title) + chapter_files = sorted(outdir.glob("chapitre_*.wav")) + if not chapter_files: + raise gr.Error(f"Aucun chapitre trouvé dans {outdir}. Lancez d'abord la narration.") + + target = outdir / f"{outdir.name}_complet.{output_format}" + result = assembly.assemble( + chapter_files, + target, + title=title or outdir.name, + author=author or "", + ) + message = [ + f"### Assemblage\n", + f"- {len(result.chapters)} chapitre(s) · **{result.duration_sec / 60:.1f} min**", + f"- {result.message}", + ] + if result.pending_command: + import subprocess + + message.append( + "\nffmpeg n'est pas installé. Le WAV complet et les marqueurs de chapitres sont " + "prêts ; lancez ensuite :\n\n```\n" + + subprocess.list2cmdline(result.pending_command) + + "\n```" + ) + delivered = result.output_path or result.wav_path + return "\n".join(message), str(delivered) + def _on_toggle_instant(checked): """Instant UI toggle — no ASR, no blocking.""" if checked: @@ -796,123 +1063,202 @@ def _run_asr_if_needed(checked, audio_path): "" ) - gr.Markdown(I18N("usage_instructions")) + with gr.Tabs(): + with gr.Tab(I18N("tab_studio")): + gr.Markdown(I18N("usage_instructions")) - with gr.Row(): - with gr.Column(): - reference_wav = gr.Audio( - sources=["upload", "microphone"], - type="filepath", - label=I18N("reference_audio_label"), - ) - show_prompt_text = gr.Checkbox( - value=False, - label=I18N("show_prompt_text_label"), - info=I18N("show_prompt_text_info"), - elem_classes=["switch-toggle"], - ) - prompt_text = gr.Textbox( - value="", - label=I18N("prompt_text_label"), - placeholder=I18N("prompt_text_placeholder"), - lines=2, - visible=False, - ) - _default_lang = _PRESET_LANGS[0] if _PRESET_LANGS else None - preset_lang = gr.Dropdown( - choices=[(_lang_label(c), c) for c in _PRESET_LANGS], - value=_default_lang, - label=I18N("preset_lang_label"), - visible=len(_PRESET_LANGS) > 1, # only show when there is a choice to make - ) - preset_voice = gr.Dropdown( - choices=[PRESET_CUSTOM_LABEL] + _voice_names_for_lang(_default_lang), - value=PRESET_CUSTOM_LABEL, - label=I18N("preset_voices_label"), - info=I18N("preset_voices_info"), - ) - preview_btn = gr.Button(I18N("preview_btn_label"), size="sm") - preview_audio = gr.Audio(label=I18N("preview_btn_label"), visible=False) - control_instruction = gr.Textbox( - value="", - label=I18N("control_label"), - placeholder=I18N("control_placeholder"), - lines=2, - ) - text = gr.Textbox( - value=DEFAULT_TARGET_TEXT, - label=I18N("target_text_label"), - lines=3, - ) - load_txt_btn = gr.UploadButton( - I18N("load_txt_label"), - file_types=[".txt"], - size="sm", - ) - - with gr.Accordion(I18N("advanced_settings_title"), open=False): - DoDenoisePromptAudio = gr.Checkbox( - value=False, - label=I18N("ref_denoise_label"), - elem_classes=["switch-toggle"], - info=I18N("ref_denoise_info"), - ) - DoNormalizeText = gr.Checkbox( - value=False, - label=I18N("normalize_label"), - elem_classes=["switch-toggle"], - info=I18N("normalize_info"), - ) - enable_chunking = gr.Checkbox( - value=True, - label=I18N("chunking_label"), - elem_classes=["switch-toggle"], - info=I18N("chunking_info"), - ) - chunk_max_chars = gr.Slider( - minimum=100, - maximum=600, - value=_CHUNK_MAX_CHARS, - step=20, - label=I18N("chunk_size_label"), - info=I18N("chunk_size_info"), - ) - cfg_value = gr.Slider( - minimum=1.0, - maximum=3.0, - value=2.0, - step=0.1, - label=I18N("cfg_label"), - info=I18N("cfg_info"), - ) - dit_steps = gr.Slider( - minimum=1, - maximum=50, - value=10, - step=1, - label=I18N("dit_steps_label"), - info=I18N("dit_steps_info"), - ) - with gr.Row(): - seed_value = gr.Number( - value=random.randint(0, 2**32 - 1), - precision=0, - label=I18N("seed_label"), - info=I18N("seed_info"), - interactive=False, + with gr.Row(): + with gr.Column(): + reference_wav = gr.Audio( + sources=["upload", "microphone"], + type="filepath", + label=I18N("reference_audio_label"), ) - random_seed = gr.Checkbox( - value=True, - label=I18N("random_seed_label"), + show_prompt_text = gr.Checkbox( + value=False, + label=I18N("show_prompt_text_label"), + info=I18N("show_prompt_text_info"), elem_classes=["switch-toggle"], - info=I18N("random_seed_info"), + ) + prompt_text = gr.Textbox( + value="", + label=I18N("prompt_text_label"), + placeholder=I18N("prompt_text_placeholder"), + lines=2, + visible=False, + ) + _default_lang = _PRESET_LANGS[0] if _PRESET_LANGS else None + preset_lang = gr.Dropdown( + choices=[(_lang_label(c), c) for c in _PRESET_LANGS], + value=_default_lang, + label=I18N("preset_lang_label"), + visible=len(_PRESET_LANGS) > 1, # only show when there is a choice to make + ) + preset_voice = gr.Dropdown( + choices=[PRESET_CUSTOM_LABEL] + _voice_names_for_lang(_default_lang), + value=PRESET_CUSTOM_LABEL, + label=I18N("preset_voices_label"), + info=I18N("preset_voices_info"), + ) + preview_btn = gr.Button(I18N("preview_btn_label"), size="sm") + preview_audio = gr.Audio(label=I18N("preview_btn_label"), visible=False) + control_instruction = gr.Textbox( + value="", + label=I18N("control_label"), + placeholder=I18N("control_placeholder"), + lines=2, + ) + text = gr.Textbox( + value=DEFAULT_TARGET_TEXT, + label=I18N("target_text_label"), + lines=3, + ) + load_txt_btn = gr.UploadButton( + I18N("load_txt_label"), + file_types=[".txt"], + size="sm", ) - run_btn = gr.Button(I18N("generate_btn"), variant="primary", size="lg") - - with gr.Column(): - audio_output = gr.Audio(label=I18N("generated_audio_label")) - gr.Markdown(I18N("examples_footer")) + with gr.Accordion(I18N("advanced_settings_title"), open=False): + DoDenoisePromptAudio = gr.Checkbox( + value=False, + label=I18N("ref_denoise_label"), + elem_classes=["switch-toggle"], + info=I18N("ref_denoise_info"), + ) + DoNormalizeText = gr.Checkbox( + value=False, + label=I18N("normalize_label"), + elem_classes=["switch-toggle"], + info=I18N("normalize_info"), + ) + prepare_text = gr.Checkbox( + value=False, + label=I18N("prepare_text_label"), + elem_classes=["switch-toggle"], + info=I18N("prepare_text_info"), + ) + master_audio = gr.Checkbox( + value=True, + label=I18N("master_label"), + elem_classes=["switch-toggle"], + info=I18N("master_info"), + ) + enable_chunking = gr.Checkbox( + value=True, + label=I18N("chunking_label"), + elem_classes=["switch-toggle"], + info=I18N("chunking_info"), + ) + chunk_max_chars = gr.Slider( + minimum=100, + maximum=600, + value=_CHUNK_MAX_CHARS, + step=20, + label=I18N("chunk_size_label"), + info=I18N("chunk_size_info"), + ) + cfg_value = gr.Slider( + minimum=1.0, + maximum=3.0, + value=2.0, + step=0.1, + label=I18N("cfg_label"), + info=I18N("cfg_info"), + ) + dit_steps = gr.Slider( + minimum=1, + maximum=50, + value=10, + step=1, + label=I18N("dit_steps_label"), + info=I18N("dit_steps_info"), + ) + with gr.Row(): + seed_value = gr.Number( + value=random.randint(0, 2**32 - 1), + precision=0, + label=I18N("seed_label"), + info=I18N("seed_info"), + interactive=False, + ) + random_seed = gr.Checkbox( + value=True, + label=I18N("random_seed_label"), + elem_classes=["switch-toggle"], + info=I18N("random_seed_info"), + ) + + run_btn = gr.Button(I18N("generate_btn"), variant="primary", size="lg") + + with gr.Column(): + audio_output = gr.Audio(label=I18N("generated_audio_label")) + gr.Markdown(I18N("examples_footer")) + + with gr.Tab(I18N("tab_book")): + gr.Markdown(I18N("book_intro")) + + with gr.Row(): + with gr.Column(): + book_upload = gr.UploadButton( + I18N("book_file_label"), file_types=[".txt"], size="sm" + ) + book_text = gr.Textbox( + value="", + label=I18N("book_text_label"), + lines=14, + placeholder="Chapitre premier…\n\n---\n\nChapitre deux…", + ) + with gr.Row(): + book_title = gr.Textbox(value="", label=I18N("book_title_label")) + book_author = gr.Textbox(value="", label=I18N("book_author_label")) + + with gr.Accordion(I18N("book_settings_title"), open=False): + book_prepare = gr.Checkbox( + value=True, + label=I18N("prepare_text_label"), + info=I18N("prepare_text_info"), + elem_classes=["switch-toggle"], + ) + book_target_rms = gr.Slider( + minimum=-30.0, + maximum=-12.0, + value=audio_tools.MasteringSettings().target_rms_db, + step=0.5, + label=I18N("book_target_rms_label"), + info=I18N("book_target_rms_info"), + ) + book_pause_sentence = gr.Slider( + minimum=0.0, + maximum=2.0, + value=chunking.PauseProfile().sentence, + step=0.05, + label=I18N("book_pause_sentence_label"), + ) + book_pause_paragraph = gr.Slider( + minimum=0.0, + maximum=3.0, + value=chunking.PauseProfile().paragraph, + step=0.05, + label=I18N("book_pause_paragraph_label"), + ) + + with gr.Row(): + book_plan_btn = gr.Button(I18N("book_plan_btn"), size="sm") + book_run_btn = gr.Button(I18N("book_generate_btn"), variant="primary") + + with gr.Column(): + book_status = gr.Markdown(value="") + book_audio = gr.Audio(label=I18N("book_audio_label")) + with gr.Row(): + book_format = gr.Dropdown( + choices=["m4b", "mp3", "wav"], + value="m4b", + label=I18N("book_format_label"), + scale=1, + ) + book_assemble_btn = gr.Button(I18N("book_assemble_btn"), scale=2) + book_output_file = gr.File(label=I18N("book_file_output_label")) show_prompt_text.change( fn=_on_toggle_instant, @@ -987,12 +1333,65 @@ def _run_asr_if_needed(checked, audio_path): enable_chunking, chunk_max_chars, preset_voice, + prepare_text, + master_audio, ], outputs=[audio_output, seed_value], show_progress=True, api_name="generate", ) + book_upload.upload( + fn=_load_text_file, + inputs=[book_upload], + outputs=[book_text], + ) + + book_plan_btn.click( + fn=_book_plan, + inputs=[book_text, chunk_max_chars, book_prepare, book_pause_sentence, book_pause_paragraph], + outputs=[book_status], + show_progress=False, + ) + + # The voice comes from the Studio tab, so the seed is settled the same + # way as for a single generation before narration starts. + book_run_btn.click( + fn=_prepare_seed, + inputs=[random_seed, seed_value], + outputs=[seed_value], + show_progress=False, + ).then( + fn=_book_narrate, + inputs=[ + book_text, + book_title, + book_author, + control_instruction, + cfg_value, + dit_steps, + DoNormalizeText, + seed_value, + chunk_max_chars, + book_prepare, + book_target_rms, + book_pause_sentence, + book_pause_paragraph, + preset_voice, + ], + outputs=[book_status, book_audio], + show_progress=True, + api_name="narrate_book", + ) + + book_assemble_btn.click( + fn=_book_assemble, + inputs=[book_title, book_author, book_format], + outputs=[book_status, book_output_file], + show_progress=True, + api_name="assemble_book", + ) + return interface From 2a81860f17d8e010ae4a5aae56b06e7ef5affd53 Mon Sep 17 00:00:00 2001 From: PaxHelios Date: Tue, 28 Jul 2026 21:58:57 +0200 Subject: [PATCH 11/98] feat(scripts): port narrate_book to the pipeline, add assemble_audiobook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit narrate_book.py now runs on the narration package: prepared text, pause plan, segment-level cache and mastering, instead of its own inlined copies. --assemble hands the finished chapters straight to the assembly stage. assemble_audiobook.py is the standalone entry point for chapters already on disk — join a directory of per-chapter WAVs into an MP3 or a chaptered M4B, with --check reporting loudness against the audiobook platform targets. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01UqWxj2j9bdavcLn25ckX8X --- scripts/assemble_audiobook.py | 156 +++++++++++++++ scripts/narrate_book.py | 349 ++++++++++++++++++++++++---------- 2 files changed, 401 insertions(+), 104 deletions(-) create mode 100644 scripts/assemble_audiobook.py diff --git a/scripts/assemble_audiobook.py b/scripts/assemble_audiobook.py new file mode 100644 index 00000000..f4f3d881 --- /dev/null +++ b/scripts/assemble_audiobook.py @@ -0,0 +1,156 @@ +"""Assemble per-chapter WAVs into one chaptered audiobook file. + +Run this after ``narrate_book.py`` has produced a directory of chapter WAVs. +Chapters are ordered by filename, which is why ``narrate_book.py`` zero-pads +them (``chapitre_001.wav``, ``chapitre_002.wav``, ...). + +Chapter titles come from ``--titles`` if given, otherwise from a ``titles.txt`` +next to the WAVs (one title per line), otherwise from the filenames. + +Examples +-------- + # M4B with chapter markers (needs ffmpeg on PATH): + ./.venv/Scripts/python.exe scripts/assemble_audiobook.py output/book_mon_livre \\ + --title "Mon Livre" --author "Edwin" --format m4b + + # No ffmpeg installed? This still produces the full WAV plus the chapter file, + # and prints the exact command to run once ffmpeg is available: + ./.venv/Scripts/python.exe scripts/assemble_audiobook.py output/book_mon_livre + + # Check the assembled book against audiobook loudness limits: + ./.venv/Scripts/python.exe scripts/assemble_audiobook.py output/book_mon_livre --check +""" +from __future__ import annotations + +import argparse +import subprocess +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from narration import assemble as assembly # noqa: E402 + + +def _read_titles(directory: Path, explicit: str | None) -> list[str] | None: + if explicit: + path = Path(explicit) + if not path.is_file(): + raise SystemExit(f"Titles file not found: {path}") + return [line.strip() for line in path.read_text(encoding="utf-8").splitlines() if line.strip()] + default = directory / "titles.txt" + if default.is_file(): + return [line.strip() for line in default.read_text(encoding="utf-8").splitlines() if line.strip()] + return None + + +def _report_levels(wav_path: Path) -> None: + """Measure the finished book against the ACX limits.""" + import soundfile as sf + + from narration import audio + + data, sample_rate = sf.read(str(wav_path), dtype="float32", always_2d=False) + report = audio.acx_report(data, sample_rate) + print("\nNiveaux (norme ACX / livre audio) :") + print(f" durée : {report['duration_sec'] / 60:.1f} min") + print( + f" RMS : {report['rms_db']:.1f} dBFS " + f"[{audio.ACX_RMS_MIN_DB:.0f} .. {audio.ACX_RMS_MAX_DB:.0f}] " + f"{'OK' if report['rms_ok'] else 'HORS NORME'}" + ) + print( + f" crête : {report['peak_db']:.1f} dBFS " + f"[<= {audio.ACX_PEAK_CEILING_DB:.0f}] {'OK' if report['peak_ok'] else 'HORS NORME'}" + ) + print( + f" bruit de fond: {report['noise_floor_db']:.1f} dBFS " + f"[<= {audio.ACX_NOISE_FLOOR_DB:.0f}] {'OK' if report['noise_floor_ok'] else 'HORS NORME'}" + ) + print(f" conforme : {'oui' if report['compliant'] else 'non'}") + + +def main() -> int: + parser = argparse.ArgumentParser( + description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter + ) + parser.add_argument("directory", help="Directory holding the chapter .wav files") + parser.add_argument("--out", help="Output file (default: /.)") + parser.add_argument( + "--format", + default="m4b", + choices=["m4b", "m4a", "mp3", "wav"], + help="Container for the finished book (default: m4b)", + ) + parser.add_argument("--title", default="", help="Book title") + parser.add_argument("--author", default="", help="Author / narrator") + parser.add_argument("--titles", help="File with one chapter title per line") + parser.add_argument("--cover", help="Cover image embedded in the finished file") + parser.add_argument( + "--gap", + type=float, + default=assembly.DEFAULT_CHAPTER_GAP_SEC, + help=f"Silence between chapters in seconds (default: {assembly.DEFAULT_CHAPTER_GAP_SEC})", + ) + parser.add_argument("--pattern", default="*.wav", help="Glob selecting chapter files (default: *.wav)") + parser.add_argument("--check", action="store_true", help="Report loudness against the ACX limits") + parser.add_argument( + "--run-pending", + action="store_true", + help="If ffmpeg was missing, try running the encode command anyway", + ) + args = parser.parse_args() + + directory = Path(args.directory) + if not directory.is_dir(): + raise SystemExit(f"Not a directory: {directory}") + + chapters = sorted(p for p in directory.glob(args.pattern) if p.is_file()) + # Never fold a previous assembly back into the book. + chapters = [p for p in chapters if not p.stem.endswith("_complet")] + if not chapters: + raise SystemExit(f"No chapter files matching {args.pattern!r} in {directory}") + + book_name = args.title or directory.name.replace("book_", "").replace("_", " ").strip() or "livre" + out_path = Path(args.out) if args.out else directory / f"{directory.name}_complet.{args.format}" + + print(f"Chapitres : {len(chapters)}") + for path in chapters: + print(f" {path.name}") + + result = assembly.assemble( + chapters, + out_path, + title=book_name, + author=args.author, + titles=_read_titles(directory, args.titles), + gap_sec=args.gap, + cover_path=args.cover, + ) + + print(f"\nDurée totale : {result.duration_sec / 60:.1f} min ({len(result.chapters)} chapitres)") + print(f"WAV : {result.wav_path}") + if result.metadata_path: + print(f"Marqueurs : {result.metadata_path}") + print(result.message) + + if result.pending_command and args.run_pending: + print("\nExécution de la commande ffmpeg…") + completed = subprocess.run(result.pending_command) + if completed.returncode == 0: + result.output_path = out_path + result.pending_command = None + print(f"Écrit : {out_path}") + if result.pending_command: + print("\nÀ exécuter une fois ffmpeg installé :") + print(" " + subprocess.list2cmdline(result.pending_command)) + elif result.output_path: + print(f"Livre audio : {result.output_path}") + + if args.check: + _report_levels(result.wav_path) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/narrate_book.py b/scripts/narrate_book.py index 40f16b50..e94c862c 100644 --- a/scripts/narrate_book.py +++ b/scripts/narrate_book.py @@ -4,37 +4,43 @@ a single generation call is not possible (the engine errors above ~8192 tokens) and holding the whole audio in memory is wasteful. -How it works +The pipeline ------------ -- Reads a UTF-8 ``.txt`` file. Chapters are separated by a line containing only - ``---`` (Markdown horizontal rule) by default, or by ``--chapter-regex``. If no - separator is found, the whole text is treated as a single chapter. -- Each chapter is split into sentence chunks (reusing ``app._split_text_into_chunks``) - so every call stays well under the engine's token limit. -- Chunks are synthesized with the SAME seed for a consistent voice, then stitched - per chapter with a short silence. -- **Memory-safe:** only one chapter is held in memory at a time, never the whole book. -- **Resumable:** a chapter whose output ``.wav`` already exists is skipped, so an - interrupted run continues where it left off. Use ``--force`` to regenerate. -- The denoiser is never loaded (narration uses no reference audio), so startup is - fast and does not touch ModelScope. +1. **Prepare** — the text goes through the French normalizer, so ``1789``, + ``M. Dupont``, ``XIVe siècle`` and ``14h30`` are read as a narrator would say + them (``--no-text-prep`` to disable, ``--lexicon`` for your own proper nouns). +2. **Segment** — each chapter is cut on sentence boundaries so every call stays + well under the engine's token limit, and each segment carries how long the + pause after it should be: longer after a paragraph than after a full stop. +3. **Synthesize** — with the SAME seed throughout, so the voice stays identical. + Every segment is cached by content, so an interrupted run resumes at the + segment it died on rather than restarting the chapter. +4. **Master** — segments are trimmed, de-clicked, stitched with their pauses and + normalised once per chapter to the audiobook loudness target. +5. **Assemble** (optional, ``--assemble``) — chapters are joined into a single + M4B/MP3 with chapter markers. + +**Memory-safe:** only one chapter is held in memory at a time, never the book. +**Resumable:** finished chapters are skipped, and within an unfinished chapter +every already-generated segment comes from the cache. +The denoiser is never loaded (narration uses no reference audio), so startup is +fast and does not touch ModelScope. Examples -------- - # Preview segmentation without generating anything (fast, no model load): + # Preview segmentation and prepared text without generating anything: ./.venv/Scripts/python.exe scripts/narrate_book.py livre.txt --voice "Narrateur profond & calme" --dry-run - # Narrate with a preset voice: - ./.venv/Scripts/python.exe scripts/narrate_book.py livre.txt --voice "Narrateur profond & calme" + # Narrate with a preset voice, then assemble an M4B: + ./.venv/Scripts/python.exe scripts/narrate_book.py livre.txt --voice "Narrateur profond & calme" --assemble m4b - # Narrate with a custom voice (description + seed): + # Custom voice (description + seed): ./.venv/Scripts/python.exe scripts/narrate_book.py livre.txt --description "Voix ..." --seed 123 # On a CUDA GPU (far faster): ./.venv/Scripts/python.exe scripts/narrate_book.py livre.txt --voice "..." --device cuda """ import argparse -import re import os import sys import tempfile @@ -48,14 +54,14 @@ sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) import app # noqa: E402 +from narration import assemble as assembly # noqa: E402 +from narration import audio as audio_tools # noqa: E402 +from narration import cache as cache_tools # noqa: E402 +from narration import chunking, text_fr # noqa: E402 - -def split_chapters(text: str, chapter_regex: str | None) -> list[str]: - """Split the book text into chapters. Defaults to Markdown '---' rules.""" - pattern = chapter_regex if chapter_regex else r"(?m)^\s*---\s*$" - parts = re.split(pattern, text) - chapters = [p.strip() for p in parts if p.strip()] - return chapters or [text.strip()] +#: Rough characters-per-second of finished narration, used only to estimate how +#: long a book will run before committing hours of CPU to it. +_CHARS_PER_SECOND = 14.0 def resolve_voice(args) -> tuple[str, int | None]: @@ -69,30 +75,73 @@ def resolve_voice(args) -> tuple[str, int | None]: return (args.description or ""), args.seed -def main() -> int: - parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) +def chapter_title(chapter: str, index: int) -> str: + """First non-empty line of a chapter, used as its marker title.""" + for line in chapter.splitlines(): + stripped = line.strip().lstrip("#").strip() + if stripped: + return stripped[:80] + return f"Chapitre {index}" + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter + ) parser.add_argument("input", help="Path to the .txt file to narrate") - parser.add_argument("--voice", help="Preset voice name (see conf/preset_voices.json)") - parser.add_argument("--description", help="Custom voice description (if not using --voice)") - parser.add_argument("--seed", type=int, help="Seed for the custom voice (fixes the voice identity)") - parser.add_argument("--outdir", help="Output directory (default: output/book_)") - parser.add_argument("--device", default="cpu", help="auto, cpu, mps, cuda, or cuda:N (default: cpu)") - parser.add_argument("--model-id", default="openbmb/VoxCPM2", help="Model path or HF repo id") - parser.add_argument("--chunk-max-chars", type=int, default=app._CHUNK_MAX_CHARS, - help=f"Max characters per chunk (default: {app._CHUNK_MAX_CHARS})") - parser.add_argument("--silence", type=float, default=app._CHUNK_SILENCE_SEC, - help=f"Silence between chunks in seconds (default: {app._CHUNK_SILENCE_SEC})") - parser.add_argument("--cfg", type=float, default=2.0, help="CFG guidance scale (default: 2.0)") - parser.add_argument("--steps", type=int, default=10, help="Diffusion steps (default: 10)") - parser.add_argument("--no-normalize", action="store_true", help="Disable text normalization") - parser.add_argument("--chapter-regex", help="Regex (MULTILINE) that separates chapters (default: '^---$')") - parser.add_argument("--force", action="store_true", help="Regenerate chapters even if their .wav exists") - parser.add_argument("--dry-run", action="store_true", help="Show the segmentation plan, generate nothing") - parser.add_argument("--continuity", action="store_true", - help="EXPERIMENTAL: chain each chunk from the previous one (prompt-cache " - "continuation) for smoother joins, instead of same-seed only. Slower; " - "resets at each chapter boundary. Tune on a GPU (slow to iterate on CPU).") - args = parser.parse_args() + + voice = parser.add_argument_group("voix") + voice.add_argument("--voice", help="Preset voice name (see conf/preset_voices.json)") + voice.add_argument("--description", help="Custom voice description (if not using --voice)") + voice.add_argument("--seed", type=int, help="Seed for the custom voice (fixes the voice identity)") + voice.add_argument("--cfg", type=float, default=2.0, help="CFG guidance scale (default: 2.0)") + voice.add_argument("--steps", type=int, default=10, help="Diffusion steps (default: 10)") + + text = parser.add_argument_group("texte") + text.add_argument("--no-text-prep", action="store_true", + help="Skip French normalization (numbers, abbreviations, Roman numerals)") + text.add_argument("--lexicon", default="conf/pronunciation_fr.json", + help="Pronunciation lexicon JSON (default: conf/pronunciation_fr.json)") + text.add_argument("--no-normalize", action="store_true", help="Disable the engine's own text normalization") + text.add_argument("--chapter-regex", help="Regex (MULTILINE) that separates chapters (default: '^---$')") + text.add_argument("--chunk-max-chars", type=int, default=chunking.DEFAULT_MAX_CHARS, + help=f"Max characters per segment (default: {chunking.DEFAULT_MAX_CHARS})") + + pauses = parser.add_argument_group("pauses et mastering") + defaults = chunking.PauseProfile() + pauses.add_argument("--pause-clause", type=float, default=defaults.clause, + help=f"Pause after a mid-sentence split (default: {defaults.clause}s)") + pauses.add_argument("--pause-sentence", type=float, default=defaults.sentence, + help=f"Pause after a sentence (default: {defaults.sentence}s)") + pauses.add_argument("--pause-paragraph", type=float, default=defaults.paragraph, + help=f"Pause after a paragraph (default: {defaults.paragraph}s)") + pauses.add_argument("--silence", type=float, + help="Force one uniform pause everywhere, overriding the three above") + pauses.add_argument("--target-rms", type=float, default=audio_tools.MasteringSettings().target_rms_db, + help="Loudness target in dBFS (ACX window is -23..-18, default: -20)") + pauses.add_argument("--no-master", action="store_true", + help="Skip trimming, de-clicking and loudness normalization") + + run = parser.add_argument_group("exécution") + run.add_argument("--outdir", help="Output directory (default: output/book_)") + run.add_argument("--device", default="cpu", help="auto, cpu, mps, cuda, or cuda:N (default: cpu)") + run.add_argument("--model-id", default="openbmb/VoxCPM2", help="Model path or HF repo id") + run.add_argument("--force", action="store_true", help="Regenerate chapters even if their .wav exists") + run.add_argument("--no-cache", action="store_true", help="Do not cache or reuse generated segments") + run.add_argument("--dry-run", action="store_true", help="Show the plan, generate nothing") + run.add_argument("--assemble", nargs="?", const="m4b", choices=["m4b", "m4a", "mp3", "wav"], + help="Assemble the chapters into one chaptered file when done") + run.add_argument("--title", default="", help="Book title used for the assembled file") + run.add_argument("--author", default="", help="Author / narrator used for the assembled file") + run.add_argument("--continuity", action="store_true", + help="EXPERIMENTAL: chain each segment from the previous one (prompt-cache " + "continuation) for smoother joins, instead of same-seed only. Slower; " + "resets at each chapter boundary. Tune on a GPU (slow to iterate on CPU).") + return parser + + +def main() -> int: + args = build_parser().parse_args() if not args.voice and not args.description: raise SystemExit("Provide either --voice or --description [--seed N].") @@ -100,93 +149,185 @@ def main() -> int: in_path = Path(args.input) if not in_path.is_file(): raise SystemExit(f"Input file not found: {in_path}") - text = in_path.read_text(encoding="utf-8").strip() - if not text: + raw_text = in_path.read_text(encoding="utf-8").strip() + if not raw_text: raise SystemExit(f"Input file is empty: {in_path}") description, seed = resolve_voice(args) - chapters = split_chapters(text, args.chapter_regex) outdir = Path(args.outdir) if args.outdir else app._OUTPUT_DIR / f"book_{app._sanitize_filename(in_path.stem)}" - # Plan: chunk every chapter up front so --dry-run can show the full picture. - plan = [(i, ch, app._split_text_into_chunks(ch, args.chunk_max_chars)) for i, ch in enumerate(chapters, 1)] - total_chunks = sum(len(chunks) for _, _, chunks in plan) - total_chars = sum(len(ch) for ch in chapters) - print(f"Input : {in_path}") - print(f"Voice : {args.voice or '(custom)'} | seed={seed}") - print(f"Chapters : {len(chapters)} | chunks: {total_chunks} | chars: {total_chars}") - print(f"Output dir : {outdir}") - for i, _, chunks in plan: - print(f" chapter {i:03d}: {len(chunks)} chunk(s)") + # ---- prepare ------------------------------------------------------- + raw_chapters = chunking.split_chapters(raw_text, args.chapter_regex) + titles = [chapter_title(chapter, i) for i, chapter in enumerate(raw_chapters, 1)] + + lexicon = {} + if not args.no_text_prep: + lexicon = text_fr.load_lexicon(args.lexicon) + chapters = [text_fr.normalize_french(chapter, lexicon=lexicon) for chapter in raw_chapters] + else: + chapters = raw_chapters + + if args.silence is not None: + profile = chunking.PauseProfile(clause=args.silence, sentence=args.silence, paragraph=args.silence) + else: + profile = chunking.PauseProfile( + clause=args.pause_clause, sentence=args.pause_sentence, paragraph=args.pause_paragraph + ) + + plan = [ + (index, chunking.split_into_segments(chapter, args.chunk_max_chars, profile)) + for index, chapter in enumerate(chapters, 1) + ] + total_segments = sum(len(segments) for _, segments in plan) + total_chars = sum(chunking.total_characters(segments) for _, segments in plan) + + print(f"Entrée : {in_path}") + print(f"Voix : {args.voice or '(personnalisée)'} | seed={seed}") + print(f"Préparation : {'désactivée' if args.no_text_prep else f'française ({len(lexicon)} entrée(s) de lexique)'}") + print(f"Chapitres : {len(chapters)} | segments : {total_segments} | caractères : {total_chars}") + print(f"Durée estimée : ~{total_chars / _CHARS_PER_SECOND / 60:.0f} min de narration") + print(f"Sortie : {outdir}") + for index, segments in plan: + print(f" chapitre {index:03d}: {len(segments)} segment(s) « {titles[index - 1][:50]} »") if args.dry_run: - print("\nDry run — nothing generated.") + if plan and plan[0][1]: + print("\nPremier segment après préparation du texte :") + print(f" « {plan[0][1][0].text} »") + print("\nDry run — rien n'a été généré.") return 0 + # ---- synthesize ---------------------------------------------------- outdir.mkdir(parents=True, exist_ok=True) - demo = app.VoxCPMDemo(model_id=args.model_id, device=args.device, load_denoiser=False) - normalize = not args.no_normalize + (outdir / "titles.txt").write_text("\n".join(titles) + "\n", encoding="utf-8") - started = time.strftime("%H:%M:%S") - print(f"\nStarting narration at {started} (device={args.device}). This is slow on CPU.\n", flush=True) + voice_spec = cache_tools.VoiceSpec( + description=description, + seed=seed, + cfg=args.cfg, + steps=args.steps, + normalize=not args.no_normalize, + model_id=args.model_id, + ) + cache = cache_tools.ChunkCache(outdir / ".cache", enabled=not args.no_cache) + mastering = audio_tools.MasteringSettings(target_rms_db=args.target_rms) - for i, _, chunks in plan: - out = outdir / f"chapitre_{i:03d}.wav" + demo = app.VoxCPMDemo(model_id=args.model_id, device=args.device, load_denoiser=False) + print(f"\nDébut de la narration à {time.strftime('%H:%M:%S')} (device={args.device}). " + f"C'est lent sur CPU.\n", flush=True) + + for index, segments in plan: + out = outdir / f"chapitre_{index:03d}.wav" if out.is_file() and not args.force: - print(f"[chapter {i:03d}/{len(plan)}] exists, skipping -> {out.name}", flush=True) + print(f"[chapitre {index:03d}/{len(plan)}] déjà présent, ignoré -> {out.name}", flush=True) continue - print(f"[chapter {i:03d}/{len(plan)}] {len(chunks)} chunk(s) ...", flush=True) - parts: list[np.ndarray] = [] - sr = None - # Continuity: chain each chunk from the immediately previous one only - # (bounded window → never overflows the KV cache). Reset per chapter. - prev_wav_path: str | None = None - prev_text: str | None = None - tmp_paths: list[str] = [] + + print(f"[chapitre {index:03d}/{len(plan)}] {len(segments)} segment(s) …", flush=True) + rendered: list[tuple[np.ndarray, float]] = [] + sample_rate = None + # Continuity chains each segment to the immediately previous one only + # (bounded window, so the KV cache never overflows). Reset per chapter. + previous_wav_path: str | None = None + previous_text: str | None = None + previous_key: str | None = None + temporaries: list[str] = [] try: - for j, chunk in enumerate(chunks): - if args.continuity and prev_wav_path is not None: - # Voice comes from the running audio, so drop the control text. - sr, wav, _ = demo.generate_tts_audio( - text_input=chunk, + for position, segment in enumerate(segments): + key = cache.key(segment.text, voice_spec, parent=previous_key if args.continuity else None) + cached = cache.get(key) + if cached is not None: + sample_rate, wav = cached + status = "cache" + elif args.continuity and previous_wav_path is not None: + # The voice now comes from the running audio, so the control + # text is dropped. + sample_rate, wav, _ = demo.generate_tts_audio( + text_input=segment.text, control_instruction="", - reference_wav_path_input=prev_wav_path, - prompt_text=prev_text, + reference_wav_path_input=previous_wav_path, + prompt_text=previous_text, cfg_value_input=args.cfg, - do_normalize=normalize, + do_normalize=not args.no_normalize, inference_timesteps=args.steps, seed=seed, ) + cache.put(key, sample_rate, wav, text=segment.text) + status = "généré" else: - sr, wav, _ = demo.generate_tts_audio( - text_input=chunk, + sample_rate, wav, _ = demo.generate_tts_audio( + text_input=segment.text, control_instruction=description, cfg_value_input=args.cfg, - do_normalize=normalize, + do_normalize=not args.no_normalize, inference_timesteps=args.steps, seed=seed, ) - if j > 0: - parts.append(np.zeros(int(sr * args.silence), dtype=wav.dtype)) - parts.append(wav) - if args.continuity: # stash this chunk as the prompt for the next one - with tempfile.NamedTemporaryFile(delete=False, suffix=".wav") as tmp: - tmp_paths.append(tmp.name) - sf.write(tmp_paths[-1], wav, sr) - prev_wav_path, prev_text = tmp_paths[-1], chunk - print(f" chunk {j + 1}/{len(chunks)} done", flush=True) + cache.put(key, sample_rate, wav, text=segment.text) + status = "généré" + + rendered.append((wav, segment.pause_after)) + previous_key = key + if args.continuity: # stash this segment as the prompt for the next + with tempfile.NamedTemporaryFile(delete=False, suffix=".wav") as handle: + temporaries.append(handle.name) + sf.write(temporaries[-1], wav, sample_rate) + previous_wav_path, previous_text = temporaries[-1], segment.text + print(f" segment {position + 1}/{len(segments)} {status}", flush=True) finally: - for p in tmp_paths: + for path in temporaries: try: - os.unlink(p) + os.unlink(path) except OSError: pass - book = np.concatenate(parts) - sf.write(str(out), book, sr) - print(f"[chapter {i:03d}/{len(plan)}] saved -> {out.name} ({len(book) / sr:.1f}s)", flush=True) - print(f"\nDone. Chapter files are in: {outdir}", flush=True) - print("Tip: concatenate them into one file with your audio tool, e.g. ffmpeg concat.", flush=True) + if not rendered or sample_rate is None: + print(f"[chapitre {index:03d}/{len(plan)}] vide, ignoré", flush=True) + continue + + if args.no_master: + chapter_audio = audio_tools.concatenate( + (wav for wav, _ in rendered), sample_rate, gap_sec=profile.sentence + ) + else: + chapter_audio = audio_tools.stitch(rendered, sample_rate, mastering) + + sf.write(str(out), chapter_audio, sample_rate, subtype="PCM_16") + report = audio_tools.acx_report(chapter_audio, sample_rate) + print( + f"[chapitre {index:03d}/{len(plan)}] écrit -> {out.name} " + f"({report['duration_sec'] / 60:.1f} min, RMS {report['rms_db']:.1f} dBFS, " + f"crête {report['peak_db']:.1f} dBFS)", + flush=True, + ) + + print(f"\nTerminé. Chapitres dans : {outdir}") + print(cache.stats.describe()) + + # ---- assemble ------------------------------------------------------ + if args.assemble: + chapter_files = sorted(p for p in outdir.glob("chapitre_*.wav")) + if not chapter_files: + print("Rien à assembler.") + return 0 + target = outdir / f"{outdir.name}_complet.{args.assemble}" + print(f"\nAssemblage de {len(chapter_files)} chapitre(s) -> {target.name}") + result = assembly.assemble( + chapter_files, + target, + title=args.title or in_path.stem, + author=args.author, + titles=titles, + ) + print(f"Durée totale : {result.duration_sec / 60:.1f} min") + print(result.message) + if result.pending_command: + import subprocess + + print("À exécuter une fois ffmpeg installé :") + print(" " + subprocess.list2cmdline(result.pending_command)) + else: + print("Astuce : ajoutez --assemble m4b pour produire un fichier unique avec chapitres, " + "ou lancez scripts/assemble_audiobook.py plus tard.") return 0 From a9bfd80e2398ee610c9a5fe1f527acaf8bfe2e79 Mon Sep 17 00:00:00 2001 From: PaxHelios Date: Tue, 28 Jul 2026 21:58:58 +0200 Subject: [PATCH 12/98] docs: document the audiobook production chain NARRATION.md gains the three entry points (tab, narrate_book, assemble_audiobook), what text preparation fixes and where it stops, the custom pronunciation lexicon, loudness targets, resuming after an interruption, and per-use-case settings. GUIDE_FR.md gets a short "Livres audio" section pointing at it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01UqWxj2j9bdavcLn25ckX8X --- docs/GUIDE_FR.md | 26 +++++++ docs/NARRATION.md | 169 ++++++++++++++++++++++++++++++++++++++-------- 2 files changed, 165 insertions(+), 30 deletions(-) diff --git a/docs/GUIDE_FR.md b/docs/GUIDE_FR.md index 3f2bc593..95c5604c 100644 --- a/docs/GUIDE_FR.md +++ b/docs/GUIDE_FR.md @@ -10,6 +10,7 @@ les différentes façons d'utiliser l'application. - [Installation](#installation) - [Interface web (recommandé)](#interface-web-recommandé) - [Les trois modes de génération](#les-trois-modes-de-génération) +- [Livres audio](#livres-audio) - [API REST](#api-rest) - [Ligne de commande (CLI)](#ligne-de-commande-cli) - [API Python](#api-python) @@ -91,6 +92,31 @@ et le débit. Fonctionne en français, anglais, chinois… Exemples : - *« Voix masculine grave et posée, ton de narrateur de documentaire »* - *« Jeune femme enjouée, débit rapide, ton enthousiaste »* +## Livres audio + +Ce fork ajoute une chaîne de production complète pour la narration longue en +français : préparation du texte (nombres, abréviations, chiffres romains lus +correctement), découpage avec pauses selon la ponctuation, mastering aux normes +des plateformes de livres audio, reprise après interruption au segment près, et +assemblage en M4B/MP3 avec marqueurs de chapitres. + +Trois points d'entrée : + +```bash +# Onglet « 📚 Livre audio » de la démo Gradio +python app.py --port 8808 --no-denoiser + +# Narrer un livre entier en ligne de commande +python scripts/narrate_book.py livre.txt --voice "Narrateur profond & calme" --assemble m4b + +# Assembler des chapitres déjà générés +python scripts/assemble_audiobook.py output/book_mon_livre --title "Mon Livre" --check +``` + +**→ Le guide détaillé est dans [docs/NARRATION.md](NARRATION.md)** : vitesse selon le +matériel, réglages par usage (fiction, documentaire, méditation, podcast), lexique de +prononciation personnalisé, et normes de sonie. + ## API REST Le serveur (`python server.py`) expose : diff --git a/docs/NARRATION.md b/docs/NARRATION.md index 30aa1b51..f1b06cb6 100644 --- a/docs/NARRATION.md +++ b/docs/NARRATION.md @@ -9,13 +9,29 @@ podcast, etc. **Oui, c'est fait pour ça** — mais deux réalités comptent : 1. **La vitesse dépend du matériel.** Sur **GPU CUDA**, c'est rapide et pratique. Sur - **CPU seul**, c'est ~50× plus lent que le temps réel : utilisable pour des extraits - courts, impraticable pour un livre entier. + **CPU seul**, c'est ~40× plus lent que le temps réel : utilisable pour des extraits + courts, très long pour un livre entier. 2. **Le découpage est automatique et obligatoire.** Le moteur ne peut pas traiter plus de ~8 192 tokens d'un coup — au-delà il s'arrête sur une erreur « KV cache is full » (voir `src/voxcpm/model/voxcpm2.py`). L'app et le script découpent le texte en phrases pour rester bien en-dessous de cette limite, sans que tu aies à t'en soucier. +## La chaîne de production + +Le texte brut ne devient pas un livre audio en une étape. Cinq étapes s'enchaînent, +chacune dans un module de `narration/` — testable et utilisable indépendamment : + +| Étape | Module | Ce qu'elle fait | +|---|---|---| +| **1. Préparation** | `narration/text_fr.py` | Réécrit le texte tel qu'un narrateur le dirait : `1789` → « mille sept cent quatre-vingt-neuf », `M. Dupont` → « Monsieur Dupont », `XIVe siècle` → « quatorzième siècle », `14h30`, `1 250 €`, `3,5 %`… | +| **2. Découpage** | `narration/chunking.py` | Coupe en segments sous la limite du moteur, **sans jamais couper une phrase**, et décide la durée du silence après chaque segment selon la ponctuation | +| **3. Synthèse** | moteur VoxCPM2 | Même seed partout → voix identique du début à la fin | +| **4. Mastering** | `narration/audio.py` | Rogne les silences parasites, supprime les clics aux jointures, insère les pauses, normalise la sonie **une fois par chapitre** | +| **5. Assemblage** | `narration/assemble.py` | Réunit les chapitres en un seul M4B/MP3 avec marqueurs de chapitres | + +Entre les étapes 2 et 3, un **cache par segment** (`narration/cache.py`) rend la +narration reprenable : voir plus bas. + ## Vitesse : à quoi s'attendre | Matériel | Vitesse (RTF) | 10 min d'audio | Livre de 3 h | @@ -45,24 +61,24 @@ temps) au prix d'un peu plus de RAM. Pour revenir à l'ancien comportement bfloat16 : `set VOXCPM_CPU_DTYPE=bfloat16` avant de lancer l'app (ou export sous bash). -## Deux façons de narrer +## Trois façons de narrer -### 1. Interface web — pour des extraits / chapitre par chapitre +### 1. Onglet « 📚 Livre audio » — pour un livre depuis l'interface -Idéale pour tester des voix, générer une méditation, un segment de podcast, ou un -chapitre à la fois. +1. Choisis d'abord une voix dans l'onglet **🎙️ Studio** (la description et le seed + de cette voix sont ceux qui seront utilisés). +2. Passe sur l'onglet **📚 Livre audio**, charge ton `.txt` ou colle le texte. +3. Clique **« 🔍 Analyser sans générer »** : tu vois le nombre de chapitres, de + segments, la durée estimée, et **le premier segment tel qu'il sera réellement lu** + (après préparation du texte). C'est le moment de repérer un nombre ou une + abréviation mal interprétés — avant d'engager des heures de calcul. +4. Clique **« 📖 Narrer le livre »**. Chaque chapitre terminé est écrit sur disque + et devient écoutable immédiatement ; l'avancement s'affiche au fur et à mesure. +5. Clique **« 📦 Assembler le livre audio »** pour obtenir un fichier unique. -- Charge ton texte avec **« 📄 Charger un fichier .txt »** (ou colle-le). -- Choisis une **voix prédéfinie** (le seed et le style se règlent automatiquement). -- Laisse **« Découper les longs textes »** activé (règle la taille de segment avec le - curseur si besoin, 100–600 caractères). -- Clique **« Générer la voix »**. Chaque génération est archivée dans `output/` sous un - nom explicite (`narration__seed_.wav`). +### 2. Script `narrate_book.py` — pour un livre entier en ligne de commande -### 2. Script `narrate_book.py` — pour un livre / long script entier - -Robuste pour les longs contenus : **sauvegarde par chapitre**, **reprise après -interruption**, **économe en mémoire** (un seul chapitre en RAM à la fois). +Le plus robuste pour les longs contenus. Sépare les chapitres de ton `.txt` par une ligne contenant seulement `---` : @@ -76,21 +92,114 @@ Chapitre deuxième. ... Puis : ``` -# Aperçu du découpage, sans rien générer : +# Aperçu : découpage, durée estimée, et texte préparé — sans rien générer : .\.venv\Scripts\python.exe scripts\narrate_book.py livre.txt --voice "Narrateur profond & calme" --dry-run # Génération (une .wav par chapitre dans output/book_/) : .\.venv\Scripts\python.exe scripts\narrate_book.py livre.txt --voice "Narrateur profond & calme" +# Génération + assemblage direct en M4B avec chapitres : +.\.venv\Scripts\python.exe scripts\narrate_book.py livre.txt --voice "Narrateur profond & calme" ^ + --assemble m4b --title "Mon Livre" --author "Edwin" + # Sur GPU : .\.venv\Scripts\python.exe scripts\narrate_book.py livre.txt --voice "..." --device cuda ``` -- **Reprise** : si le script est interrompu, relance la même commande — les chapitres - déjà produits sont ignorés (utilise `--force` pour tout régénérer). -- Options utiles : `--chunk-max-chars`, `--silence`, `--cfg`, `--steps`, `--no-normalize`, - `--chapter-regex` (séparateur de chapitres personnalisé), `--description` + `--seed` - (voix personnalisée au lieu d'un preset). +### 3. Onglet « 🎙️ Studio » — pour des extraits + +Idéal pour tester des voix, générer une méditation ou un segment de podcast. +Deux options utiles dans les **Réglages avancés** : + +- **Préparation du texte français** — applique l'étape 1 de la chaîne. +- **Mastering livre audio** — applique l'étape 4 (activé par défaut). + +## Reprise après interruption + +C'est le point critique sur CPU, où un chapitre prend des heures. + +- **Par chapitre** : un chapitre dont le `.wav` existe déjà est ignoré. `--force` le + régénère. +- **Par segment** : chaque segment généré est mis en cache dans + `output/book_/.cache/`, indexé par le **contenu** (texte + description + seed + + CFG + étapes + modèle). Si tu relances après une interruption, seuls les segments + manquants sont calculés — pas tout le chapitre. + +Conséquences pratiques : + +- Corriger une coquille dans un paragraphe n'invalide que les segments de ce + paragraphe. Le reste du livre est réutilisé tel quel. +- Changer de voix (ou de seed) invalide tout, ce qui est correct : c'est un autre + narrateur. +- Le cache occupe de la place. `--no-cache` le désactive ; supprimer le dossier + `.cache/` est sans risque une fois le livre terminé. + +## Qualité audio : la norme ACX + +Les plateformes de livres audio vérifient trois choses. Le mastering vise ces valeurs, +et chaque chapitre est mesuré à l'écriture : + +| Mesure | Cible | Pourquoi | +|---|---|---| +| RMS (sonie) | entre **-23 et -18 dBFS** (défaut : -20) | volume homogène d'un chapitre à l'autre | +| Crête | **≤ -3 dBFS** | marge avant saturation | +| Bruit de fond | **≤ -60 dBFS** | silences réellement silencieux | + +Le RMS est mesuré **sur la parole seule** : les silences entre phrases sont exclus du +calcul. Sans cela, un chapitre aux pauses généreuses mesurerait plusieurs dB trop bas, +et le corriger pousserait la parole au-dessus du plafond de crête. + +Vérifier un livre assemblé : +``` +.\.venv\Scripts\python.exe scripts\assemble_audiobook.py output\book_mon_livre --check +``` + +## Assemblage en un fichier unique + +``` +# M4B avec marqueurs de chapitres (nécessite ffmpeg) : +.\.venv\Scripts\python.exe scripts\assemble_audiobook.py output\book_mon_livre ^ + --title "Mon Livre" --author "Edwin" --format m4b +``` + +**ffmpeg n'est pas installé sur cette machine.** Ce n'est pas bloquant : le script +produit quand même le WAV complet et le fichier de marqueurs, puis affiche la commande +exacte à lancer une fois ffmpeg installé. Les heures de synthèse ne sont jamais perdues +à cause d'un encodeur manquant. + +Les titres de chapitres viennent, dans l'ordre : de `--titles`, puis d'un fichier +`titles.txt` à côté des WAV (écrit automatiquement par `narrate_book.py` à partir de la +première ligne de chaque chapitre), puis des noms de fichiers. + +## Prononciation : lexique personnalisé + +`conf/pronunciation_fr.json` associe ce qui est écrit à ce qui doit être prononcé. +C'est l'outil pour les noms propres d'un roman, les sigles et les mots étrangers : + +```json +{ + "SNCF": "S N C F", + "Nietzsche": "Nitche", + "Aurélien Krähenbühl": "Aurélien Krènebul" +} +``` + +Le remplacement est insensible à la casse et ne s'applique qu'à des mots entiers. +Les clés commençant par `_` sont des commentaires. + +## Ce que la préparation du texte corrige (et ses limites) + +Sont gérés : nombres cardinaux et ordinaux (`1er`, `2e`, `1re`), décimales, sommes en +euros/dollars/livres, pourcentages, heures (`14h30`), abréviations (`M.`, `Mme`, `Dr`, +`Me`, `St`, `etc.`, `av. J.-C.`, `n°`, `p. 42`), chiffres romains, tirets de dialogue, +guillemets, et le balisage Markdown. + +Les chiffres romains ne sont développés que dans des contextes **non ambigus** : +après un mot déclencheur (`chapitre XIV`, `tome III`), en forme ordinale (`XIXe`), ou +seuls sur une ligne de titre. C'est délibéré : « Le » est L + e, « Ce » est C + e — les +développer partout ferait lire « Le manuscrit » comme « cinquantième manuscrit ». + +Désactiver globalement : `--no-text-prep` (script) ou décocher la case (interface). ## Réglages recommandés par usage @@ -98,8 +207,8 @@ Puis : |---|---|---| | **Livre audio (fiction)** | *Narrateur profond & calme* / *Narratrice douce & naturelle* | défauts (CFG 2.0, 10 étapes) | | **Documentaire / non-fiction** | *Narrateur documentaire velouté* / *Narrateur moderne & professionnel* | défauts | -| **Méditation guidée** | *Méditation guidée (grave & lente)* | augmente `--silence` (ex. 0.6–1.0 s) pour de longues pauses | -| **Podcast** | *Conteur jeune & dynamique* / *Narratrice chaleureuse & conversationnelle* | défauts | +| **Méditation guidée** | *Méditation guidée (grave & lente)* | `--pause-sentence 0.8 --pause-paragraph 1.6` | +| **Podcast** | *Conteur jeune & dynamique* / *Narratrice chaleureuse & conversationnelle* | `--pause-paragraph 0.6` (rythme plus soutenu) | ## Cohérence de la voix sur un long texte @@ -107,15 +216,15 @@ La voix reste identique d'un segment à l'autre parce que **le même seed est r pour tous les segments** (une paire description + seed régénère exactement la même voix). C'est ce qui garantit un narrateur constant sur tout un livre. -> Note : les jointures entre segments sont de simples silences. Pour des transitions -> encore plus fluides (prosodie enchaînée via *prompt-cache*), une option expérimentale -> serait possible — demande-la si tu en as besoin. +L'option expérimentale `--continuity` va plus loin : chaque segment est enchaîné à +partir du précédent (continuation par *prompt-cache*) pour des jointures encore plus +fluides. Le mécanisme fonctionne mais il est **beaucoup plus lent** — à régler sur GPU. ## Limites à connaître - **Longueur par appel** : ~8 192 tokens max (découpage automatique, donc transparent). - **Durée par segment** : le moteur vise ~6× la longueur du texte et s'arrête tout seul ; garde des segments de taille raisonnable (défaut 300 caractères). -- **Sortie** : WAV 48 kHz. Un livre entier concaténé en un seul fichier serait très - lourd en mémoire — c'est pourquoi le script écrit **un fichier par chapitre**. Assemble-les - ensuite avec ton outil audio (ex. `ffmpeg` concat) si tu veux un seul fichier. +- **Sortie** : les chapitres sont écrits en WAV 16 bits, un fichier par chapitre. Un + livre entier n'est jamais chargé en mémoire — ni à la génération, ni à l'assemblage + (qui écrit en flux). From 622732099223e0f3f48c2dcb0c4c9d5f8286051d Mon Sep 17 00:00:00 2001 From: PaxHelios Date: Tue, 28 Jul 2026 23:31:41 +0200 Subject: [PATCH 13/98] feat(narration): detect the segments the engine got wrong MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A neural TTS engine fails occasionally and locally: one segment in a few dozen comes back cut off mid-word, silent, or babbling past the end of its text. On a GPU that hardly matters — regenerate the chapter. On a CPU-only machine a chapter is hours, so the only affordable repair is at the level of the bad segment, which first requires finding it. Listening to four hours of narration for eleven seconds of defect is not a method. Every check reads the waveform against the text that produced it. That pairing is what makes them possible: audio alone cannot say whether a two-second segment is complete, but two seconds for two hundred characters is a truncation. Detected are silent, truncated, runaway and clipped takes (fatal), plus internal gaps, missing decay at the end and a repeating level envelope (suspect). render_checked() re-rolls a fatally defective take with a seed derived from the original, so a repaired book stays reproducible, and keeps the best attempt rather than the last — a second roll can be worse than the first, and silently keeping the worse one would make the pass harmful. The speech-rate bounds come from measurement, not from the nominal figure: the seven preset voices span 15.8 to 24.1 characters per second on the same sentence, so the limits sit well outside that range. Checked against the real preset previews — no false positives — and a test pins those measured rates so the thresholds cannot drift back into them. audio.frame_rms_db() is exposed for this: the shape of the level curve over time is what separates a dropped sentence from a clean read. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01UqWxj2j9bdavcLn25ckX8X --- narration/__init__.py | 3 +- narration/audio.py | 19 ++ narration/quality.py | 524 ++++++++++++++++++++++++++++++++ tests/test_narration_quality.py | 363 ++++++++++++++++++++++ 4 files changed, 908 insertions(+), 1 deletion(-) create mode 100644 narration/quality.py create mode 100644 tests/test_narration_quality.py diff --git a/narration/__init__.py b/narration/__init__.py index bf3f8dee..cfd85a91 100644 --- a/narration/__init__.py +++ b/narration/__init__.py @@ -11,8 +11,9 @@ text_fr prepare raw French prose for a TTS engine chunking cut prepared text into engine-sized segments + pause plan cache content-addressed store so an interrupted run resumes per chunk + quality flag the segments the engine got wrong, and re-roll those only audio trim, master and stitch the generated segments assemble join chapters into a single MP3/M4B with chapter markers """ -__all__ = ["assemble", "audio", "cache", "chunking", "text_fr"] +__all__ = ["assemble", "audio", "cache", "chunking", "quality", "text_fr"] diff --git a/narration/audio.py b/narration/audio.py index 63882854..5f57a506 100644 --- a/narration/audio.py +++ b/narration/audio.py @@ -31,6 +31,7 @@ "acx_report", "as_float_mono", "fade_edges", + "frame_rms_db", "master_segment", "noise_floor_db", "normalize_level", @@ -157,6 +158,24 @@ def speech_rms_db(wav: np.ndarray, sr: int, frame_ms: float = 400.0, hop_ms: flo return _to_db(float(np.sqrt(speech.mean()))) +def frame_rms_db( + wav: np.ndarray, sr: int, frame_ms: float = 50.0, hop_ms: float = 25.0 +) -> np.ndarray: + """Level of every frame in dBFS, as a 1-D array. + + The shape of this curve over time is what tells a dropped sentence or a + segment cut off mid-word apart from a clean one, so :mod:`narration.quality` + reads it rather than re-deriving the framing itself. + """ + wav = as_float_mono(wav) + if wav.size == 0: + return np.zeros(0, dtype=np.float32) + power = _frame_power(wav, sr, frame_ms, hop_ms) + if power.size == 0: + return np.zeros(0, dtype=np.float32) + return (10.0 * np.log10(np.maximum(power, _EPS))).astype(np.float32) + + def peak_db(wav: np.ndarray) -> float: """Sample peak in dBFS.""" wav = as_float_mono(wav) diff --git a/narration/quality.py b/narration/quality.py new file mode 100644 index 00000000..b0a280ed --- /dev/null +++ b/narration/quality.py @@ -0,0 +1,524 @@ +"""Catch the segments the engine got wrong, and re-roll only those. + +A neural TTS engine fails occasionally and locally: one segment in a few dozen +comes back cut off mid-word, silent, or babbling well past the end of its text. +On a GPU that hardly matters — regenerate the chapter. On a CPU-only machine a +chapter is hours, so the only affordable repair is at the level of the single +bad segment, which means the bad segment has to be *identified* automatically. +Listening to four hours of narration to find eleven seconds of it is not a +workflow. + +Everything here reads the waveform against the text that produced it. That +pairing is what makes the checks possible at all: audio alone cannot say whether +a two-second segment is complete, but two seconds for a sentence of two hundred +characters is a truncation, full stop. + +What is detected, and why each one is worth a check: + +``silent`` nothing came back — the failure that is trivially detectable and + catastrophic if shipped. +``truncated`` far less audio than the text implies; the engine stopped early. +``runaway`` far more; the engine looped or hallucinated past the text. +``clipped`` samples pinned at full scale, which no amount of later + mastering can undo. +``gap`` a long silence in the middle, the signature of a skipped clause. +``abrupt_end`` still at full speech level on the last sample — cut mid-word. +``looped`` the level envelope repeats, as it does when a phrase is spoken + twice. + +Deliberately torch-free, like the rest of the package: the checks run on a +finished waveform, so they are unit-testable against synthetic signals without +loading a model. +""" +from __future__ import annotations + +import hashlib +from dataclasses import dataclass, field +from typing import Callable, List, Optional, Sequence, Tuple + +import numpy as np + +from . import audio as audio_tools + +__all__ = [ + "FATAL", + "SUSPECT", + "Issue", + "QualityThresholds", + "RenderResult", + "SegmentReport", + "ends_abruptly", + "envelope_repetition", + "inspect_segment", + "longest_internal_silence_sec", + "render_checked", + "retry_seed", + "summarize", +] + +#: A defect bad enough that the segment should be generated again. +FATAL = "fatal" +#: Worth a human ear, not worth spending minutes of CPU on by itself. +SUSPECT = "suspect" + +_SEVERITY_RANK = {SUSPECT: 1, FATAL: 2} + + +@dataclass(frozen=True) +class QualityThresholds: + """Where each defect starts. + + The speech-rate limits are the load-bearing ones, and they are set from + measurement rather than from the nominal figure. The engine's own preset + voices, given the same 81-character sentence, come back between 15.8 and + 24.1 characters per second — a spread of more than 50% between the slowest + and the fastest voice. The bounds therefore sit well outside that range, so + that choosing a brisk voice is never mistaken for a defect; a truncation + that drops half of a sentence still doubles the rate and lands outside them. + """ + + #: Middle of the range measured across the preset voices. Explains a report, + #: and breaks ties between attempts; never judges one on its own. + expected_chars_per_second: float = 17.0 + #: Above this, the text cannot have been spoken in the audio returned. + truncated_chars_per_second: float = 35.0 + #: Below this, there is far more audio than the text can account for. + runaway_chars_per_second: float = 6.0 + #: Segments shorter than this are treated as a failed generation outright. + min_duration_sec: float = 0.2 + #: A segment whose peak sits below this carries no speech at all. + silence_peak_db: float = -50.0 + #: Longest silence tolerated *between* the first and last word. + max_internal_silence_sec: float = 1.5 + #: How far below the segment's own speech level counts as silence. + silence_relative_db: float = 30.0 + #: The last few ms should have decayed at least this far below speech level. + abrupt_end_margin_db: float = 20.0 + edge_window_ms: float = 60.0 + #: Fraction of samples at full scale that means clipping rather than a stray peak. + clipping_sample_ratio: float = 0.0005 + clipping_threshold: float = 0.999 + #: Envelope self-similarity above which a segment looks like a repeat. + loop_correlation: float = 0.92 + min_loop_lag_sec: float = 0.5 + + +@dataclass(frozen=True) +class Issue: + """One defect found in one segment.""" + + code: str + severity: str + detail: str + + def __str__(self) -> str: # pragma: no cover - trivial + return f"{self.code} ({self.severity}): {self.detail}" + + +@dataclass(frozen=True) +class SegmentReport: + """What the audio measures, and what is wrong with it.""" + + duration_sec: float + characters: int + chars_per_second: float + rms_db: float + peak_db: float + longest_silence_sec: float + issues: Tuple[Issue, ...] = () + #: Rate the segment was judged against, carried so :attr:`penalty` can rank + #: attempts without needing the thresholds that produced the report. + expected_chars_per_second: float = 17.0 + + @property + def ok(self) -> bool: + """True when nothing at all was flagged.""" + return not self.issues + + @property + def fatal(self) -> bool: + """True when the segment should be generated again.""" + return any(issue.severity == FATAL for issue in self.issues) + + @property + def severity(self) -> Optional[str]: + if not self.issues: + return None + return max((issue.severity for issue in self.issues), key=lambda s: _SEVERITY_RANK.get(s, 0)) + + @property + def codes(self) -> Tuple[str, ...]: + return tuple(issue.code for issue in self.issues) + + @property + def penalty(self) -> Tuple[int, int, float]: + """Sort key for choosing between attempts — lower is better. + + Fatal count first, then suspect count, then distance from the expected + speech rate. The last term only ever breaks ties between attempts that + are equally defective, and prefers the one whose length best matches its + text. + """ + fatal = sum(1 for issue in self.issues if issue.severity == FATAL) + suspect = len(self.issues) - fatal + rate_error = ( + abs(self.chars_per_second - self.expected_chars_per_second) + if self.chars_per_second + else 1e6 + ) + return (fatal, suspect, rate_error) + + def describe(self) -> str: + if self.ok: + return f"ok ({self.duration_sec:.1f}s, {self.chars_per_second:.0f} car/s)" + return f"{self.severity}: " + ", ".join(f"{i.code} — {i.detail}" for i in self.issues) + + +# -------------------------------------------------------------------------- +# Individual measurements +# -------------------------------------------------------------------------- + + +def longest_internal_silence_sec( + wav: np.ndarray, + sr: int, + *, + relative_db: float = 30.0, + frame_ms: float = 30.0, +) -> float: + """Longest silence *between* the first and last word, in seconds. + + Leading and trailing silence is excluded deliberately: every segment has + some, it is trimmed later anyway, and counting it would flag every segment + the engine padded generously. + """ + wav = audio_tools.as_float_mono(wav) + if wav.size == 0 or sr <= 0: + return 0.0 + + hop_ms = frame_ms / 2.0 + levels = audio_tools.frame_rms_db(wav, sr, frame_ms=frame_ms, hop_ms=hop_ms) + if levels.size == 0: + return 0.0 + + threshold = audio_tools.speech_rms_db(wav, sr) - relative_db + if not np.isfinite(threshold): + return 0.0 + + loud = np.flatnonzero(levels > threshold) + if loud.size < 2: + return 0.0 + + # Only the stretch that actually contains speech is examined. + inner = levels[loud[0] : loud[-1] + 1] <= threshold + if not inner.any(): + return 0.0 + + longest = current = 0 + for quiet in inner: + current = current + 1 if quiet else 0 + longest = max(longest, current) + return longest * hop_ms / 1000.0 + + +def ends_abruptly( + wav: np.ndarray, + sr: int, + *, + margin_db: float = 20.0, + window_ms: float = 60.0, +) -> bool: + """True when the segment never decays into silence before it stops. + + A finished phrase trails off; a truncated one stops while sound is still + coming out. The margin is generous on purpose — a segment may legitimately + end on a weak final syllable several dB down, but a properly terminated one + ends tens of dB down, in its own noise floor. + """ + wav = audio_tools.as_float_mono(wav) + if wav.size == 0 or sr <= 0: + return False + + tail = wav[-max(1, int(sr * window_ms / 1000.0)) :] + if tail.size == 0: + return False + + speech = audio_tools.speech_rms_db(wav, sr) + tail_level = 20.0 * float(np.log10(max(float(np.sqrt(np.mean(np.square(tail, dtype=np.float64)))), 1e-12))) + if not np.isfinite(speech): + return False + return tail_level > speech - margin_db + + +def envelope_repetition( + wav: np.ndarray, + sr: int, + *, + min_lag_sec: float = 0.5, + hop_ms: float = 25.0, + floor_db: float = 40.0, +) -> float: + """How strongly the level envelope repeats itself, in 0..1. + + A phrase spoken twice produces a level curve that matches itself when + shifted by the length of the phrase. Comparing the envelope with delayed + copies of itself surfaces that without any transcription. Normal prose does + not reach the default threshold — the rhythm of speech is not that regular. + + Two details decide whether this measures anything at all. The envelope is + restricted to the speech itself and floored ``floor_db`` below it, because + digital silence lands at -120 dBFS and a segment's own padding would + otherwise dominate the curve and drown the part being compared. And each lag + is scored as a correlation over its own overlap, not against the whole + signal, so a repeat is not penalised for how late it occurs. + """ + wav = audio_tools.as_float_mono(wav) + if wav.size == 0 or sr <= 0: + return 0.0 + + levels = audio_tools.frame_rms_db(wav, sr, frame_ms=hop_ms * 2.0, hop_ms=hop_ms) + speech_level = audio_tools.speech_rms_db(wav, sr) + if levels.size == 0 or not np.isfinite(speech_level): + return 0.0 + + floor = speech_level - floor_db + loud = np.flatnonzero(levels > floor) + if loud.size == 0: + return 0.0 + envelope = np.maximum(levels[loud[0] : loud[-1] + 1], floor).astype(np.float64) + + min_lag = int(round(min_lag_sec * 1000.0 / hop_ms)) + # Below twice the minimum lag there is no room for a repeat to show up. + if min_lag < 1 or envelope.size < 2 * min_lag: + return 0.0 + + best = 0.0 + for lag in range(min_lag, envelope.size // 2 + 1): + head = envelope[:-lag] + tail = envelope[lag:] + head = head - head.mean() + tail = tail - tail.mean() + denominator = float(np.sqrt(np.dot(head, head) * np.dot(tail, tail))) + if denominator <= 0.0: + continue + best = max(best, float(np.dot(head, tail)) / denominator) + return float(np.clip(best, 0.0, 1.0)) + + +def _clipped_ratio(wav: np.ndarray, threshold: float) -> float: + wav = audio_tools.as_float_mono(wav) + if wav.size == 0: + return 0.0 + return float(np.count_nonzero(np.abs(wav) >= threshold)) / float(wav.size) + + +# -------------------------------------------------------------------------- +# Verdict +# -------------------------------------------------------------------------- + + +def inspect_segment( + wav: np.ndarray, + sr: int, + text: str, + thresholds: QualityThresholds = QualityThresholds(), +) -> SegmentReport: + """Measure one generated segment against the text it was generated from.""" + wav = audio_tools.as_float_mono(wav) + duration = float(wav.size) / sr if sr > 0 else 0.0 + characters = len((text or "").strip()) + rate = characters / duration if duration > 0 else 0.0 + peak = audio_tools.peak_db(wav) + rms = audio_tools.speech_rms_db(wav, sr) if wav.size else -np.inf + + issues: List[Issue] = [] + + # -- is there audio at all -------------------------------------------- + if wav.size == 0 or duration < thresholds.min_duration_sec: + issues.append(Issue("silent", FATAL, f"durée {duration:.2f}s, quasi nulle")) + elif peak < thresholds.silence_peak_db: + issues.append(Issue("silent", FATAL, f"crête {peak:.1f} dBFS, aucun signal audible")) + + # -- does its length match its text ------------------------------------ + # Only meaningful once there is both text and audio; an empty segment has + # already been flagged above and would divide by zero here. + if characters and duration >= thresholds.min_duration_sec: + if rate > thresholds.truncated_chars_per_second: + expected = characters / thresholds.expected_chars_per_second + issues.append( + Issue( + "truncated", + FATAL, + f"{characters} caractères en {duration:.1f}s (~{expected:.1f}s attendues)", + ) + ) + elif rate < thresholds.runaway_chars_per_second: + expected = characters / thresholds.expected_chars_per_second + issues.append( + Issue( + "runaway", + FATAL, + f"{duration:.1f}s pour {characters} caractères (~{expected:.1f}s attendues)", + ) + ) + + # -- damage that mastering cannot repair ------------------------------- + clipped = _clipped_ratio(wav, thresholds.clipping_threshold) + if clipped > thresholds.clipping_sample_ratio: + issues.append(Issue("clipped", FATAL, f"{clipped * 100:.2f}% des échantillons saturés")) + + # -- shape of the delivery --------------------------------------------- + gap = longest_internal_silence_sec(wav, sr, relative_db=thresholds.silence_relative_db) + if gap > thresholds.max_internal_silence_sec: + issues.append(Issue("gap", SUSPECT, f"silence interne de {gap:.1f}s")) + + if wav.size and ends_abruptly( + wav, sr, margin_db=thresholds.abrupt_end_margin_db, window_ms=thresholds.edge_window_ms + ): + issues.append(Issue("abrupt_end", SUSPECT, "se termine au niveau de parole, coupé net")) + + repetition = envelope_repetition(wav, sr, min_lag_sec=thresholds.min_loop_lag_sec) + if repetition > thresholds.loop_correlation: + issues.append(Issue("looped", SUSPECT, f"enveloppe répétitive (corrélation {repetition:.2f})")) + + return SegmentReport( + duration_sec=duration, + characters=characters, + chars_per_second=rate, + rms_db=float(rms), + peak_db=float(peak), + longest_silence_sec=gap, + issues=tuple(issues), + expected_chars_per_second=thresholds.expected_chars_per_second, + ) + + +# -------------------------------------------------------------------------- +# Repair +# -------------------------------------------------------------------------- + + +def retry_seed(base_seed: Optional[int], attempt: int, text: str = "") -> Optional[int]: + """A different but reproducible seed for re-generating one segment. + + Derived rather than random so that a repaired book stays reproducible: the + same book re-run from scratch repairs the same segment with the same seed + and gets the same audio. ``None`` stays ``None`` — the engine is already + picking its own seed, so asking again is enough. + """ + if base_seed is None: + return None + digest = hashlib.sha256(f"{base_seed}:{attempt}:{text}".encode("utf-8")).digest() + return int.from_bytes(digest[:4], "big") % (2**32 - 1) + + +@dataclass +class RenderResult: + """The audio finally kept for a segment, and how it was arrived at.""" + + sample_rate: int + wav: np.ndarray + report: SegmentReport + attempts: int + seed: Optional[int] + #: Reports of every rejected attempt, oldest first. + rejected: List[SegmentReport] = field(default_factory=list) + + @property + def repaired(self) -> bool: + """True when a re-roll was needed and produced something acceptable.""" + return self.attempts > 1 and not self.report.fatal + + @property + def unrepairable(self) -> bool: + """True when every attempt came back defective.""" + return self.report.fatal + + +def render_checked( + text: str, + render: Callable[[Optional[int]], Tuple[int, np.ndarray]], + *, + base_seed: Optional[int] = None, + max_attempts: int = 2, + thresholds: QualityThresholds = QualityThresholds(), + on_attempt: Optional[Callable[[int, SegmentReport], None]] = None, +) -> RenderResult: + """Generate a segment, and re-roll it while it comes back fatally defective. + + ``render`` takes a seed and returns ``(sample_rate, audio)``; keeping the + engine behind that callable is what lets this be tested without a model. + + Only fatal defects trigger a re-roll — a suspect one costs minutes of CPU to + chase and is often the text's own doing. The best attempt is always + returned, never the last: a second roll can be worse than the first, and + silently keeping the worse one would make the repair pass harmful. + """ + attempts = max(1, int(max_attempts)) + best: Optional[RenderResult] = None + rejected: List[SegmentReport] = [] + last_error: Optional[Exception] = None + calls = 0 + + for attempt in range(attempts): + seed = base_seed if attempt == 0 else retry_seed(base_seed, attempt, text) + calls += 1 + try: + sample_rate, wav = render(seed) + except Exception as error: # noqa: BLE001 - retried below, re-raised if terminal + # A crash on one seed is itself a failure mode worth re-rolling: the + # engine occasionally dies on a specific seed/text pair. + last_error = error + continue + + wav = audio_tools.as_float_mono(wav) + report = inspect_segment(wav, sample_rate, text, thresholds) + if on_attempt is not None: + on_attempt(attempt, report) + + candidate = RenderResult( + sample_rate=sample_rate, wav=wav, report=report, attempts=calls, seed=seed + ) + if best is None or report.penalty < best.report.penalty: + if best is not None: + rejected.append(best.report) + best = candidate + else: + rejected.append(report) + + if not report.fatal: + break + + if best is None: + raise last_error if last_error is not None else RuntimeError( + "render produced nothing and raised nothing" + ) + + best.attempts = calls + best.rejected = rejected + return best + + +def summarize(reports: Sequence[Tuple[str, SegmentReport]]) -> dict: + """Aggregate per-segment reports into something worth printing at the end.""" + flagged = [(label, report) for label, report in reports if not report.ok] + counts: dict = {} + for _, report in flagged: + for issue in report.issues: + counts[issue.code] = counts.get(issue.code, 0) + 1 + return { + "segments": len(reports), + "flagged": len(flagged), + "fatal": sum(1 for _, report in flagged if report.fatal), + "by_code": counts, + "details": [ + { + "segment": label, + "severity": report.severity, + "duration_sec": round(report.duration_sec, 2), + "chars_per_second": round(report.chars_per_second, 1), + "issues": [{"code": i.code, "severity": i.severity, "detail": i.detail} for i in report.issues], + } + for label, report in flagged + ], + } diff --git a/tests/test_narration_quality.py b/tests/test_narration_quality.py new file mode 100644 index 00000000..c62cc517 --- /dev/null +++ b/tests/test_narration_quality.py @@ -0,0 +1,363 @@ +"""Tests for narration.quality — defect detection and targeted re-rolls. + +Signals are synthesised rather than generated, so the whole suite runs in +milliseconds with no model: a defect is defined by the shape of the waveform +against its text, and that shape can be built by hand. +""" +from __future__ import annotations + +import numpy as np +import pytest + +from narration import quality + +SR = 24000 + + +def speech(seconds: float, level_db: float = -20.0, sr: int = SR, seed: int = 0) -> np.ndarray: + """A speech-like signal: a syllable-rate amplitude envelope over noise. + + Not speech, but it shares the two properties every check here reads — a + steady RMS and an irregular envelope — so it stands in for a clean segment. + """ + rng = np.random.default_rng(seed) + samples = int(sr * seconds) + if samples <= 0: + return np.zeros(0, dtype=np.float32) + # Syllable gains drawn at random rather than from a periodic function: a + # sine envelope would repeat on its own and make every segment look looped. + syllables = max(2, int(seconds * 6)) + gains = rng.uniform(0.3, 1.0, syllables + 1) + envelope = np.interp(np.linspace(0.0, syllables, samples), np.arange(syllables + 1), gains) + signal = rng.normal(0.0, 1.0, samples) * envelope + signal /= max(float(np.sqrt(np.mean(signal**2))), 1e-12) + return (signal * 10.0 ** (level_db / 20.0)).astype(np.float32) + + +def with_edges(body: np.ndarray, lead: float = 0.2, tail: float = 0.35, sr: int = SR) -> np.ndarray: + """Pad a segment with the silence a well-behaved generation leaves.""" + return np.concatenate( + [np.zeros(int(sr * lead), dtype=np.float32), body, np.zeros(int(sr * tail), dtype=np.float32)] + ) + + +def sentence(characters: int) -> str: + return "a" * characters + + +# -------------------------------------------------------------------------- +# A clean segment +# -------------------------------------------------------------------------- + + +def test_clean_segment_has_no_issues(): + # 140 characters at ~14 char/s is about 10 seconds of speech. + wav = with_edges(speech(10.0)) + report = quality.inspect_segment(wav, SR, sentence(140)) + assert report.ok, report.describe() + assert report.severity is None + assert not report.fatal + + +def test_clean_segment_reports_its_measurements(): + wav = with_edges(speech(10.0, level_db=-20.0)) + report = quality.inspect_segment(wav, SR, sentence(140)) + assert report.characters == 140 + assert report.duration_sec == pytest.approx(10.55, abs=0.1) + assert report.chars_per_second == pytest.approx(140 / 10.55, rel=0.05) + assert report.rms_db == pytest.approx(-20.0, abs=1.5) + + +def test_slow_and_fast_reading_are_both_accepted(): + # Well below and well above the nominal rate, both legitimate. + for seconds, characters in ((10.0, 100), (10.0, 300)): + report = quality.inspect_segment(with_edges(speech(seconds)), SR, sentence(characters)) + assert not report.fatal, f"{characters} chars / {seconds}s: {report.describe()}" + + +@pytest.mark.parametrize("rate", [15.8, 18.8, 21.1, 24.1]) +def test_rates_measured_from_the_preset_voices_are_never_flagged(rate): + """Guards the thresholds against drifting into the engine's real range. + + These are the rates the seven preset voices actually produced on the same + sentence. A change that makes any of them look defective would flag a large + share of a real book, so it is caught here rather than four hours in. + """ + seconds = 8.0 + report = quality.inspect_segment( + with_edges(speech(seconds)), SR, sentence(int(rate * (seconds + 0.55))) + ) + assert not report.fatal, f"{rate} char/s flagged: {report.describe()}" + + +# -------------------------------------------------------------------------- +# Fatal defects +# -------------------------------------------------------------------------- + + +def test_empty_audio_is_silent_and_fatal(): + report = quality.inspect_segment(np.zeros(0, dtype=np.float32), SR, sentence(100)) + assert "silent" in report.codes + assert report.fatal + + +def test_digital_silence_is_detected(): + report = quality.inspect_segment(np.zeros(SR * 5, dtype=np.float32), SR, sentence(70)) + assert "silent" in report.codes + assert report.fatal + + +def test_inaudible_segment_is_detected(): + report = quality.inspect_segment(with_edges(speech(5.0, level_db=-70.0)), SR, sentence(70)) + assert "silent" in report.codes + + +def test_truncation_is_fatal(): + # A long sentence that came back as one second of audio. + report = quality.inspect_segment(with_edges(speech(1.0)), SR, sentence(200)) + assert "truncated" in report.codes + assert report.fatal + + +def test_runaway_is_fatal(): + # Three characters cannot account for twenty seconds of audio. + report = quality.inspect_segment(with_edges(speech(20.0)), SR, sentence(3)) + assert "runaway" in report.codes + assert report.fatal + + +def test_clipping_is_fatal(): + wav = with_edges(speech(6.0, level_db=-6.0)) + wav[SR : SR + 2000] = 1.0 # a pinned stretch, not a stray sample + report = quality.inspect_segment(wav, SR, sentence(85)) + assert "clipped" in report.codes + assert report.fatal + + +def test_a_single_full_scale_sample_is_not_clipping(): + wav = with_edges(speech(6.0)) + wav[SR] = 1.0 + report = quality.inspect_segment(wav, SR, sentence(85)) + assert "clipped" not in report.codes + + +def test_empty_text_does_not_trigger_a_rate_defect(): + # Nothing to compare the duration against; dividing by zero characters + # must not manufacture a truncation. + report = quality.inspect_segment(with_edges(speech(3.0)), SR, "") + assert "truncated" not in report.codes + assert "runaway" not in report.codes + + +# -------------------------------------------------------------------------- +# Suspect defects +# -------------------------------------------------------------------------- + + +def test_internal_gap_is_flagged_as_suspect(): + body = np.concatenate([speech(3.0), np.zeros(int(SR * 2.5), dtype=np.float32), speech(3.0, seed=1)]) + report = quality.inspect_segment(with_edges(body), SR, sentence(120)) + assert "gap" in report.codes + assert not report.fatal # worth an ear, not worth an hour of CPU + assert report.longest_silence_sec == pytest.approx(2.5, abs=0.3) + + +def test_edge_silence_is_not_an_internal_gap(): + wav = with_edges(speech(8.0), lead=2.0, tail=3.0) + assert quality.longest_internal_silence_sec(wav, SR) < 0.5 + assert "gap" not in quality.inspect_segment(wav, SR, sentence(110)).codes + + +def test_normal_sentence_pauses_are_not_gaps(): + body = np.concatenate([speech(4.0), np.zeros(int(SR * 0.5), dtype=np.float32), speech(4.0, seed=2)]) + assert "gap" not in quality.inspect_segment(with_edges(body), SR, sentence(115)).codes + + +def test_abrupt_end_is_flagged(): + # No trailing silence at all: the waveform stops at speaking level. + wav = np.concatenate([np.zeros(int(SR * 0.2), dtype=np.float32), speech(8.0)]) + assert quality.ends_abruptly(wav, SR) + assert "abrupt_end" in quality.inspect_segment(wav, SR, sentence(110)).codes + + +def test_decayed_end_is_not_abrupt(): + assert not quality.ends_abruptly(with_edges(speech(8.0)), SR) + + +def test_repeated_phrase_is_flagged_as_looped(): + phrase = speech(2.0, seed=7) + wav = with_edges(np.concatenate([phrase, phrase, phrase, phrase])) + assert quality.envelope_repetition(wav, SR) > 0.9 + assert "looped" in quality.inspect_segment(wav, SR, sentence(115)).codes + + +def test_ordinary_speech_is_not_looped(): + wav = with_edges(speech(10.0, seed=3)) + assert quality.envelope_repetition(wav, SR) < 0.92 + + +def test_short_segment_cannot_be_looped(): + # Too short for a repeat to be measurable — must return 0, not crash. + assert quality.envelope_repetition(speech(0.4), SR) == 0.0 + + +# -------------------------------------------------------------------------- +# Report plumbing +# -------------------------------------------------------------------------- + + +def test_severity_reports_the_worst_issue(): + report = quality.inspect_segment(with_edges(speech(1.0)), SR, sentence(200)) + assert report.severity == quality.FATAL + + +def test_penalty_prefers_fewer_and_milder_issues(): + clean = quality.inspect_segment(with_edges(speech(10.0)), SR, sentence(140)) + truncated = quality.inspect_segment(with_edges(speech(1.0)), SR, sentence(200)) + assert clean.penalty < truncated.penalty + + +def test_summarize_counts_by_code(): + good = quality.inspect_segment(with_edges(speech(10.0)), SR, sentence(140)) + bad = quality.inspect_segment(np.zeros(SR, dtype=np.float32), SR, sentence(140)) + summary = quality.summarize([("ch1/seg1", good), ("ch1/seg2", bad)]) + assert summary["segments"] == 2 + assert summary["flagged"] == 1 + assert summary["fatal"] == 1 + assert summary["by_code"]["silent"] == 1 + assert summary["details"][0]["segment"] == "ch1/seg2" + + +# -------------------------------------------------------------------------- +# Retry seeds +# -------------------------------------------------------------------------- + + +def test_retry_seed_is_deterministic(): + assert quality.retry_seed(1234, 1, "bonjour") == quality.retry_seed(1234, 1, "bonjour") + + +def test_retry_seed_differs_per_attempt_and_text(): + assert quality.retry_seed(1234, 1, "bonjour") != quality.retry_seed(1234, 2, "bonjour") + assert quality.retry_seed(1234, 1, "bonjour") != quality.retry_seed(1234, 1, "bonsoir") + assert quality.retry_seed(1234, 1, "bonjour") != 1234 + + +def test_retry_seed_stays_in_engine_range(): + for attempt in range(1, 20): + value = quality.retry_seed(2**31, attempt, "x") + assert 0 <= value < 2**32 + + +def test_retry_seed_of_none_is_none(): + # The engine already randomises; asking again is enough. + assert quality.retry_seed(None, 1, "bonjour") is None + + +# -------------------------------------------------------------------------- +# render_checked +# -------------------------------------------------------------------------- + + +def _good(_seed=None): + return SR, with_edges(speech(10.0)) + + +def _truncated(_seed=None): + return SR, with_edges(speech(0.5)) + + +def test_render_checked_keeps_a_good_first_take(): + calls = [] + + def render(seed): + calls.append(seed) + return _good(seed) + + result = quality.render_checked(sentence(140), render, base_seed=99, max_attempts=3) + assert result.report.ok + assert result.attempts == 1 + assert calls == [99] # no re-roll when nothing is wrong + assert not result.repaired + + +def test_render_checked_rerolls_a_fatal_take(): + def render(seed): + return _truncated(seed) if seed == 99 else _good(seed) + + result = quality.render_checked(sentence(140), render, base_seed=99, max_attempts=3) + assert result.attempts == 2 + assert not result.report.fatal + assert result.repaired + assert result.seed != 99 + assert [r.codes for r in result.rejected] == [("truncated",)] + + +def test_render_checked_stops_at_max_attempts(): + calls = [] + + def render(seed): + calls.append(seed) + return _truncated(seed) + + result = quality.render_checked(sentence(140), render, base_seed=1, max_attempts=3) + assert len(calls) == 3 + assert result.attempts == 3 + assert result.unrepairable + + +def test_render_checked_returns_the_best_attempt_not_the_last(): + # First take is merely truncated, second is silent — the first must win. + takes = [(SR, with_edges(speech(3.0))), (SR, np.zeros(SR * 3, dtype=np.float32))] + + def render(_seed): + return takes.pop(0) + + result = quality.render_checked(sentence(200), render, base_seed=5, max_attempts=2) + assert "silent" not in result.report.codes + assert result.seed == 5 + assert quality.audio_tools.peak_db(result.wav) > -50.0 + + +def test_render_checked_survives_an_engine_crash_on_one_seed(): + def render(seed): + if seed == 42: + raise RuntimeError("engine died on this seed") + return _good(seed) + + result = quality.render_checked(sentence(140), render, base_seed=42, max_attempts=3) + assert result.report.ok + assert result.attempts == 2 # the crashed call counts as an attempt + + +def test_render_checked_reraises_when_every_attempt_crashes(): + def render(_seed): + raise RuntimeError("engine is down") + + with pytest.raises(RuntimeError, match="engine is down"): + quality.render_checked(sentence(140), render, base_seed=1, max_attempts=2) + + +def test_render_checked_reports_each_attempt(): + seen = [] + + def render(seed): + return _truncated(seed) if seed == 7 else _good(seed) + + quality.render_checked( + sentence(140), + render, + base_seed=7, + max_attempts=2, + on_attempt=lambda index, report: seen.append((index, report.codes)), + ) + assert seen[0] == (0, ("truncated",)) + assert seen[1][0] == 1 + + +def test_render_checked_never_calls_render_more_than_once_when_ok(): + calls = [] + quality.render_checked( + sentence(140), lambda seed: (calls.append(seed), _good(seed))[1], base_seed=None, max_attempts=5 + ) + assert len(calls) == 1 From 6ac39a80721df4df5c2169ae0234e1c82195f018 Mon Sep 17 00:00:00 2001 From: PaxHelios Date: Tue, 28 Jul 2026 23:31:41 +0200 Subject: [PATCH 14/98] feat(narrate_book): inspect and re-roll defective segments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generation now goes through the quality pass: --qc-retries controls how many times a fatally defective segment is re-rolled (default 1, 0 to report without regenerating), --no-qc skips it, and --qc-strict exits non-zero if a defect survives, so an automated chain can react. Reused cache entries are inspected too — a segment cached by a run predating this pass would otherwise reach the book unexamined. The per-segment verdicts are written to qc_report.json next to the chapters, with the worst offenders also printed at the end. Tested end to end against a stub engine, so the whole run — planning, cache, inspection, re-roll, mastering, report — executes in milliseconds without importing torch. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01UqWxj2j9bdavcLn25ckX8X --- scripts/narrate_book.py | 117 +++++++++++++---- tests/test_narrate_book_qc.py | 235 ++++++++++++++++++++++++++++++++++ 2 files changed, 328 insertions(+), 24 deletions(-) create mode 100644 tests/test_narrate_book_qc.py diff --git a/scripts/narrate_book.py b/scripts/narrate_book.py index e94c862c..76a28b9e 100644 --- a/scripts/narrate_book.py +++ b/scripts/narrate_book.py @@ -41,6 +41,7 @@ ./.venv/Scripts/python.exe scripts/narrate_book.py livre.txt --voice "..." --device cuda """ import argparse +import json import os import sys import tempfile @@ -57,7 +58,7 @@ from narration import assemble as assembly # noqa: E402 from narration import audio as audio_tools # noqa: E402 from narration import cache as cache_tools # noqa: E402 -from narration import chunking, text_fr # noqa: E402 +from narration import chunking, quality, text_fr # noqa: E402 #: Rough characters-per-second of finished narration, used only to estimate how #: long a book will run before committing hours of CPU to it. @@ -137,6 +138,15 @@ def build_parser() -> argparse.ArgumentParser: help="EXPERIMENTAL: chain each segment from the previous one (prompt-cache " "continuation) for smoother joins, instead of same-seed only. Slower; " "resets at each chapter boundary. Tune on a GPU (slow to iterate on CPU).") + + qc = parser.add_argument_group("contrôle qualité") + qc.add_argument("--no-qc", action="store_true", + help="Do not inspect generated segments for defects") + qc.add_argument("--qc-retries", type=int, default=1, metavar="N", + help="Re-roll a fatally defective segment up to N times with a derived " + "seed (default: 1; 0 to report defects without regenerating)") + qc.add_argument("--qc-strict", action="store_true", + help="Exit non-zero if any segment is still defective at the end") return parser @@ -212,6 +222,10 @@ def main() -> int: cache = cache_tools.ChunkCache(outdir / ".cache", enabled=not args.no_cache) mastering = audio_tools.MasteringSettings(target_rms_db=args.target_rms) + qc = not args.no_qc + thresholds = quality.QualityThresholds() + qc_reports: list[tuple[str, quality.SegmentReport]] = [] + demo = app.VoxCPMDemo(model_id=args.model_id, device=args.device, load_denoiser=False) print(f"\nDébut de la narration à {time.strftime('%H:%M:%S')} (device={args.device}). " f"C'est lent sur CPU.\n", flush=True) @@ -234,36 +248,67 @@ def main() -> int: try: for position, segment in enumerate(segments): key = cache.key(segment.text, voice_spec, parent=previous_key if args.continuity else None) + label = f"ch{index:03d}/seg{position + 1:03d}" + + def render(current_seed, _segment=segment): + """One generation of this segment at a given seed.""" + if args.continuity and previous_wav_path is not None: + # The voice now comes from the running audio, so the + # control text is dropped. + sr, wav_out, _ = demo.generate_tts_audio( + text_input=_segment.text, + control_instruction="", + reference_wav_path_input=previous_wav_path, + prompt_text=previous_text, + cfg_value_input=args.cfg, + do_normalize=not args.no_normalize, + inference_timesteps=args.steps, + seed=current_seed, + ) + else: + sr, wav_out, _ = demo.generate_tts_audio( + text_input=_segment.text, + control_instruction=description, + cfg_value_input=args.cfg, + do_normalize=not args.no_normalize, + inference_timesteps=args.steps, + seed=current_seed, + ) + return sr, wav_out + cached = cache.get(key) if cached is not None: sample_rate, wav = cached status = "cache" - elif args.continuity and previous_wav_path is not None: - # The voice now comes from the running audio, so the control - # text is dropped. - sample_rate, wav, _ = demo.generate_tts_audio( - text_input=segment.text, - control_instruction="", - reference_wav_path_input=previous_wav_path, - prompt_text=previous_text, - cfg_value_input=args.cfg, - do_normalize=not args.no_normalize, - inference_timesteps=args.steps, - seed=seed, + # Inspected too: a segment cached by a run that predates the + # quality pass, or one that was kept as the least-bad + # attempt, should still be reported rather than pass silently. + report = quality.inspect_segment(wav, sample_rate, segment.text, thresholds) if qc else None + elif qc: + result = quality.render_checked( + segment.text, + render, + base_seed=seed, + max_attempts=max(1, args.qc_retries + 1), + thresholds=thresholds, ) + sample_rate, wav, report = result.sample_rate, result.wav, result.report cache.put(key, sample_rate, wav, text=segment.text) - status = "généré" + if result.attempts == 1: + status = "généré" + elif result.unrepairable: + status = f"généré, DÉFECTUEUX après {result.attempts} essais" + else: + status = f"régénéré ({result.attempts} essais)" else: - sample_rate, wav, _ = demo.generate_tts_audio( - text_input=segment.text, - control_instruction=description, - cfg_value_input=args.cfg, - do_normalize=not args.no_normalize, - inference_timesteps=args.steps, - seed=seed, - ) + sample_rate, wav = render(seed) cache.put(key, sample_rate, wav, text=segment.text) - status = "généré" + status, report = "généré", None + + if report is not None: + qc_reports.append((label, report)) + if not report.ok: + status += f" — {report.describe()}" rendered.append((wav, segment.pause_after)) previous_key = key @@ -303,12 +348,32 @@ def main() -> int: print(f"\nTerminé. Chapitres dans : {outdir}") print(cache.stats.describe()) + # ---- quality report ------------------------------------------------- + defective = 0 + if qc_reports: + summary = quality.summarize(qc_reports) + defective = summary["fatal"] + report_path = outdir / "qc_report.json" + report_path.write_text(json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8") + if summary["flagged"]: + codes = ", ".join(f"{code}×{count}" for code, count in sorted(summary["by_code"].items())) + print( + f"Contrôle qualité : {summary['flagged']}/{summary['segments']} segment(s) signalé(s) " + f"({codes}) — détail dans {report_path.name}" + ) + for detail in summary["details"][:10]: + print(f" {detail['segment']}: {', '.join(i['code'] for i in detail['issues'])}") + if len(summary["details"]) > 10: + print(f" … et {len(summary['details']) - 10} autre(s), voir {report_path.name}") + else: + print(f"Contrôle qualité : {summary['segments']}/{summary['segments']} segment(s) sains") + # ---- assemble ------------------------------------------------------ if args.assemble: chapter_files = sorted(p for p in outdir.glob("chapitre_*.wav")) if not chapter_files: print("Rien à assembler.") - return 0 + return 1 if (args.qc_strict and defective) else 0 target = outdir / f"{outdir.name}_complet.{args.assemble}" print(f"\nAssemblage de {len(chapter_files)} chapitre(s) -> {target.name}") result = assembly.assemble( @@ -328,6 +393,10 @@ def main() -> int: else: print("Astuce : ajoutez --assemble m4b pour produire un fichier unique avec chapitres, " "ou lancez scripts/assemble_audiobook.py plus tard.") + + if args.qc_strict and defective: + print(f"--qc-strict : {defective} segment(s) toujours défectueux.") + return 1 return 0 diff --git a/tests/test_narrate_book_qc.py b/tests/test_narrate_book_qc.py new file mode 100644 index 00000000..f320298d --- /dev/null +++ b/tests/test_narrate_book_qc.py @@ -0,0 +1,235 @@ +"""End-to-end tests of the quality pass inside scripts/narrate_book.py. + +The engine is replaced by a stub, so the whole narration run — planning, cache, +quality inspection, re-rolls, mastering, report — executes in milliseconds and +without importing torch. What is under test is the wiring: that a defective take +really does trigger a second call, that the good one is what lands in the +chapter, and that the report says so. +""" +from __future__ import annotations + +import importlib.util +import json +import sys +import types +from pathlib import Path + +import numpy as np +import pytest +import soundfile as sf + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +SR = 24000 +BASE_SEED = 4242 +#: Characters per second the stub engine reads at, inside the accepted band. +STUB_RATE = 17.0 + +#: Every (text, seed) the stub engine was asked for, in order. +CALLS: list[tuple[str, int | None]] = [] +#: Seeds the stub should fail on. Empty means every take is clean. +FAIL_SEEDS: set[int | None] = set() + + +def _noise(seconds: float, level: float = 0.2) -> np.ndarray: + """A signal that passes every check except the ones a test is targeting.""" + samples = max(1, int(SR * seconds)) + rng = np.random.default_rng(abs(hash(round(seconds, 3))) % (2**32)) + syllables = max(2, int(seconds * 6)) + gains = rng.uniform(0.3, 1.0, syllables + 1) + envelope = np.interp(np.linspace(0.0, syllables, samples), np.arange(syllables + 1), gains) + body = (rng.normal(0.0, 1.0, samples) * envelope).astype(np.float32) + body *= level / max(float(np.max(np.abs(body))), 1e-9) + tail = np.zeros(int(SR * 0.4), dtype=np.float32) + return np.concatenate([tail, body, tail]) + + +class StubDemo: + """Stands in for VoxCPMDemo, returning audio whose defects are scripted.""" + + def __init__(self, **_kwargs) -> None: + pass + + def generate_tts_audio(self, *, text_input, seed=None, **_kwargs): + CALLS.append((text_input, seed)) + characters = len((text_input or "").strip()) + if seed in FAIL_SEEDS: + # Far too little audio for the text: a truncation, which is fatal. + return SR, _noise(0.3), None + return SR, _noise(max(0.5, characters / STUB_RATE)), None + + +app_stub = types.ModuleType("app") +app_stub.PRESET_VOICES = [ + {"name": "Voix de test", "description": "voix française de test", "seed": BASE_SEED} +] +app_stub._PRESET_BY_NAME = {"Voix de test": app_stub.PRESET_VOICES[0]} +app_stub._OUTPUT_DIR = ROOT / "output" +app_stub._sanitize_filename = lambda name: name +app_stub.VoxCPMDemo = StubDemo +sys.modules["app"] = app_stub + +spec = importlib.util.spec_from_file_location("narrate_book", ROOT / "scripts" / "narrate_book.py") +narrate_book = importlib.util.module_from_spec(spec) +assert spec.loader is not None +spec.loader.exec_module(narrate_book) + + +@pytest.fixture(autouse=True) +def reset_stub(): + CALLS.clear() + FAIL_SEEDS.clear() + yield + CALLS.clear() + FAIL_SEEDS.clear() + + +@pytest.fixture +def book(tmp_path): + """A two-chapter book, one segment each.""" + path = tmp_path / "livre.txt" + path.write_text( + "Chapitre premier. Une phrase de longueur raisonnable pour un segment.\n" + "---\n" + "Chapitre second. Une autre phrase, de longueur comparable au premier.\n", + encoding="utf-8", + ) + return path + + +def run(monkeypatch, book, outdir, *extra) -> int: + argv = [ + "narrate_book.py", + str(book), + "--voice", + "Voix de test", + "--outdir", + str(outdir), + *extra, + ] + monkeypatch.setattr(sys, "argv", argv) + return narrate_book.main() + + +def read_report(outdir: Path) -> dict: + return json.loads((outdir / "qc_report.json").read_text(encoding="utf-8")) + + +# -------------------------------------------------------------------------- + + +def test_clean_run_reports_every_segment_as_healthy(monkeypatch, book, tmp_path): + outdir = tmp_path / "out" + assert run(monkeypatch, book, outdir) == 0 + + report = read_report(outdir) + assert report["segments"] == 2 + assert report["flagged"] == 0 + assert report["fatal"] == 0 + assert len(CALLS) == 2 # one call per segment, no re-roll + assert sorted(p.name for p in outdir.glob("chapitre_*.wav")) == [ + "chapitre_001.wav", + "chapitre_002.wav", + ] + + +def test_defective_take_is_rerolled_with_a_derived_seed(monkeypatch, book, tmp_path): + FAIL_SEEDS.add(BASE_SEED) # the first attempt of every segment fails + outdir = tmp_path / "out" + assert run(monkeypatch, book, outdir) == 0 + + # Two segments, each generated twice: the failing base seed, then a derived one. + assert len(CALLS) == 4 + seeds = [seed for _, seed in CALLS] + assert seeds[0] == BASE_SEED and seeds[1] != BASE_SEED + assert seeds[2] == BASE_SEED and seeds[3] != BASE_SEED + + # The repaired take is the one kept, so nothing is left flagged. + report = read_report(outdir) + assert report["flagged"] == 0 + + +def test_reroll_seed_matches_the_documented_derivation(monkeypatch, book, tmp_path): + from narration import quality + + FAIL_SEEDS.add(BASE_SEED) + run(monkeypatch, book, tmp_path / "out") + + text, retry = CALLS[0][0], CALLS[1][1] + assert retry == quality.retry_seed(BASE_SEED, 1, text) + + +def test_unrepairable_segment_is_reported_not_hidden(monkeypatch, book, tmp_path): + # Every seed fails, so no number of re-rolls can save it. + FAIL_SEEDS.update({BASE_SEED, None}) + monkeypatch.setattr(narrate_book.quality, "retry_seed", lambda *a, **k: None) + outdir = tmp_path / "out" + assert run(monkeypatch, book, outdir, "--qc-retries", "2") == 0 + + report = read_report(outdir) + assert report["flagged"] == 2 + assert report["fatal"] == 2 + assert report["by_code"]["truncated"] == 2 + assert report["details"][0]["issues"][0]["code"] == "truncated" + # A chapter is still written — a defective book beats no book, and the + # report says exactly which segments to listen to. + assert (outdir / "chapitre_001.wav").is_file() + + +def test_qc_strict_exits_non_zero_when_a_defect_survives(monkeypatch, book, tmp_path): + FAIL_SEEDS.update({BASE_SEED, None}) + monkeypatch.setattr(narrate_book.quality, "retry_seed", lambda *a, **k: None) + assert run(monkeypatch, book, tmp_path / "out", "--qc-strict") == 1 + + +def test_qc_strict_exits_zero_on_a_clean_run(monkeypatch, book, tmp_path): + assert run(monkeypatch, book, tmp_path / "out", "--qc-strict") == 0 + + +def test_zero_retries_reports_without_regenerating(monkeypatch, book, tmp_path): + FAIL_SEEDS.add(BASE_SEED) + outdir = tmp_path / "out" + assert run(monkeypatch, book, outdir, "--qc-retries", "0") == 0 + + assert len(CALLS) == 2 # inspected, never re-rolled + assert read_report(outdir)["fatal"] == 2 + + +def test_no_qc_skips_inspection_entirely(monkeypatch, book, tmp_path): + FAIL_SEEDS.add(BASE_SEED) + outdir = tmp_path / "out" + assert run(monkeypatch, book, outdir, "--no-qc") == 0 + + assert len(CALLS) == 2 + assert not (outdir / "qc_report.json").exists() + + +def test_repaired_audio_is_what_reaches_the_chapter(monkeypatch, book, tmp_path): + FAIL_SEEDS.add(BASE_SEED) + outdir = tmp_path / "out" + run(monkeypatch, book, outdir) + + audio, sample_rate = sf.read(str(outdir / "chapitre_001.wav"), dtype="float32") + # The rejected take was 0.3s of speech; the kept one is several seconds. + assert len(audio) / sample_rate > 2.0 + + +def test_cached_segments_are_still_inspected(monkeypatch, book, tmp_path): + """A resumed run must not skip the report for what it reused. + + Segments cached by an earlier run — possibly one that predates the quality + pass — would otherwise pass silently and never appear in the report. + """ + outdir = tmp_path / "out" + run(monkeypatch, book, outdir) + assert len(CALLS) == 2 + + # Second run over the same directory: chapters exist, so force them to be + # rebuilt from the cache rather than skipped wholesale. + CALLS.clear() + run(monkeypatch, book, outdir, "--force") + assert CALLS == [] # everything served from the cache + report = read_report(outdir) + assert report["segments"] == 2 + assert report["flagged"] == 0 From 70073b6fe7cb11f24da6c6ab9bc09289fb9ac2ff Mon Sep 17 00:00:00 2001 From: PaxHelios Date: Tue, 28 Jul 2026 23:31:42 +0200 Subject: [PATCH 15/98] feat(app): surface the quality pass in the audiobook tab A "quality re-rolls per segment" slider (0-3, default 1) in the narration settings, and defects reported in the progress panel as they are found rather than only in the summary: on a run that lasts hours, a defect worth stopping for should not wait until the end to become visible. The run ends with a count and a qc_report.json alongside the chapters. When every chapter was already generated nothing is inspected, and the quality line is then omitted rather than claiming a clean bill. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01UqWxj2j9bdavcLn25ckX8X --- app.py | 73 ++++++++++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 64 insertions(+), 9 deletions(-) diff --git a/app.py b/app.py index 84c3b73f..3f609044 100644 --- a/app.py +++ b/app.py @@ -26,7 +26,7 @@ from narration import assemble as assembly from narration import audio as audio_tools from narration import cache as cache_tools -from narration import chunking, text_fr +from narration import chunking, quality, text_fr logging.basicConfig( level=logging.INFO, @@ -223,6 +223,9 @@ "book_target_rms_info": "Audiobook platforms expect RMS between -23 and -18 dBFS.", "book_pause_sentence_label": "Pause after a sentence (s)", "book_pause_paragraph_label": "Pause after a paragraph (s)", + "book_qc_label": "Quality re-rolls per segment", + "book_qc_info": "A segment that comes back truncated, silent or babbling is generated " + "again with a derived seed. 0 only reports the defects.", "usage_instructions": _USAGE_INSTRUCTIONS_EN, "examples_footer": _EXAMPLES_FOOTER_EN, }, @@ -283,6 +286,9 @@ "book_target_rms_info": "Les plateformes de livres audio attendent un RMS entre -23 et -18 dBFS.", "book_pause_sentence_label": "Pause après une phrase (s)", "book_pause_paragraph_label": "Pause après un paragraphe (s)", + "book_qc_label": "Réessais qualité par segment", + "book_qc_info": "Un segment qui revient tronqué, muet ou parti en boucle est régénéré " + "avec une graine dérivée. 0 se contente de signaler les défauts.", "usage_instructions": _USAGE_INSTRUCTIONS_FR, "examples_footer": _EXAMPLES_FOOTER_FR, }, @@ -912,6 +918,7 @@ def _book_narrate( pause_sentence, pause_paragraph, preset_name, + qc_retries, progress=gr.Progress(), ): """Narrate every chapter, writing each one to disk as soon as it is done. @@ -951,6 +958,8 @@ def _book_narrate( f"Voix : **{voice_label}** · graine `{seed}` · dossier `{outdir.name}`\n", ] last_chapter_path = None + qc_flagged: List[Tuple[str, quality.SegmentReport]] = [] + qc_inspected = 0 yield "\n".join(lines), None for index, chapter in enumerate(chapters, 1): @@ -969,21 +978,43 @@ def _book_narrate( rendered: List[Tuple[np.ndarray, float]] = [] sr = None - for segment in progress.tqdm(segments, desc=f"Chapitre {index}/{len(chapters)}"): + for position, segment in enumerate( + progress.tqdm(segments, desc=f"Chapitre {index}/{len(chapters)}"), 1 + ): key = cache.key(segment.text, voice_spec) cached = cache.get(key) if cached is not None: sr, wav_chunk = cached + report = quality.inspect_segment(wav_chunk, sr, segment.text) else: - sr, wav_chunk, _ = demo.generate_tts_audio( - text_input=segment.text, - control_instruction=description, - cfg_value_input=cfg_value, - do_normalize=do_normalize, - inference_timesteps=int(dit_steps), - seed=seed, + def render(current_seed, _segment=segment): + sample_rate, wav_out, _ = demo.generate_tts_audio( + text_input=_segment.text, + control_instruction=description, + cfg_value_input=cfg_value, + do_normalize=do_normalize, + inference_timesteps=int(dit_steps), + seed=current_seed, + ) + return sample_rate, wav_out + + result = quality.render_checked( + segment.text, render, base_seed=seed, max_attempts=int(qc_retries) + 1 ) + sr, wav_chunk, report = result.sample_rate, result.wav, result.report cache.put(key, sr, wav_chunk, text=segment.text) + + qc_inspected += 1 + if not report.ok: + # Surfaced as it happens rather than only in the final + # summary: on a run that lasts hours, a defect worth + # stopping for should not wait until the end to be seen. + qc_flagged.append((f"ch{index:03d}/seg{position:03d}", report)) + lines.append( + f" - {'❌' if report.fatal else '⚠️'} chapitre {index}, segment " + f"{position}/{len(segments)} — {report.describe()}" + ) + yield "\n".join(lines), last_chapter_path rendered.append((wav_chunk, segment.pause_after)) chapter_audio = audio_tools.stitch(rendered, sr, mastering) @@ -997,6 +1028,21 @@ def _book_narrate( yield "\n".join(lines), last_chapter_path lines.append(f"\n**Terminé.** {cache.stats.describe()}") + if qc_flagged: + summary = quality.summarize(qc_flagged) + (outdir / "qc_report.json").write_text( + json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8" + ) + codes = ", ".join(f"{code}×{count}" for code, count in sorted(summary["by_code"].items())) + lines.append( + f"\n**Contrôle qualité :** {summary['flagged']} segment(s) signalé(s) " + f"({codes}), dont {summary['fatal']} non réparé(s) — détail dans " + f"`{outdir.name}/qc_report.json`." + ) + elif qc_inspected: + lines.append( + f"\n**Contrôle qualité :** {qc_inspected} segment(s) inspecté(s), aucun défaut." + ) lines.append(f"\nChapitres dans `{outdir}` — utilisez « Assembler » pour un fichier unique.") yield "\n".join(lines), last_chapter_path @@ -1242,6 +1288,14 @@ def _run_asr_if_needed(checked, audio_path): step=0.05, label=I18N("book_pause_paragraph_label"), ) + book_qc_retries = gr.Slider( + minimum=0, + maximum=3, + value=1, + step=1, + label=I18N("book_qc_label"), + info=I18N("book_qc_info"), + ) with gr.Row(): book_plan_btn = gr.Button(I18N("book_plan_btn"), size="sm") @@ -1378,6 +1432,7 @@ def _run_asr_if_needed(checked, audio_path): book_pause_sentence, book_pause_paragraph, preset_voice, + book_qc_retries, ], outputs=[book_status, book_audio], show_progress=True, From 77223fa21ccfb9c1526d622f198af186920c862d Mon Sep 17 00:00:00 2001 From: PaxHelios Date: Tue, 28 Jul 2026 23:31:42 +0200 Subject: [PATCH 16/98] docs: explain the quality pass and where its thresholds come from Covers what each defect code means, why only fatal ones trigger a re-roll, and the measured speech-rate range the truncation bounds are placed around. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01UqWxj2j9bdavcLn25ckX8X --- docs/NARRATION.md | 56 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/docs/NARRATION.md b/docs/NARRATION.md index f1b06cb6..c5a6a950 100644 --- a/docs/NARRATION.md +++ b/docs/NARRATION.md @@ -154,6 +154,62 @@ Vérifier un livre assemblé : .\.venv\Scripts\python.exe scripts\assemble_audiobook.py output\book_mon_livre --check ``` +## Contrôle qualité automatique + +Le moteur échoue rarement, mais il échoue **localement** : un segment sur quelques +dizaines revient coupé au milieu d'un mot, muet, ou parti en boucle bien après la fin +de son texte. Sur GPU on régénère le chapitre. Sur CPU un chapitre représente des +heures, donc la seule réparation abordable porte sur le segment fautif — encore +faut-il le trouver. Écouter quatre heures de narration pour repérer onze secondes +n'est pas une méthode. + +Chaque segment généré est donc confronté **au texte qui l'a produit**. C'est ce +couplage qui rend la détection possible : l'audio seul ne peut pas dire si deux +secondes constituent une phrase complète, mais deux secondes pour deux cents +caractères sont une troncature, sans ambiguïté. + +| Code | Gravité | Ce qui est détecté | +|---|---|---| +| `silent` | fatal | rien n'est revenu | +| `truncated` | fatal | beaucoup moins d'audio que le texte ne l'implique | +| `runaway` | fatal | beaucoup plus — le moteur a bouclé ou divagué | +| `clipped` | fatal | échantillons saturés, irrécupérables au mastering | +| `gap` | suspect | long silence interne, signature d'une proposition sautée | +| `abrupt_end` | suspect | s'arrête au niveau de parole, sans décroissance | +| `looped` | suspect | l'enveloppe de niveau se répète | + +Seuls les défauts **fatals** déclenchent une régénération, avec une seed dérivée de +la seed d'origine — donc reproductible : le même livre relancé de zéro répare le même +segment de la même façon. Le meilleur essai est conservé, jamais le dernier : un +second tirage peut être pire que le premier, et garder silencieusement le pire +rendrait la réparation nuisible. + +```bash +# Comportement par défaut : un nouvel essai par segment fatalement défectueux +python scripts/narrate_book.py livre.txt --voice "..." + +# Plus insistant sur un livre qu'on ne veut pas réécouter segment par segment +python scripts/narrate_book.py livre.txt --voice "..." --qc-retries 3 + +# Signaler sans régénérer (utile pour auditer un livre déjà produit) +python scripts/narrate_book.py livre.txt --voice "..." --qc-retries 0 + +# Sortie en code d'erreur s'il reste un défaut — pour un enchaînement automatisé +python scripts/narrate_book.py livre.txt --voice "..." --qc-strict +``` + +Le bilan est écrit dans `output/book_/qc_report.json` : un segment par entrée, +avec sa durée, son débit et ses défauts. `--no-qc` désactive tout. + +**Sur les seuils de débit.** Ce sont eux qui portent la détection de troncature, et +ils viennent de la mesure, pas d'une estimation : sur la même phrase de 81 +caractères, les sept voix préréglées produisent entre **15,8 et 24,1 caractères par +seconde**, soit plus de 50 % d'écart entre la plus lente et la plus rapide. Les +bornes (35 et 6 car/s) sont donc placées largement en dehors de cette plage — choisir +une voix rapide ne doit jamais ressembler à un défaut — tout en restant franchies par +une troncature qui perdrait la moitié d'une phrase. Un test verrouille ces valeurs +mesurées, pour qu'un réglage ultérieur ne puisse pas les faire dériver sans alerte. + ## Assemblage en un fichier unique ``` From bd31ea0c1cf9ecc66effabc3fb2a57cb90b7ec91 Mon Sep 17 00:00:00 2001 From: PaxHelios Date: Wed, 29 Jul 2026 00:07:00 +0200 Subject: [PATCH 17/98] fix(app): preview the voice that is selected, not the text boxes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clicking "Écouter un aperçu" could start a from-scratch generation instead of playing the stored sample. The handler read the description and seed from the Studio text boxes; when those were empty or out of step with the dropdown there was no seed, so no cache key, so nothing to play — and the button silently began synthesizing. The server log from a real attempt shows it: `[Voice Design] control: None`, then twenty-two minutes to reach 11% of one five-second sample, with nothing on screen to explain the wait. A preset is now asked for its own description, seed, CFG and steps, which makes the stored preview a guaranteed hit. Without one, an empty description is refused outright rather than generating a random voice, and a genuine generation on CPU warns that it will take tens of minutes and points at scripts/pregenerate_previews.py. Verified against the running server through its own API: the case that used to hang — preset selected, boxes empty — now returns the cached file in 0.9s, and the empty case errors instantly instead of occupying the queue. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01UqWxj2j9bdavcLn25ckX8X --- app.py | 50 ++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 46 insertions(+), 4 deletions(-) diff --git a/app.py b/app.py index 3f609044..cb8303e5 100644 --- a/app.py +++ b/app.py @@ -825,15 +825,50 @@ def _generate( out_path = _save_output_wav(wav_np, sr, last_successful_seed, voice_name) return out_path, last_successful_seed - def _preview_voice(description, seed_value, cfg, steps, normalize): - """Generate (and cache) a short sample of the currently selected voice.""" - seed = _coerce_seed(seed_value) + def _preview_voice(description, seed_value, cfg, steps, normalize, preset_name=None): + """Play the stored sample of the selected voice, generating it if absent. + + A preset is asked for its own description and seed rather than reading + the text boxes. Those boxes can be empty or half-edited, and when the + seed is missing there is no cache key, so what looks like "play this + voice" silently becomes a from-scratch generation — roughly forty + minutes on a CPU, with nothing on screen to say so. + """ + preset = ( + _PRESET_BY_NAME.get(preset_name) + if preset_name and preset_name != PRESET_CUSTOM_LABEL + else None + ) + if preset is not None: + description = preset.get("description", description) + seed = _coerce_seed(preset.get("seed")) + cfg = preset.get("cfg", cfg) + steps = preset.get("diffusion_steps", steps) + normalize = preset.get("normalize", normalize) + else: + seed = _coerce_seed(seed_value) + cache_path = None if seed is not None: _PREVIEW_DIR.mkdir(parents=True, exist_ok=True) cache_path = _PREVIEW_DIR / f"preview_{seed}.wav" if cache_path.is_file(): return str(cache_path) + + if not (description or "").strip(): + raise gr.Error( + "Aucune voix à écouter : choisissez une voix prédéfinie dans la liste, " + "ou décrivez la voix souhaitée." + ) + + # Nothing cached, so this really is a generation. On CPU that is tens of + # minutes; saying so beats a button that appears to do nothing. + if not demo.device.startswith("cuda"): + gr.Warning( + "Aucun aperçu enregistré pour cette voix : génération en cours, " + "comptez plusieurs dizaines de minutes sur ce processeur. " + "scripts/pregenerate_previews.py permet de les préparer à l'avance." + ) sr, wav_np, _ = demo.generate_tts_audio( text_input=_PREVIEW_TEXT, control_instruction=description or "", @@ -1361,7 +1396,14 @@ def _run_asr_if_needed(checked, audio_path): show_progress=False, ).then( fn=_preview_voice, - inputs=[control_instruction, seed_value, cfg_value, dit_steps, DoNormalizeText], + inputs=[ + control_instruction, + seed_value, + cfg_value, + dit_steps, + DoNormalizeText, + preset_voice, + ], outputs=[preview_audio], show_progress=True, ) From f9dccbae76630345b161537a40a62d8f04c08f27 Mon Sep 17 00:00:00 2001 From: PaxHelios Date: Wed, 29 Jul 2026 00:07:02 +0200 Subject: [PATCH 18/98] feat(voices): seven more French narration voices Fills the gaps in the existing set rather than adding variations of it: a young female storyteller opposite the young male one, a dark female voice for thriller and noir, an elderly fireside storyteller for tales, a female counterpart to the guided-meditation voice, a plain didactic voice for essays and how-to books, a polished female voice for literary fiction, and a theatrical one for epic and historical narrative. Fourteen voices in total. Each is a (description, seed) pair, so the audio is reproducible; the previews still have to be listened to before any of them is trusted for a book. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01UqWxj2j9bdavcLn25ckX8X --- conf/preset_voices.json | 63 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/conf/preset_voices.json b/conf/preset_voices.json index 00d4ef0f..93f2daed 100644 --- a/conf/preset_voices.json +++ b/conf/preset_voices.json @@ -61,5 +61,68 @@ "diffusion_steps": 10, "normalize": true, "lang": "fr" + }, + { + "name": "Conteuse jeune & pétillante", + "description": "Voix féminine française de jeune conteuse d'environ vingt-cinq ans pour livre audio, pétillante et expressive, ton vivant et complice, débit naturel et enjoué, idéale pour la jeunesse et le roman contemporain", + "seed": 1794602311, + "cfg": 2.0, + "diffusion_steps": 10, + "normalize": true, + "lang": "fr" + }, + { + "name": "Narratrice grave & intense", + "description": "Voix féminine française grave et posée, timbre sombre et affirmé, ton tendu et maîtrisé, débit lent et pesant, diction précise, idéale pour le thriller, le polar et le récit sombre", + "seed": 2884176053, + "cfg": 2.0, + "diffusion_steps": 10, + "normalize": true, + "lang": "fr" + }, + { + "name": "Vieux conteur au coin du feu", + "description": "Voix masculine française de conteur âgé, timbre patiné et légèrement rocailleux, ton bienveillant et malicieux, débit lent et posé avec des silences, idéale pour les contes, les légendes et les récits pour enfants", + "seed": 3361208947, + "cfg": 2.0, + "diffusion_steps": 10, + "normalize": true, + "lang": "fr" + }, + { + "name": "Méditation guidée (voix féminine)", + "description": "Voix féminine française très douce et aérienne pour méditation guidée, extrêmement lente, presque chuchotée, ton apaisant et enveloppant, longues pauses, respiration calme, idéale pour la relaxation et le sommeil", + "seed": 1029384756, + "cfg": 2.0, + "diffusion_steps": 10, + "normalize": true, + "lang": "fr" + }, + { + "name": "Pédagogue clair & précis", + "description": "Voix masculine française neutre et pédagogique, timbre clair et présent, ton didactique et bienveillant, débit régulier et articulé, idéale pour l'essai, le livre pratique et la vulgarisation", + "seed": 2470915638, + "cfg": 2.0, + "diffusion_steps": 10, + "normalize": true, + "lang": "fr" + }, + { + "name": "Narratrice lumineuse & posée", + "description": "Voix féminine française lumineuse et posée, timbre clair et soyeux, ton élégant et maîtrisé, débit fluide et régulier, diction soignée, idéale pour la littérature blanche et le récit intimiste", + "seed": 4055721384, + "cfg": 2.0, + "diffusion_steps": 10, + "normalize": true, + "lang": "fr" + }, + { + "name": "Narrateur dramatique & théâtral", + "description": "Voix masculine française théâtrale et ample, timbre riche et projeté, ton dramatique et habité, débit varié avec des ruptures marquées, idéale pour la fiction intense, l'épopée et le récit historique", + "seed": 1613947205, + "cfg": 2.0, + "diffusion_steps": 10, + "normalize": true, + "lang": "fr" } ] From d7bcbb5db691cc488225c6cf6e8d73a4f1494a73 Mon Sep 17 00:00:00 2001 From: PaxHelios Date: Wed, 29 Jul 2026 00:36:33 +0200 Subject: [PATCH 19/98] refactor(quality): re-base the speech-rate figures on all fourteen voices MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The thresholds were derived from the seven voices that existed at the time. With fourteen previews now generated, the measured range is unchanged — 15.8 to 24.1 characters per second at both sample sizes — so the bounds stay where they are, but they now rest on twice the evidence. That a doubled sample moved neither end is the part worth recording. The expected rate moves from 17.0 to the measured median of 20.2, which only affects the "expected duration" wording in a report and the tie-break between two equally defective attempts. The test now pins every distinct rate observed across the fourteen, not a sample of four. Measured on the real previews: one voice out of fourteen is flagged, at suspect level (abrupt_end), none fatally. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01UqWxj2j9bdavcLn25ckX8X --- docs/NARRATION.md | 14 +++++++++----- narration/quality.py | 24 ++++++++++++++---------- tests/test_narration_quality.py | 7 +++++-- 3 files changed, 28 insertions(+), 17 deletions(-) diff --git a/docs/NARRATION.md b/docs/NARRATION.md index c5a6a950..f6224a67 100644 --- a/docs/NARRATION.md +++ b/docs/NARRATION.md @@ -203,11 +203,15 @@ avec sa durée, son débit et ses défauts. `--no-qc` désactive tout. **Sur les seuils de débit.** Ce sont eux qui portent la détection de troncature, et ils viennent de la mesure, pas d'une estimation : sur la même phrase de 81 -caractères, les sept voix préréglées produisent entre **15,8 et 24,1 caractères par -seconde**, soit plus de 50 % d'écart entre la plus lente et la plus rapide. Les -bornes (35 et 6 car/s) sont donc placées largement en dehors de cette plage — choisir -une voix rapide ne doit jamais ressembler à un défaut — tout en restant franchies par -une troncature qui perdrait la moitié d'une phrase. Un test verrouille ces valeurs +caractères, les quatorze voix préréglées produisent entre **15,8 et 24,1 caractères +par seconde** (médiane 20,2), soit plus de 50 % d'écart entre la plus lente et la plus +rapide. Les bornes (35 et 6 car/s) sont donc placées largement en dehors de cette +plage — choisir une voix rapide ne doit jamais ressembler à un défaut — tout en +restant franchies par une troncature qui perdrait la moitié d'une phrase. + +Cette plage n'a **pas bougé** quand le jeu de voix est passé de sept à quatorze : +mêmes 15,8 et 24,1 aux deux extrémités. C'est ce qui lui donne du crédit — doubler +l'échantillon n'a déplacé aucune borne. Un test verrouille chacune des valeurs mesurées, pour qu'un réglage ultérieur ne puisse pas les faire dériver sans alerte. ## Assemblage en un fichier unique diff --git a/narration/quality.py b/narration/quality.py index b0a280ed..baa1a2c6 100644 --- a/narration/quality.py +++ b/narration/quality.py @@ -69,17 +69,21 @@ class QualityThresholds: """Where each defect starts. The speech-rate limits are the load-bearing ones, and they are set from - measurement rather than from the nominal figure. The engine's own preset - voices, given the same 81-character sentence, come back between 15.8 and - 24.1 characters per second — a spread of more than 50% between the slowest - and the fastest voice. The bounds therefore sit well outside that range, so - that choosing a brisk voice is never mistaken for a defect; a truncation - that drops half of a sentence still doubles the rate and lands outside them. + measurement rather than from the nominal figure. The fourteen preset voices, + given the same 81-character sentence, come back between 15.8 and 24.1 + characters per second, median 20.2 — a spread of more than 50% between the + slowest and the fastest voice. The bounds therefore sit well outside that + range, so that choosing a brisk voice is never mistaken for a defect; a + truncation that drops half of a sentence still doubles the rate and lands + outside them. + + The range held exactly when the voice set grew from seven to fourteen, which + is the reason to trust it: doubling the sample moved neither end. """ - #: Middle of the range measured across the preset voices. Explains a report, - #: and breaks ties between attempts; never judges one on its own. - expected_chars_per_second: float = 17.0 + #: Median measured across the preset voices. Explains a report, and breaks + #: ties between attempts; never judges one on its own. + expected_chars_per_second: float = 20.0 #: Above this, the text cannot have been spoken in the audio returned. truncated_chars_per_second: float = 35.0 #: Below this, there is far more audio than the text can account for. @@ -128,7 +132,7 @@ class SegmentReport: issues: Tuple[Issue, ...] = () #: Rate the segment was judged against, carried so :attr:`penalty` can rank #: attempts without needing the thresholds that produced the report. - expected_chars_per_second: float = 17.0 + expected_chars_per_second: float = 20.0 @property def ok(self) -> bool: diff --git a/tests/test_narration_quality.py b/tests/test_narration_quality.py index c62cc517..0a6d5e11 100644 --- a/tests/test_narration_quality.py +++ b/tests/test_narration_quality.py @@ -75,13 +75,16 @@ def test_slow_and_fast_reading_are_both_accepted(): assert not report.fatal, f"{characters} chars / {seconds}s: {report.describe()}" -@pytest.mark.parametrize("rate", [15.8, 18.8, 21.1, 24.1]) +@pytest.mark.parametrize("rate", [15.8, 17.5, 18.8, 19.5, 20.2, 21.1, 23.0, 24.1]) def test_rates_measured_from_the_preset_voices_are_never_flagged(rate): """Guards the thresholds against drifting into the engine's real range. - These are the rates the seven preset voices actually produced on the same + Every distinct rate the fourteen preset voices produced on the same sentence. A change that makes any of them look defective would flag a large share of a real book, so it is caught here rather than four hours in. + + The range was unchanged when the set grew from seven voices to fourteen — + 15.8 to 24.1 both times — which is what makes it worth pinning. """ seconds = 8.0 report = quality.inspect_segment( From 6767ca3c917f4f568ceb1a7a326e2c6a1b7395fc Mon Sep 17 00:00:00 2001 From: PaxHelios Date: Wed, 29 Jul 2026 00:41:54 +0200 Subject: [PATCH 20/98] feat(app): choose and audition the narration voice from the book tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The audiobook tab had no voice control at all: the book was narrated with whatever the Studio tab happened to be set to, and a reader looking for a voice for their book had to leave the tab, guess which Studio state mattered, and come back. That is how a user ends up starting a forty-minute generation while trying to listen to a preset. The tab now has its own voice dropdown and preview button, and it is the dropdown — not a copy of the Studio state — that the book is narrated with. Deliberately not synchronised with the Studio picker: two-way mirroring between tabs invites an update loop, and one authoritative control per tab is easier to reason about than two that chase each other. A custom voice still works, by leaving the dropdown on "Personnalisé" and describing the voice in Studio. The preset-versus-text-boxes resolution, written twice since the preview fix, is now a single _resolve_voice helper shared by preview and narration. The dropdown lists every voice rather than the Studio language filter's subset, so a book is never silently restricted by a control on another tab. Verified against the running server: previewing from the book tab with the Studio boxes empty returns the cached sample in under a second, and asking to narrate with no voice chosen is refused instantly instead of synthesizing a random one. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01UqWxj2j9bdavcLn25ckX8X --- app.py | 89 +++++++++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 72 insertions(+), 17 deletions(-) diff --git a/app.py b/app.py index cb8303e5..78ccf8f0 100644 --- a/app.py +++ b/app.py @@ -719,6 +719,29 @@ def _coerce_seed(seed_value) -> Optional[int]: return None return int(seed_value) + def _resolve_voice(preset_name, description, seed_value, cfg, steps, normalize): + """Voice parameters to use, preferring the selected preset over the boxes. + + A preset carries its own description, seed, CFG and steps. Reading those + from the text boxes instead lets the two drift apart — and a missing seed + turns "play this voice" into a from-scratch generation, which is tens of + minutes on a CPU. + """ + preset = ( + _PRESET_BY_NAME.get(preset_name) + if preset_name and preset_name != PRESET_CUSTOM_LABEL + else None + ) + if preset is None: + return (description or ""), _coerce_seed(seed_value), cfg, steps, normalize + return ( + preset.get("description", description or ""), + _coerce_seed(preset.get("seed")), + preset.get("cfg", cfg), + preset.get("diffusion_steps", steps), + preset.get("normalize", normalize), + ) + def _prepare_seed(use_random_seed: bool, seed_value): if use_random_seed: return random.randint(0, 2**32 - 1) @@ -834,19 +857,9 @@ def _preview_voice(description, seed_value, cfg, steps, normalize, preset_name=N voice" silently becomes a from-scratch generation — roughly forty minutes on a CPU, with nothing on screen to say so. """ - preset = ( - _PRESET_BY_NAME.get(preset_name) - if preset_name and preset_name != PRESET_CUSTOM_LABEL - else None + description, seed, cfg, steps, normalize = _resolve_voice( + preset_name, description, seed_value, cfg, steps, normalize ) - if preset is not None: - description = preset.get("description", description) - seed = _coerce_seed(preset.get("seed")) - cfg = preset.get("cfg", cfg) - steps = preset.get("diffusion_steps", steps) - normalize = preset.get("normalize", normalize) - else: - seed = _coerce_seed(seed_value) cache_path = None if seed is not None: @@ -965,14 +978,15 @@ def _book_narrate( if not chapters: raise gr.Error("Aucun texte à narrer. Chargez un fichier .txt ou collez le texte.") - description = control_instruction or "" + description, seed, cfg_value, dit_steps, do_normalize = _resolve_voice( + preset_name, control_instruction, seed_value, cfg_value, dit_steps, do_normalize + ) if not description.strip(): raise gr.Error( - "Choisissez d'abord une voix dans l'onglet Studio " - "(la description de la voix est vide)." + "Choisissez une voix dans la liste ci-dessus, ou décrivez-en une " + "dans l'onglet Studio." ) - seed = _coerce_seed(seed_value) outdir = _book_dir(title) outdir.mkdir(parents=True, exist_ok=True) profile = _book_profile(pause_sentence, pause_paragraph) @@ -1294,6 +1308,28 @@ def _run_asr_if_needed(checked, audio_path): book_title = gr.Textbox(value="", label=I18N("book_title_label")) book_author = gr.Textbox(value="", label=I18N("book_author_label")) + # The book's own voice picker. Deliberately not a mirror + # of the Studio one: this dropdown is what the book is + # narrated with, so choosing a voice for a book never + # means leaving the tab. + with gr.Row(): + book_preset_voice = gr.Dropdown( + # Every voice, not the Studio tab's language + # filter: a book picked here should never be + # silently restricted by a control it cannot see. + choices=[PRESET_CUSTOM_LABEL] + _voice_names_for_lang(None), + value=PRESET_CUSTOM_LABEL, + label=I18N("preset_voices_label"), + info=I18N("preset_voices_info"), + scale=3, + ) + book_preview_btn = gr.Button( + I18N("preview_btn_label"), size="sm", scale=1 + ) + book_preview_audio = gr.Audio( + label=I18N("preview_btn_label"), visible=False + ) + with gr.Accordion(I18N("book_settings_title"), open=False): book_prepare = gr.Checkbox( value=True, @@ -1473,7 +1509,7 @@ def _run_asr_if_needed(checked, audio_path): book_target_rms, book_pause_sentence, book_pause_paragraph, - preset_voice, + book_preset_voice, book_qc_retries, ], outputs=[book_status, book_audio], @@ -1481,6 +1517,25 @@ def _run_asr_if_needed(checked, audio_path): api_name="narrate_book", ) + book_preview_btn.click( + fn=lambda: gr.update(visible=True), + outputs=[book_preview_audio], + show_progress=False, + ).then( + fn=_preview_voice, + inputs=[ + control_instruction, + seed_value, + cfg_value, + dit_steps, + DoNormalizeText, + book_preset_voice, + ], + outputs=[book_preview_audio], + show_progress=True, + api_name="preview_book_voice", + ) + book_assemble_btn.click( fn=_book_assemble, inputs=[book_title, book_author, book_format], From 8a71bbf5d6ea8ce4a2d358e5e8147c55351d8302 Mon Sep 17 00:00:00 2001 From: PaxHelios Date: Wed, 29 Jul 2026 00:42:35 +0200 Subject: [PATCH 21/98] docs: the book tab now picks its own voice Step one no longer sends the reader to the Studio tab; the voice is chosen and auditioned where the book is narrated. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01UqWxj2j9bdavcLn25ckX8X --- docs/NARRATION.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/docs/NARRATION.md b/docs/NARRATION.md index f6224a67..ccd16af0 100644 --- a/docs/NARRATION.md +++ b/docs/NARRATION.md @@ -65,9 +65,13 @@ lancer l'app (ou export sous bash). ### 1. Onglet « 📚 Livre audio » — pour un livre depuis l'interface -1. Choisis d'abord une voix dans l'onglet **🎙️ Studio** (la description et le seed - de cette voix sont ceux qui seront utilisés). -2. Passe sur l'onglet **📚 Livre audio**, charge ton `.txt` ou colle le texte. +1. Dans l'onglet **📚 Livre audio**, choisis une voix dans la liste **🎭 Voix + prédéfinies** et clique **« 🔊 Écouter un aperçu »** pour la comparer aux autres. + L'écoute est instantanée : les aperçus sont pré-générés dans + `assets/voice_previews/`. C'est cette liste qui détermine la voix du livre. + Pour une voix sur mesure, laisse-la sur **« Personnalisé / manuel »** et décris + la voix dans l'onglet **🎙️ Studio**. +2. Charge ton `.txt` ou colle le texte. 3. Clique **« 🔍 Analyser sans générer »** : tu vois le nombre de chapitres, de segments, la durée estimée, et **le premier segment tel qu'il sera réellement lu** (après préparation du texte). C'est le moment de repérer un nombre ou une From 978c84572cfe9059950aacd94d00b8d881414075 Mon Sep 17 00:00:00 2001 From: PaxHelios Date: Wed, 29 Jul 2026 01:12:20 +0200 Subject: [PATCH 22/98] feat(narration): repair one segment without re-narrating its chapter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The quality pass says which segment came out wrong; acting on that meant regenerating the whole chapter — hours of CPU to replace three seconds — or hand-deleting a cache entry named after a hash. What was missing was never the audio, which the cache still holds, but the recipe: how the chapter was cut into segments and with which voice, thrown away the moment a run ended. A narration now writes plan.json beside its chapters. With it a repair is offline except for the one segment being re-rolled: read the plan, generate that segment again with a fresh derived seed, drop it into the cache under the same key, restitch the chapter from cache entries. The other segments are never touched. Three decisions worth stating. A re-roll that comes back worse than the take it replaces is discarded, because a repair that can degrade a book is not a repair. The attempt number is remembered in the cache sidecar, so asking twice gives two different takes rather than the same derived seed again. And a chapter missing a cached segment is reported rather than written, since a silently shortened chapter is worse than a rebuild that failed. inspect_book() reads the cache rather than the finished chapters — a defect has to be located at the segment to be repaired at the segment, and this way it also works on books narrated before the quality pass existed. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01UqWxj2j9bdavcLn25ckX8X --- narration/__init__.py | 3 +- narration/cache.py | 33 ++- narration/repair.py | 371 +++++++++++++++++++++++++++++++++ tests/test_narration_repair.py | 329 +++++++++++++++++++++++++++++ 4 files changed, 731 insertions(+), 5 deletions(-) create mode 100644 narration/repair.py create mode 100644 tests/test_narration_repair.py diff --git a/narration/__init__.py b/narration/__init__.py index cfd85a91..cff98ae8 100644 --- a/narration/__init__.py +++ b/narration/__init__.py @@ -13,7 +13,8 @@ cache content-addressed store so an interrupted run resumes per chunk quality flag the segments the engine got wrong, and re-roll those only audio trim, master and stitch the generated segments + repair re-roll one segment and restitch its chapter, from a saved plan assemble join chapters into a single MP3/M4B with chapter markers """ -__all__ = ["assemble", "audio", "cache", "chunking", "quality", "text_fr"] +__all__ = ["assemble", "audio", "cache", "chunking", "quality", "repair", "text_fr"] diff --git a/narration/cache.py b/narration/cache.py index 5a657fa7..d1a9740f 100644 --- a/narration/cache.py +++ b/narration/cache.py @@ -127,7 +127,29 @@ def get(self, key: str) -> Optional[Tuple[int, np.ndarray]]: self.stats.hits += 1 return int(sample_rate), np.asarray(data, dtype=np.float32) - def put(self, key: str, sample_rate: int, wav: np.ndarray, text: str = "") -> Optional[Path]: + def attempt_of(self, key: str) -> int: + """Which re-roll produced the stored entry — 0 when it is the first take. + + Recorded so that repairing the same segment twice gives two different + takes: the seed of a re-roll is derived from the attempt number, so + without this the second repair would reproduce the first one exactly. + """ + sidecar = self.path(key).with_suffix(".json") + if not sidecar.is_file(): + return 0 + try: + return int(json.loads(sidecar.read_text(encoding="utf-8")).get("attempt", 0)) + except (OSError, ValueError, TypeError): + return 0 + + def put( + self, + key: str, + sample_rate: int, + wav: np.ndarray, + text: str = "", + attempt: int = 0, + ) -> Optional[Path]: """Store a generated segment. Returns the path, or None when disabled.""" if not self.enabled: return None @@ -146,12 +168,14 @@ def put(self, key: str, sample_rate: int, wav: np.ndarray, text: str = "") -> Op except (RuntimeError, OSError): temporary.unlink(missing_ok=True) return None - if text: - self._write_sidecar(key, text, sample_rate, wav) + if text or attempt: + self._write_sidecar(key, text, sample_rate, wav, attempt) self.stats.writes += 1 return target - def _write_sidecar(self, key: str, text: str, sample_rate: int, wav: np.ndarray) -> None: + def _write_sidecar( + self, key: str, text: str, sample_rate: int, wav: np.ndarray, attempt: int = 0 + ) -> None: """Record what a cache file contains, so the directory stays readable. Never fatal: losing a debugging aid must not lose the audio it describes. @@ -163,6 +187,7 @@ def _write_sidecar(self, key: str, text: str, sample_rate: int, wav: np.ndarray) "text": text, "duration_sec": round(len(wav) / float(sample_rate or 1), 3), "sample_rate": int(sample_rate), + "attempt": int(attempt), }, ensure_ascii=False, indent=1, diff --git a/narration/repair.py b/narration/repair.py new file mode 100644 index 00000000..8dd45095 --- /dev/null +++ b/narration/repair.py @@ -0,0 +1,371 @@ +"""Fix one bad segment without re-narrating the chapter it sits in. + +The quality pass says which segment came out wrong. Acting on that used to mean +regenerating the whole chapter — hours on a CPU to replace three seconds — or +hand-deleting a cache entry whose name is a hash. What was missing is not the +audio, which the cache still holds, but the *recipe*: how the chapter was cut +into segments, and with which voice. That is thrown away when a run ends. + +So a narration writes ``plan.json`` beside its chapters. With it, a repair is +cheap and entirely offline except for the one segment being re-rolled: read the +plan, generate that segment again with a fresh derived seed, drop it into the +cache under the same key, and stitch the chapter back together from cache +entries. The other segments are never touched, and never re-synthesized. + +Re-rolls are derived, not random, so a repair is reproducible; the attempt +number is remembered in the cache sidecar, so asking twice gives two different +takes rather than the same one again. + +Torch-free like the rest of the package: the engine arrives as a callable, which +is what lets the whole repair path be tested in milliseconds. +""" +from __future__ import annotations + +import json +from dataclasses import asdict, dataclass, field +from pathlib import Path +from typing import Callable, List, Optional, Sequence, Tuple + +import numpy as np +import soundfile as sf + +from . import audio as audio_tools +from . import cache as cache_tools +from . import quality + +__all__ = [ + "PLAN_FILENAME", + "BookPlan", + "PlannedChapter", + "PlannedSegment", + "RepairResult", + "chapter_path", + "flagged_segments", + "inspect_book", + "rebuild_chapter", + "reroll_segment", + "segment_label", +] + +PLAN_FILENAME = "plan.json" +#: Bumped when a plan written by an older version can no longer be read. +PLAN_VERSION = 1 + + +@dataclass(frozen=True) +class PlannedSegment: + text: str + pause_after: float = 0.0 + + +@dataclass(frozen=True) +class PlannedChapter: + index: int + title: str = "" + segments: Tuple[PlannedSegment, ...] = () + + +@dataclass +class BookPlan: + """Everything needed to rebuild a book's audio from its cache. + + The voice is stored as the fields of :class:`~narration.cache.VoiceSpec` + rather than the spec itself, because the cache key is derived from it: a + plan that could not reproduce the exact key would point at nothing. + """ + + voice: dict = field(default_factory=dict) + mastering: dict = field(default_factory=dict) + chapters: Tuple[PlannedChapter, ...] = () + version: int = PLAN_VERSION + + # -- persistence ------------------------------------------------------- + + def to_dict(self) -> dict: + return { + "version": self.version, + "voice": dict(self.voice), + "mastering": dict(self.mastering), + "chapters": [ + { + "index": chapter.index, + "title": chapter.title, + "segments": [asdict(segment) for segment in chapter.segments], + } + for chapter in self.chapters + ], + } + + @classmethod + def from_dict(cls, payload: dict) -> "BookPlan": + version = int(payload.get("version", PLAN_VERSION)) + if version > PLAN_VERSION: + raise ValueError( + f"plan.json is version {version}, this build reads up to {PLAN_VERSION}" + ) + chapters = [] + for entry in payload.get("chapters", []): + chapters.append( + PlannedChapter( + index=int(entry["index"]), + title=entry.get("title", ""), + segments=tuple( + PlannedSegment( + text=segment.get("text", ""), + pause_after=float(segment.get("pause_after", 0.0)), + ) + for segment in entry.get("segments", []) + ), + ) + ) + return cls( + voice=dict(payload.get("voice", {})), + mastering=dict(payload.get("mastering", {})), + chapters=tuple(chapters), + version=version, + ) + + def save(self, outdir: str | Path) -> Path: + target = Path(outdir) / PLAN_FILENAME + target.parent.mkdir(parents=True, exist_ok=True) + # Written whole then moved: a plan truncated by a killed process would + # make every later repair impossible, which is worse than having none. + temporary = target.with_suffix(".json.tmp") + temporary.write_text( + json.dumps(self.to_dict(), ensure_ascii=False, indent=1), encoding="utf-8" + ) + temporary.replace(target) + return target + + @classmethod + def load(cls, outdir: str | Path) -> "BookPlan": + source = Path(outdir) + if source.is_dir(): + source = source / PLAN_FILENAME + if not source.is_file(): + raise FileNotFoundError( + f"No {PLAN_FILENAME} in {Path(outdir)} — that book was narrated " + "before plans were recorded, or by another tool. Re-run the " + "narration to write one; cached segments are reused, so it is cheap." + ) + return cls.from_dict(json.loads(source.read_text(encoding="utf-8"))) + + # -- access ------------------------------------------------------------ + + def voice_spec(self) -> cache_tools.VoiceSpec: + known = {f for f in cache_tools.VoiceSpec.__dataclass_fields__} + return cache_tools.VoiceSpec(**{k: v for k, v in self.voice.items() if k in known}) + + def mastering_settings(self) -> audio_tools.MasteringSettings: + known = {f for f in audio_tools.MasteringSettings.__dataclass_fields__} + return audio_tools.MasteringSettings( + **{k: v for k, v in self.mastering.items() if k in known} + ) + + def chapter(self, index: int) -> PlannedChapter: + for chapter in self.chapters: + if chapter.index == index: + return chapter + raise KeyError(f"No chapter {index} in this plan") + + def segment(self, chapter_index: int, position: int) -> PlannedSegment: + """``position`` is 1-based, matching the labels in a quality report.""" + segments = self.chapter(chapter_index).segments + if not 1 <= position <= len(segments): + raise KeyError( + f"Chapter {chapter_index} has {len(segments)} segment(s), asked for {position}" + ) + return segments[position - 1] + + +def segment_label(chapter_index: int, position: int) -> str: + """The identifier used in quality reports and repair menus.""" + return f"ch{chapter_index:03d}/seg{position:03d}" + + +def parse_label(label: str) -> Tuple[int, int]: + """Inverse of :func:`segment_label`.""" + try: + chapter_part, segment_part = label.split("/") + return int(chapter_part.removeprefix("ch")), int(segment_part.removeprefix("seg")) + except (ValueError, AttributeError) as error: + raise ValueError(f"Not a segment label: {label!r}") from error + + +def chapter_path(outdir: str | Path, index: int) -> Path: + return Path(outdir) / f"chapitre_{index:03d}.wav" + + +# -------------------------------------------------------------------------- +# Inspecting a finished book +# -------------------------------------------------------------------------- + + +def inspect_book( + plan: BookPlan, + cache: cache_tools.ChunkCache, + thresholds: quality.QualityThresholds = quality.QualityThresholds(), +) -> List[Tuple[str, quality.SegmentReport]]: + """Re-run the quality checks over every cached segment of a book. + + Reads the cache rather than the finished chapters, because a defect has to + be located at the segment to be repaired at the segment — and because this + then works on a book narrated before the quality pass existed. + """ + spec = plan.voice_spec() + reports: List[Tuple[str, quality.SegmentReport]] = [] + for chapter in plan.chapters: + for position, segment in enumerate(chapter.segments, 1): + entry = cache.get(cache.key(segment.text, spec)) + if entry is None: + continue + sample_rate, wav = entry + reports.append( + ( + segment_label(chapter.index, position), + quality.inspect_segment(wav, sample_rate, segment.text, thresholds), + ) + ) + return reports + + +def flagged_segments( + reports: Sequence[Tuple[str, quality.SegmentReport]], fatal_only: bool = False +) -> List[Tuple[str, quality.SegmentReport]]: + """The segments worth a human's attention, worst first.""" + picked = [ + (label, report) + for label, report in reports + if (report.fatal if fatal_only else not report.ok) + ] + picked.sort(key=lambda pair: (not pair[1].fatal, pair[0])) + return picked + + +# -------------------------------------------------------------------------- +# Repairing +# -------------------------------------------------------------------------- + + +@dataclass +class RepairResult: + """Outcome of re-rolling one segment.""" + + label: str + sample_rate: int + wav: np.ndarray + report: quality.SegmentReport + seed: Optional[int] + attempt: int + previous: Optional[quality.SegmentReport] = None + + @property + def improved(self) -> bool: + """True when the new take is no worse than the one it replaces.""" + if self.previous is None: + return self.report.ok + return self.report.penalty <= self.previous.penalty + + +def reroll_segment( + plan: BookPlan, + chapter_index: int, + position: int, + cache: cache_tools.ChunkCache, + render: Callable[[Optional[int]], Tuple[int, np.ndarray]], + *, + attempt: Optional[int] = None, + thresholds: quality.QualityThresholds = quality.QualityThresholds(), + keep_worse: bool = False, +) -> RepairResult: + """Generate one segment again and put the new take in the cache. + + The seed is derived from the voice's own seed and an attempt number, so the + same repair always yields the same audio, while asking again yields a + different take. When ``attempt`` is not given it continues from whatever the + cache last recorded. + + A worse take is discarded unless ``keep_worse`` is set: the point of a + repair is to improve the segment, and a re-roll can come back worse than + what it replaces. + """ + segment = plan.segment(chapter_index, position) + spec = plan.voice_spec() + key = cache.key(segment.text, spec) + label = segment_label(chapter_index, position) + + existing = cache.get(key) + previous_report = ( + quality.inspect_segment(existing[1], existing[0], segment.text, thresholds) + if existing is not None + else None + ) + + if attempt is None: + attempt = cache.attempt_of(key) + 1 + seed = quality.retry_seed(spec.seed, attempt, segment.text) + + sample_rate, wav = render(seed) + wav = audio_tools.as_float_mono(wav) + report = quality.inspect_segment(wav, sample_rate, segment.text, thresholds) + + result = RepairResult( + label=label, + sample_rate=sample_rate, + wav=wav, + report=report, + seed=seed, + attempt=attempt, + previous=previous_report, + ) + if result.improved or keep_worse: + cache.put(key, sample_rate, wav, text=segment.text, attempt=attempt) + return result + + +@dataclass +class RebuildResult: + path: Optional[Path] + sample_rate: int + duration_sec: float + missing: Tuple[str, ...] = () + + @property + def ok(self) -> bool: + return self.path is not None and not self.missing + + +def rebuild_chapter( + plan: BookPlan, + chapter_index: int, + cache: cache_tools.ChunkCache, + outdir: str | Path, +) -> RebuildResult: + """Stitch a chapter back together from cached segments and write it. + + Nothing is synthesized here: every segment comes from the cache. A chapter + with a missing segment is reported rather than written, because a silently + shortened chapter is far worse than one that failed to rebuild. + """ + chapter = plan.chapter(chapter_index) + spec = plan.voice_spec() + rendered: List[Tuple[np.ndarray, float]] = [] + sample_rate: Optional[int] = None + missing: List[str] = [] + + for position, segment in enumerate(chapter.segments, 1): + entry = cache.get(cache.key(segment.text, spec)) + if entry is None: + missing.append(segment_label(chapter_index, position)) + continue + sample_rate, wav = entry + rendered.append((wav, segment.pause_after)) + + if missing or not rendered or sample_rate is None: + return RebuildResult(None, sample_rate or 0, 0.0, tuple(missing)) + + audio = audio_tools.stitch(rendered, sample_rate, plan.mastering_settings()) + target = chapter_path(outdir, chapter_index) + target.parent.mkdir(parents=True, exist_ok=True) + sf.write(str(target), audio, sample_rate, subtype="PCM_16") + return RebuildResult(target, sample_rate, len(audio) / float(sample_rate), ()) diff --git a/tests/test_narration_repair.py b/tests/test_narration_repair.py new file mode 100644 index 00000000..896f2d59 --- /dev/null +++ b/tests/test_narration_repair.py @@ -0,0 +1,329 @@ +"""Tests for narration.repair — plan persistence and per-segment repair. + +The engine is a callable, so a full repair cycle — inspect a book, re-roll the +bad segment, restitch its chapter — runs here in milliseconds against synthetic +audio, with no model and no torch. +""" +from __future__ import annotations + +import json + +import numpy as np +import pytest +import soundfile as sf + +from narration import cache as cache_tools +from narration import quality, repair + +SR = 24000 +BASE_SEED = 777 + + +def speech(seconds: float, seed: int = 0) -> np.ndarray: + rng = np.random.default_rng(seed) + samples = max(1, int(SR * seconds)) + syllables = max(2, int(seconds * 6)) + gains = rng.uniform(0.3, 1.0, syllables + 1) + envelope = np.interp(np.linspace(0.0, syllables, samples), np.arange(syllables + 1), gains) + body = (rng.normal(0.0, 1.0, samples) * envelope).astype(np.float32) + body *= 0.2 / max(float(np.max(np.abs(body))), 1e-9) + pad = np.zeros(int(SR * 0.4), dtype=np.float32) + return np.concatenate([pad, body, pad]) + + +def sentence(characters: int) -> str: + return "a" * characters + + +#: 170 characters at the engine's ~20 char/s is about eight seconds. +TEXT_A = sentence(170) +TEXT_B = sentence(170)[:-1] + "b" +TEXT_C = sentence(120) + + +@pytest.fixture +def plan(): + return repair.BookPlan( + voice={ + "description": "voix de test", + "seed": BASE_SEED, + "cfg": 2.0, + "steps": 10, + "normalize": True, + "model_id": "test-model", + }, + mastering={"target_rms_db": -20.0}, + chapters=( + repair.PlannedChapter( + index=1, + title="Chapitre premier", + segments=( + repair.PlannedSegment(TEXT_A, 0.35), + repair.PlannedSegment(TEXT_B, 0.7), + ), + ), + repair.PlannedChapter( + index=2, + title="Chapitre second", + segments=(repair.PlannedSegment(TEXT_C, 0.35),), + ), + ), + ) + + +@pytest.fixture +def cache(tmp_path): + return cache_tools.ChunkCache(tmp_path / ".cache") + + +def fill_cache(plan, cache, *, bad_labels=()): + """Populate the cache as a narration would, optionally with bad takes.""" + spec = plan.voice_spec() + for chapter in plan.chapters: + for position, segment in enumerate(chapter.segments, 1): + label = repair.segment_label(chapter.index, position) + wav = speech(0.3) if label in bad_labels else speech(len(segment.text) / 20.0) + cache.put(cache.key(segment.text, spec), SR, wav, text=segment.text) + + +# -------------------------------------------------------------------------- +# Plan persistence +# -------------------------------------------------------------------------- + + +def test_plan_round_trips_through_disk(plan, tmp_path): + plan.save(tmp_path) + loaded = repair.BookPlan.load(tmp_path) + assert loaded.to_dict() == plan.to_dict() + assert loaded.segment(1, 2).text == TEXT_B + assert loaded.segment(1, 2).pause_after == 0.7 + assert loaded.chapter(2).title == "Chapitre second" + + +def test_plan_reproduces_the_exact_cache_key(plan, cache): + """The whole repair path rests on this: a plan that cannot reproduce the + key points at no audio at all.""" + spec = plan.voice_spec() + fill_cache(plan, cache) + assert cache.get(cache.key(TEXT_A, spec)) is not None + assert spec.seed == BASE_SEED and spec.model_id == "test-model" + + +def test_loading_a_missing_plan_explains_what_to_do(tmp_path): + with pytest.raises(FileNotFoundError, match="Re-run the narration"): + repair.BookPlan.load(tmp_path) + + +def test_a_newer_plan_version_is_refused(tmp_path): + (tmp_path / repair.PLAN_FILENAME).write_text( + json.dumps({"version": repair.PLAN_VERSION + 1, "chapters": []}), encoding="utf-8" + ) + with pytest.raises(ValueError, match="version"): + repair.BookPlan.load(tmp_path) + + +def test_save_is_atomic_leaving_no_temporary(plan, tmp_path): + plan.save(tmp_path) + assert not list(tmp_path.glob("*.tmp")) + + +def test_unknown_chapter_or_segment_is_a_clear_error(plan): + with pytest.raises(KeyError, match="No chapter 9"): + plan.chapter(9) + with pytest.raises(KeyError, match="asked for 5"): + plan.segment(1, 5) + + +def test_labels_round_trip(): + assert repair.segment_label(1, 2) == "ch001/seg002" + assert repair.parse_label("ch001/seg002") == (1, 2) + with pytest.raises(ValueError): + repair.parse_label("pas un label") + + +# -------------------------------------------------------------------------- +# Inspecting a finished book +# -------------------------------------------------------------------------- + + +def test_inspect_book_reports_every_cached_segment(plan, cache): + fill_cache(plan, cache) + reports = repair.inspect_book(plan, cache) + assert [label for label, _ in reports] == ["ch001/seg001", "ch001/seg002", "ch002/seg001"] + assert all(report.ok for _, report in reports) + + +def test_inspect_book_finds_the_bad_segment(plan, cache): + fill_cache(plan, cache, bad_labels={"ch001/seg002"}) + flagged = repair.flagged_segments(repair.inspect_book(plan, cache)) + assert [label for label, _ in flagged] == ["ch001/seg002"] + assert flagged[0][1].fatal + assert "truncated" in flagged[0][1].codes + + +def test_inspect_book_skips_segments_that_are_not_cached(plan, cache): + # Only chapter 2 was ever generated. + spec = plan.voice_spec() + cache.put(cache.key(TEXT_C, spec), SR, speech(6.0), text=TEXT_C) + assert [label for label, _ in repair.inspect_book(plan, cache)] == ["ch002/seg001"] + + +def test_flagged_segments_puts_fatal_defects_first(plan, cache): + fill_cache(plan, cache, bad_labels={"ch002/seg001"}) + reports = repair.inspect_book(plan, cache) + # Force a suspect-only report onto an earlier label. + suspect = quality.SegmentReport( + duration_sec=5.0, characters=100, chars_per_second=20.0, rms_db=-20.0, + peak_db=-6.0, longest_silence_sec=2.0, + issues=(quality.Issue("gap", quality.SUSPECT, "silence"),), + ) + mixed = [("ch001/seg001", suspect)] + reports + assert repair.flagged_segments(mixed)[0][0] == "ch002/seg001" + + +def test_fatal_only_filters_out_suspects(plan, cache): + suspect = quality.SegmentReport( + duration_sec=5.0, characters=100, chars_per_second=20.0, rms_db=-20.0, + peak_db=-6.0, longest_silence_sec=2.0, + issues=(quality.Issue("gap", quality.SUSPECT, "silence"),), + ) + assert repair.flagged_segments([("ch001/seg001", suspect)], fatal_only=True) == [] + + +# -------------------------------------------------------------------------- +# Re-rolling a segment +# -------------------------------------------------------------------------- + + +def test_reroll_replaces_the_cached_take(plan, cache): + fill_cache(plan, cache, bad_labels={"ch001/seg002"}) + spec = plan.voice_spec() + key = cache.key(TEXT_B, spec) + + result = repair.reroll_segment( + plan, 1, 2, cache, lambda seed: (SR, speech(8.5, seed=1)) + ) + assert result.report.ok + assert result.previous is not None and result.previous.fatal + assert result.improved + + sample_rate, stored = cache.get(key) + assert len(stored) / sample_rate > 5.0 # the good take, not the 0.3s one + + +def test_reroll_uses_a_derived_reproducible_seed(plan, cache): + fill_cache(plan, cache) + seen = [] + repair.reroll_segment( + plan, 1, 1, cache, lambda seed: (seen.append(seed), (SR, speech(8.5)))[1], attempt=3 + ) + assert seen == [quality.retry_seed(BASE_SEED, 3, TEXT_A)] + assert seen[0] != BASE_SEED + + +def test_repairing_twice_gives_a_different_take(plan, cache): + fill_cache(plan, cache) + seen = [] + + def render(seed): + seen.append(seed) + return SR, speech(8.5, seed=len(seen)) + + repair.reroll_segment(plan, 1, 1, cache, render) + repair.reroll_segment(plan, 1, 1, cache, render) + # The attempt number is remembered, so the second repair is not the first. + assert seen[0] != seen[1] + + +def test_a_worse_take_is_discarded(plan, cache): + fill_cache(plan, cache) + spec = plan.voice_spec() + key = cache.key(TEXT_A, spec) + before = cache.get(key)[1] + + result = repair.reroll_segment(plan, 1, 1, cache, lambda seed: (SR, speech(0.3))) + assert not result.improved + assert result.report.fatal + # The cache still holds the take that was there. + assert np.array_equal(cache.get(key)[1], before) + + +def test_keep_worse_overrides_the_guard(plan, cache): + fill_cache(plan, cache) + spec = plan.voice_spec() + key = cache.key(TEXT_A, spec) + + repair.reroll_segment( + plan, 1, 1, cache, lambda seed: (SR, speech(0.3)), keep_worse=True + ) + sample_rate, stored = cache.get(key) + assert len(stored) / sample_rate < 2.0 + + +# -------------------------------------------------------------------------- +# Rebuilding a chapter +# -------------------------------------------------------------------------- + + +def test_rebuild_writes_the_chapter_from_cache_only(plan, cache, tmp_path): + fill_cache(plan, cache) + result = repair.rebuild_chapter(plan, 1, cache, tmp_path) + assert result.ok + assert result.path.name == "chapitre_001.wav" + audio, sample_rate = sf.read(str(result.path), dtype="float32") + # Two ~8.5s segments plus the pause and the lead/tail silence. + assert len(audio) / sample_rate > 16.0 + + +def test_rebuild_applies_the_plans_mastering_target(plan, cache, tmp_path): + from narration import audio as audio_tools + + # A target below the material's natural level, so it is reachable: aiming + # louder than the peak ceiling allows would be clamped by design, and would + # test the ceiling rather than whether the plan's setting was read at all. + plan.mastering = {"target_rms_db": -24.0} + fill_cache(plan, cache) + result = repair.rebuild_chapter(plan, 1, cache, tmp_path) + audio, sample_rate = sf.read(str(result.path), dtype="float32") + assert audio_tools.speech_rms_db(audio, sample_rate) == pytest.approx(-24.0, abs=0.5) + + +def test_rebuild_never_breaches_the_peak_ceiling(plan, cache, tmp_path): + from narration import audio as audio_tools + + # -12 dBFS RMS is louder than this material can go without clipping, so the + # gain must be held back to protect the ceiling rather than hit the target. + plan.mastering = {"target_rms_db": -12.0} + fill_cache(plan, cache) + result = repair.rebuild_chapter(plan, 1, cache, tmp_path) + audio, sample_rate = sf.read(str(result.path), dtype="float32") + assert audio_tools.peak_db(audio) <= audio_tools.ACX_PEAK_CEILING_DB + 0.1 + assert audio_tools.speech_rms_db(audio, sample_rate) < -12.0 + + +def test_rebuild_refuses_rather_than_writing_a_short_chapter(plan, cache, tmp_path): + """A silently shortened chapter is worse than one that failed to rebuild.""" + spec = plan.voice_spec() + cache.put(cache.key(TEXT_A, spec), SR, speech(8.5), text=TEXT_A) # only segment 1 + + result = repair.rebuild_chapter(plan, 1, cache, tmp_path) + assert not result.ok + assert result.missing == ("ch001/seg002",) + assert not repair.chapter_path(tmp_path, 1).exists() + + +def test_repair_then_rebuild_is_a_complete_cycle(plan, cache, tmp_path): + """The whole point: fix one segment, get a correct chapter back, and never + re-synthesize the segments that were already fine.""" + fill_cache(plan, cache, bad_labels={"ch001/seg001"}) + assert repair.flagged_segments(repair.inspect_book(plan, cache)) + + calls = [] + repair.reroll_segment( + plan, 1, 1, cache, lambda seed: (calls.append(seed), (SR, speech(8.5, seed=9)))[1] + ) + assert len(calls) == 1 # exactly one segment was generated + + result = repair.rebuild_chapter(plan, 1, cache, tmp_path) + assert result.ok + assert not repair.flagged_segments(repair.inspect_book(plan, cache)) From f35ed2cc0e3af73be03daef88d50e321755e2773 Mon Sep 17 00:00:00 2001 From: PaxHelios Date: Wed, 29 Jul 2026 01:13:03 +0200 Subject: [PATCH 23/98] feat(app): repair a flagged segment from the audiobook tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A "Repair a flagged segment" panel: scan the finished book, pick a flagged segment from the list, re-generate that one, and get its chapter restitched from the cache. Until now the quality report could say which segment was wrong but fixing it meant deleting a hash-named cache file by hand. Narration writes plan.json before generating anything, so an interrupted run is repairable too, and the loop consumes the very segments recorded in the plan rather than re-deriving them — if the two ever disagreed, a repair would address the wrong cache entries. The panel says out loud when a re-roll came back worse and the previous take was kept, since a repair that appears to do nothing is worse than one that reports a miss. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01UqWxj2j9bdavcLn25ckX8X --- app.py | 179 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 176 insertions(+), 3 deletions(-) diff --git a/app.py b/app.py index 78ccf8f0..0bd3c845 100644 --- a/app.py +++ b/app.py @@ -2,6 +2,7 @@ import re import sys import json +import dataclasses import time import logging import random @@ -26,7 +27,7 @@ from narration import assemble as assembly from narration import audio as audio_tools from narration import cache as cache_tools -from narration import chunking, quality, text_fr +from narration import chunking, quality, repair, text_fr logging.basicConfig( level=logging.INFO, @@ -223,6 +224,12 @@ "book_target_rms_info": "Audiobook platforms expect RMS between -23 and -18 dBFS.", "book_pause_sentence_label": "Pause after a sentence (s)", "book_pause_paragraph_label": "Pause after a paragraph (s)", + "book_repair_title": "🔧 Repair a flagged segment", + "book_repair_info": "Re-generate a single defective segment and restitch its chapter " + "from the cache. The other segments are never re-synthesized.", + "book_scan_btn": "🔍 Scan this book for defects", + "book_defect_label": "Segment to repair", + "book_repair_btn": "🔧 Re-generate this segment", "book_qc_label": "Quality re-rolls per segment", "book_qc_info": "A segment that comes back truncated, silent or babbling is generated " "again with a derived seed. 0 only reports the defects.", @@ -286,6 +293,12 @@ "book_target_rms_info": "Les plateformes de livres audio attendent un RMS entre -23 et -18 dBFS.", "book_pause_sentence_label": "Pause après une phrase (s)", "book_pause_paragraph_label": "Pause après un paragraphe (s)", + "book_repair_title": "🔧 Réparer un segment signalé", + "book_repair_info": "Régénère un seul segment défectueux et reconstruit son chapitre " + "à partir du cache. Les autres segments ne sont jamais recalculés.", + "book_scan_btn": "🔍 Analyser ce livre", + "book_defect_label": "Segment à réparer", + "book_repair_btn": "🔧 Régénérer ce segment", "book_qc_label": "Réessais qualité par segment", "book_qc_info": "Un segment qui revient tronqué, muet ou parti en boucle est régénéré " "avec une graine dérivée. 0 se contente de signaler les défauts.", @@ -315,6 +328,12 @@ "preset_lang_label": "🌐 语言", "preset_voices_label": "🎭 预设旁白语音", "preset_voices_info": "选择一个语音以自动填充描述和随机种子。", + "book_repair_title": "🔧 Repair a flagged segment", + "book_repair_info": "Re-generate a single defective segment and restitch its chapter " + "from the cache. The other segments are never re-synthesized.", + "book_scan_btn": "🔍 Scan this book for defects", + "book_defect_label": "Segment to repair", + "book_repair_btn": "🔧 Re-generate this segment", "preview_btn_label": "🔊 试听该语音", "chunking_label": "拆分长文本(有声书)", "chunking_info": "自动将长文本按句子拆分并拼接音频。", @@ -901,6 +920,14 @@ def _preview_voice(description, seed_value, cfg, steps, normalize, preset_name=N # ---------- Audiobook tab ---------- + def _chapter_title(chapter: str, index: int) -> str: + """First non-empty line of a chapter, used as its marker title.""" + for line in chapter.splitlines(): + stripped = line.strip().lstrip("#").strip() + if stripped: + return stripped[:80] + return f"Chapitre {index}" + def _book_dir(title: str) -> Path: """Where a book's chapters and its resume cache live.""" return _BOOKS_DIR / f"book_{_sanitize_filename(title or 'livre')}" @@ -1001,6 +1028,29 @@ def _book_narrate( ) cache = cache_tools.ChunkCache(outdir / ".cache") + # The plan is what makes a later repair possible: without it the cut + # into segments — and so which cache entry holds which sentence — is + # lost the moment this run ends. Written before any audio, so an + # interrupted narration is still repairable. + chapter_plans = [ + repair.PlannedChapter( + index=index, + title=_chapter_title(chapter, index), + segments=tuple( + repair.PlannedSegment(segment.text, segment.pause_after) + for segment in chunking.split_into_segments( + chapter, int(chunk_max_chars_value), profile + ) + ), + ) + for index, chapter in enumerate(chapters, 1) + ] + repair.BookPlan( + voice=dataclasses.asdict(voice_spec), + mastering=dataclasses.asdict(mastering), + chapters=tuple(chapter_plans), + ).save(outdir) + voice_label = preset_name if preset_name and preset_name != PRESET_CUSTOM_LABEL else "voix personnalisée" lines = [ f"### Narration en cours\n", @@ -1011,7 +1061,8 @@ def _book_narrate( qc_inspected = 0 yield "\n".join(lines), None - for index, chapter in enumerate(chapters, 1): + for planned in chapter_plans: + index = planned.index out = outdir / f"chapitre_{index:03d}.wav" if out.is_file(): lines.append(f"- ⏭️ Chapitre {index}/{len(chapters)} — déjà généré, ignoré") @@ -1019,7 +1070,9 @@ def _book_narrate( yield "\n".join(lines), last_chapter_path continue - segments = chunking.split_into_segments(chapter, int(chunk_max_chars_value), profile) + # The very segments recorded in the plan, so a repair addresses the + # same cache entries this run wrote. + segments = planned.segments if not segments: lines.append(f"- ⚠️ Chapitre {index}/{len(chapters)} — vide, ignoré") yield "\n".join(lines), last_chapter_path @@ -1095,6 +1148,98 @@ def render(current_seed, _segment=segment): lines.append(f"\nChapitres dans `{outdir}` — utilisez « Assembler » pour un fichier unique.") yield "\n".join(lines), last_chapter_path + def _book_scan_defects(title): + """List the segments of a finished book that the quality pass flags.""" + outdir = _book_dir(title) + try: + plan = repair.BookPlan.load(outdir) + except (FileNotFoundError, ValueError) as error: + raise gr.Error(str(error)) + + cache = cache_tools.ChunkCache(outdir / ".cache") + flagged = repair.flagged_segments(repair.inspect_book(plan, cache)) + if not flagged: + return ( + gr.update(choices=[], value=None), + "**Contrôle qualité :** aucun segment à réparer dans ce livre.", + None, + ) + + choices = [ + f"{label} — {', '.join(issue.code for issue in report.issues)}" + for label, report in flagged + ] + rows = "\n".join( + f"| `{label}` | {'❌ fatal' if report.severity == quality.FATAL else '⚠️ suspect'} " + f"| {report.duration_sec:.1f}s | {', '.join(i.detail for i in report.issues)} |" + for label, report in flagged + ) + return ( + gr.update(choices=choices, value=choices[0]), + f"**{len(flagged)} segment(s) signalé(s)**\n\n" + "| Segment | Gravité | Durée | Détail |\n|---|---|---|---|\n" + rows, + None, + ) + + def _book_repair_segment(title, choice, qc_retries): + """Re-roll one flagged segment and restitch only its chapter.""" + if not choice: + raise gr.Error("Choisissez d'abord un segment à réparer.") + + outdir = _book_dir(title) + try: + plan = repair.BookPlan.load(outdir) + chapter_index, position = repair.parse_label(choice.split(" — ")[0]) + except (FileNotFoundError, ValueError) as error: + raise gr.Error(str(error)) + + cache = cache_tools.ChunkCache(outdir / ".cache") + spec = plan.voice_spec() + segment = plan.segment(chapter_index, position) + + def render(seed): + sample_rate, wav_out, _ = demo.generate_tts_audio( + text_input=segment.text, + control_instruction=spec.description, + cfg_value_input=spec.cfg, + do_normalize=spec.normalize, + inference_timesteps=int(spec.steps), + seed=seed, + ) + return sample_rate, wav_out + + gr.Info(f"Régénération de {choice.split(' — ')[0]} — une seule génération, pas le chapitre.") + result = repair.reroll_segment(plan, chapter_index, position, cache, render) + + lines = [ + f"### Réparation de `{result.label}` (essai {result.attempt}, graine `{result.seed}`)\n", + f"- Texte : « {segment.text[:120]}{'…' if len(segment.text) > 120 else ''} »", + f"- Avant : {result.previous.describe() if result.previous else '(rien en cache)'}", + f"- Après : {result.report.describe()}", + ] + if not result.improved: + # Kept the old take on purpose; say so, or the user would think the + # repair silently did nothing. + lines.append( + "\n**Le nouvel essai est moins bon : l'ancien est conservé.** " + "Relancez pour tirer une autre version." + ) + return "\n".join(lines), None + + rebuilt = repair.rebuild_chapter(plan, chapter_index, cache, outdir) + if not rebuilt.ok: + lines.append( + f"\n⚠️ Chapitre non reconstruit : segment(s) absent(s) du cache — " + f"{', '.join(rebuilt.missing)}. Relancez la narration pour les régénérer." + ) + return "\n".join(lines), None + + lines.append( + f"\n✅ Chapitre {chapter_index} reconstruit à partir du cache " + f"({rebuilt.duration_sec / 60:.1f} min) → `{rebuilt.path.name}`" + ) + return "\n".join(lines), str(rebuilt.path) + def _book_assemble(title, author, output_format): """Join the generated chapters into one chaptered file.""" outdir = _book_dir(title) @@ -1383,6 +1528,18 @@ def _run_asr_if_needed(checked, audio_path): scale=1, ) book_assemble_btn = gr.Button(I18N("book_assemble_btn"), scale=2) + + with gr.Accordion(I18N("book_repair_title"), open=False): + gr.Markdown(I18N("book_repair_info")) + book_scan_btn = gr.Button(I18N("book_scan_btn"), size="sm") + book_defects = gr.Markdown(value="") + book_defect_choice = gr.Dropdown( + choices=[], value=None, label=I18N("book_defect_label") + ) + book_repair_btn = gr.Button( + I18N("book_repair_btn"), variant="primary", size="sm" + ) + book_repair_status = gr.Markdown(value="") book_output_file = gr.File(label=I18N("book_file_output_label")) show_prompt_text.change( @@ -1536,6 +1693,22 @@ def _run_asr_if_needed(checked, audio_path): api_name="preview_book_voice", ) + book_scan_btn.click( + fn=_book_scan_defects, + inputs=[book_title], + outputs=[book_defect_choice, book_defects, book_repair_status], + show_progress=True, + api_name="scan_book_defects", + ) + + book_repair_btn.click( + fn=_book_repair_segment, + inputs=[book_title, book_defect_choice, book_qc_retries], + outputs=[book_repair_status, book_audio], + show_progress=True, + api_name="repair_book_segment", + ) + book_assemble_btn.click( fn=_book_assemble, inputs=[book_title, book_author, book_format], From 76a6a08ab7e8b183e5f6585095bcb0a406e17007 Mon Sep 17 00:00:00 2001 From: PaxHelios Date: Wed, 29 Jul 2026 01:22:15 +0200 Subject: [PATCH 24/98] fix(app): level voice previews so they can be heard and compared MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previews were raw engine output, and raw output lands anywhere between -32 and -17 dBFS depending on the voice. Two consequences, both bad. The quietest presets are barely audible on laptop speakers — "Narratrice douce & naturelle" sat at -31.9 dBFS, 14.6 dB below the loudest, which is what a user hit when a preview appeared to play but produced no sound. And auditioning at unequal levels is not auditioning: loudness is heard as quality, so the comparison the previews exist for was rigged. They now get the same treatment as a chapter — trimmed, de-clicked and levelled to the narration target — with shorter edge silences so playback starts at once. A preview is therefore heard at the volume the finished book will have. pregenerate_previews.py gains --remaster, which re-levels the previews already on disk without loading the model: levelling is a post-process, so fourteen voices take seconds instead of the five minutes each that regenerating would cost. Applied to all fourteen — the spread across the set falls from 14.6 dB to 0.8 dB, the remainder being voices whose gain is held back by the peak ceiling. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01UqWxj2j9bdavcLn25ckX8X --- app.py | 14 ++++++++++++++ scripts/pregenerate_previews.py | 34 +++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/app.py b/app.py index 0bd3c845..ad801e87 100644 --- a/app.py +++ b/app.py @@ -493,6 +493,19 @@ def _voice_names_for_lang(lang: Optional[str]) -> List[str]: _PREVIEW_TEXT = "Bonjour, ceci est un aperçu de cette voix pour la narration de votre livre audio." _PREVIEW_DIR = Path(__file__).parent / "assets" / "voice_previews" +# Previews are mastered like a chapter, for two reasons. Raw generations land +# anywhere between -32 and -17 dBFS depending on the voice — a 15 dB spread, wide +# enough that the quietest presets are barely audible on laptop speakers. And +# comparing voices at different levels is not a comparison: the louder one always +# sounds better. Shorter edge silences than a chapter, so a preview starts +# playing at once. +_PREVIEW_MASTERING = audio_tools.MasteringSettings(lead_sec=0.1, tail_sec=0.2) + + +def master_preview(sample_rate: int, wav): + """Trim, de-click and level a preview to the narration target.""" + return audio_tools.stitch([(wav, 0.0)], sample_rate, _PREVIEW_MASTERING) + # Every generation is also archived here with a descriptive filename. _OUTPUT_DIR = Path(__file__).parent / "output" @@ -909,6 +922,7 @@ def _preview_voice(description, seed_value, cfg, steps, normalize, preset_name=N inference_timesteps=int(steps), seed=seed, ) + wav_np = master_preview(sr, wav_np) if cache_path is not None: try: import soundfile as sf diff --git a/scripts/pregenerate_previews.py b/scripts/pregenerate_previews.py index c4019982..81f1ca47 100644 --- a/scripts/pregenerate_previews.py +++ b/scripts/pregenerate_previews.py @@ -23,14 +23,47 @@ import app # noqa: E402 +def remaster_existing() -> int: + """Bring already-generated previews to the same level, without the model. + + Raw generations span some 15 dB depending on the voice, which makes the + quietest presets hard to hear and makes auditioning unfair — level is heard + as quality. Levelling is a post-process, so this costs seconds rather than + the minutes per voice that regenerating would. + """ + from narration import audio as audio_tools # noqa: PLC0415 - only needed here + + changed = 0 + for voice in app.PRESET_VOICES: + path = app._PREVIEW_DIR / f"preview_{voice['seed']}.wav" + if not path.is_file(): + print(f"seed={voice['seed']}: no preview yet, skipping", flush=True) + continue + wav, sample_rate = sf.read(str(path), dtype="float32") + before = audio_tools.speech_rms_db(wav, sample_rate) + mastered = app.master_preview(sample_rate, wav) + after = audio_tools.speech_rms_db(mastered, sample_rate) + sf.write(str(path), mastered, sample_rate) + changed += 1 + print(f"seed={voice['seed']}: {before:6.1f} -> {after:6.1f} dBFS {voice['name']}", flush=True) + print(f"Re-levelled {changed} preview(s).", flush=True) + return 0 + + def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--device", default="cpu", help="auto, cpu, mps, cuda, or cuda:N (default: cpu)") parser.add_argument("--model-id", default="openbmb/VoxCPM2", help="Model path or HF repo id") parser.add_argument("--force", action="store_true", help="Regenerate even if the preview already exists") + parser.add_argument("--remaster", action="store_true", + help="Re-level existing previews in place and exit — no model, no generation") args = parser.parse_args() app._PREVIEW_DIR.mkdir(parents=True, exist_ok=True) + + if args.remaster: + return remaster_existing() + demo = app.VoxCPMDemo(model_id=args.model_id, device=args.device, load_denoiser=False) total = len(app.PRESET_VOICES) @@ -50,6 +83,7 @@ def main() -> int: inference_timesteps=int(voice.get("diffusion_steps", 10)), seed=seed, ) + wav = app.master_preview(sr, wav) sf.write(str(out), wav, sr) print(f"{tag}: saved -> {out.name} ({len(wav) / sr:.2f}s)", flush=True) From 20b68d5ecf3240a2b0162386a711322ff46c4cb8 Mon Sep 17 00:00:00 2001 From: PaxHelios Date: Fri, 31 Jul 2026 12:37:06 +0200 Subject: [PATCH 25/98] feat(narration): narrate a book straight from its .epub MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A .txt with `---` between chapters was the only way in. An EPUB already carries what that file has to be written by hand — reading order, chapter titles, the book's title and author — so it is read directly, in the UI and from narrate_book.py alike. Reading order comes from the spine rather than the file names, titles from the book's own table of contents (EPUB 3 nav or EPUB 2 NCX), and DRM is refused with a reason instead of narrated as noise. The part that is not obvious: a file is not a chapter. Books converted from one HTML source are cut into fixed-size files that start and end mid-chapter, so `Autour de la Lune` arrives as six 60,000-character blocks rather than its twenty-five chapters. Files holding several chapters are cut at their headings, and a file's opening fragment rejoins the chapter it continues. Which heading level marks a chapter cannot be decided inside one file — a lone

above repeated

is a packed file, and equally a chapter above its scenes — so it is decided by which level opens the documents across the whole book. Verified against two real books of differing structure: Autour de la Lune (25 chapters, 1859 segments) and Les trois mousquetaires (72 chapters). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Fity58qgKttpD1nLheWrzy --- app.py | 66 +++- docs/GUIDE_FR.md | 16 +- docs/NARRATION.md | 44 ++- narration/__init__.py | 12 +- narration/epub.py | 722 +++++++++++++++++++++++++++++++++++ scripts/narrate_book.py | 30 +- tests/test_narration_epub.py | 521 +++++++++++++++++++++++++ 7 files changed, 1388 insertions(+), 23 deletions(-) create mode 100644 narration/epub.py create mode 100644 tests/test_narration_epub.py diff --git a/app.py b/app.py index ad801e87..0ea52922 100644 --- a/app.py +++ b/app.py @@ -27,6 +27,7 @@ from narration import assemble as assembly from narration import audio as audio_tools from narration import cache as cache_tools +from narration import epub as epub_reader from narration import chunking, quality, repair, text_fr logging.basicConfig( @@ -199,7 +200,7 @@ "chunking_info": "Automatically split long texts into sentence chunks and stitch the audio together.", "chunk_size_label": "Max characters per chunk", "chunk_size_info": "Target size of each chunk when splitting long texts (whole sentences are kept together).", - "load_txt_label": "📄 Load a .txt file", + "load_txt_label": "📄 Load a .txt or .epub file", "prepare_text_label": "Prepare French text", "prepare_text_info": "Read numbers, abbreviations and Roman numerals as a narrator would (1789, M. Dupont, XIVe).", "master_label": "Audiobook mastering", @@ -207,7 +208,10 @@ "tab_studio": "🎙️ Studio", "tab_book": "📚 Audiobook", "book_intro": _BOOK_INTRO_EN, - "book_file_label": "📄 Load the book (.txt)", + "book_file_label": "📄 Load the book (.txt or .epub)", + "book_epub_loaded": "**{chapters} chapter(s) imported** from « {title} »{author}. " + "Check the text below — front matter and a publisher's table of " + "contents are imported like anything else, and are yours to delete.", "book_text_label": "Book text — separate chapters with a line containing only ---", "book_title_label": "Book title", "book_author_label": "Author / narrator", @@ -268,7 +272,7 @@ "chunking_info": "Découpe automatiquement les longs textes en segments de phrases et assemble l'audio.", "chunk_size_label": "Caractères max par segment", "chunk_size_info": "Taille cible de chaque segment lors du découpage (les phrases entières restent groupées).", - "load_txt_label": "📄 Charger un fichier .txt", + "load_txt_label": "📄 Charger un fichier .txt ou .epub", "prepare_text_label": "Préparation du texte français", "prepare_text_info": "Fait lire les nombres, abréviations et chiffres romains comme un narrateur (1789, M. Dupont, XIVe).", "master_label": "Mastering livre audio", @@ -276,7 +280,11 @@ "tab_studio": "🎙️ Studio", "tab_book": "📚 Livre audio", "book_intro": _BOOK_INTRO_FR, - "book_file_label": "📄 Charger le livre (.txt)", + "book_file_label": "📄 Charger le livre (.txt ou .epub)", + "book_epub_loaded": "**{chapters} chapitre(s) importé(s)** depuis « {title} »{author}. " + "Vérifiez le texte ci-dessous : les pages de garde et une table des " + "matières éditoriale sont importées comme le reste, à vous de les " + "supprimer.", "book_text_label": "Texte du livre — séparez les chapitres par une ligne contenant seulement ---", "book_title_label": "Titre du livre", "book_author_label": "Auteur / narrateur", @@ -803,18 +811,56 @@ def _on_preset_change(preset_name): gr.update(value=preset["normalize"]), # DoNormalizeText ) + def _read_uploaded_text(file_path: str) -> Tuple[str, Optional[epub_reader.EpubBook]]: + """Text of an uploaded .txt or .epub, plus the book when there was one.""" + if epub_reader.is_epub(file_path): + text, book = epub_reader.load_book_text(file_path) + logger.info( + f"Loaded EPUB '{book.title}' — {len(book.chapters)} chapter(s), " + f"{book.characters} chars from {file_path}" + ) + return text, book + return Path(file_path).read_text(encoding="utf-8").strip(), None + def _load_text_file(file_path: Optional[str]) -> str: - """Read a .txt file and return its contents to fill the target text box.""" + """Read a .txt or .epub file and return its text to fill a text box.""" if not file_path: return gr.update() try: - content = Path(file_path).read_text(encoding="utf-8").strip() + content, _ = _read_uploaded_text(file_path) logger.info(f"Loaded text file ({len(content)} chars) from {file_path}") return content except Exception as e: logger.warning(f"Could not read text file {file_path}: {e}") raise gr.Error(f"Impossible de lire le fichier : {e}") + def _load_book_file(file_path: Optional[str]): + """Fill the book tab from an upload — text, and an EPUB's own metadata. + + Title and author are only overwritten when the file carries them, so an + EPUB with empty metadata never wipes what the user typed. + """ + if not file_path: + return gr.update(), gr.update(), gr.update(), gr.update() + try: + content, book = _read_uploaded_text(file_path) + except Exception as e: + logger.warning(f"Could not read book file {file_path}: {e}") + raise gr.Error(f"Impossible de lire le fichier : {e}") + + if book is None: + return content, gr.update(), gr.update(), gr.update() + return ( + content, + book.title or gr.update(), + book.author or gr.update(), + I18N("book_epub_loaded").format( + chapters=len(book.chapters), + title=book.title or Path(file_path).stem, + author=f" — {book.author}" if book.author else "", + ), + ) + def _generate( text: str, control_instruction: str, @@ -1369,7 +1415,7 @@ def _run_asr_if_needed(checked, audio_path): ) load_txt_btn = gr.UploadButton( I18N("load_txt_label"), - file_types=[".txt"], + file_types=[".txt", ".epub"], size="sm", ) @@ -1455,7 +1501,7 @@ def _run_asr_if_needed(checked, audio_path): with gr.Row(): with gr.Column(): book_upload = gr.UploadButton( - I18N("book_file_label"), file_types=[".txt"], size="sm" + I18N("book_file_label"), file_types=[".txt", ".epub"], size="sm" ) book_text = gr.Textbox( value="", @@ -1645,9 +1691,9 @@ def _run_asr_if_needed(checked, audio_path): ) book_upload.upload( - fn=_load_text_file, + fn=_load_book_file, inputs=[book_upload], - outputs=[book_text], + outputs=[book_text, book_title, book_author, book_status], ) book_plan_btn.click( diff --git a/docs/GUIDE_FR.md b/docs/GUIDE_FR.md index 95c5604c..6a539df6 100644 --- a/docs/GUIDE_FR.md +++ b/docs/GUIDE_FR.md @@ -95,10 +95,10 @@ et le débit. Fonctionne en français, anglais, chinois… Exemples : ## Livres audio Ce fork ajoute une chaîne de production complète pour la narration longue en -français : préparation du texte (nombres, abréviations, chiffres romains lus -correctement), découpage avec pauses selon la ponctuation, mastering aux normes -des plateformes de livres audio, reprise après interruption au segment près, et -assemblage en M4B/MP3 avec marqueurs de chapitres. +français : import d'un `.txt` ou d'un `.epub`, préparation du texte (nombres, +abréviations, chiffres romains lus correctement), découpage avec pauses selon la +ponctuation, mastering aux normes des plateformes de livres audio, reprise après +interruption au segment près, et assemblage en M4B/MP3 avec marqueurs de chapitres. Trois points d'entrée : @@ -106,16 +106,16 @@ Trois points d'entrée : # Onglet « 📚 Livre audio » de la démo Gradio python app.py --port 8808 --no-denoiser -# Narrer un livre entier en ligne de commande -python scripts/narrate_book.py livre.txt --voice "Narrateur profond & calme" --assemble m4b +# Narrer un livre entier en ligne de commande (.txt ou .epub) +python scripts/narrate_book.py livre.epub --voice "Narrateur profond & calme" --assemble m4b # Assembler des chapitres déjà générés python scripts/assemble_audiobook.py output/book_mon_livre --title "Mon Livre" --check ``` **→ Le guide détaillé est dans [docs/NARRATION.md](NARRATION.md)** : vitesse selon le -matériel, réglages par usage (fiction, documentaire, méditation, podcast), lexique de -prononciation personnalisé, et normes de sonie. +matériel, import EPUB, réglages par usage (fiction, documentaire, méditation, +podcast), lexique de prononciation personnalisé, et normes de sonie. ## API REST diff --git a/docs/NARRATION.md b/docs/NARRATION.md index ccd16af0..54cb6353 100644 --- a/docs/NARRATION.md +++ b/docs/NARRATION.md @@ -23,6 +23,7 @@ chacune dans un module de `narration/` — testable et utilisable indépendammen | Étape | Module | Ce qu'elle fait | |---|---|---| +| **0. Lecture** | `narration/epub.py` | Lit un `.epub` dans l'ordre du *spine* et en tire des chapitres titrés — un `.txt` se découpe lui sur les lignes `---` | | **1. Préparation** | `narration/text_fr.py` | Réécrit le texte tel qu'un narrateur le dirait : `1789` → « mille sept cent quatre-vingt-neuf », `M. Dupont` → « Monsieur Dupont », `XIVe siècle` → « quatorzième siècle », `14h30`, `1 250 €`, `3,5 %`… | | **2. Découpage** | `narration/chunking.py` | Coupe en segments sous la limite du moteur, **sans jamais couper une phrase**, et décide la durée du silence après chaque segment selon la ponctuation | | **3. Synthèse** | moteur VoxCPM2 | Même seed partout → voix identique du début à la fin | @@ -71,7 +72,8 @@ lancer l'app (ou export sous bash). `assets/voice_previews/`. C'est cette liste qui détermine la voix du livre. Pour une voix sur mesure, laisse-la sur **« Personnalisé / manuel »** et décris la voix dans l'onglet **🎙️ Studio**. -2. Charge ton `.txt` ou colle le texte. +2. Charge ton `.txt` **ou ton `.epub`**, ou colle le texte. Un EPUB remplit aussi + le titre et l'auteur, qui serviront de métadonnées au fichier assemblé. 3. Clique **« 🔍 Analyser sans générer »** : tu vois le nombre de chapitres, de segments, la durée estimée, et **le premier segment tel qu'il sera réellement lu** (après préparation du texte). C'est le moment de repérer un nombre ou une @@ -118,6 +120,46 @@ Deux options utiles dans les **Réglages avancés** : - **Préparation du texte français** — applique l'étape 1 de la chaîne. - **Mastering livre audio** — applique l'étape 4 (activé par défaut). +## Partir d'un EPUB + +Un `.epub` se charge directement, dans l'onglet **📚 Livre audio** comme en ligne de +commande : + +``` +.\.venv\Scripts\python.exe scripts\narrate_book.py livre.epub --voice "Narrateur profond & calme" --dry-run +``` + +Ce qui en est tiré : + +- **L'ordre de lecture vient du *spine***, jamais du nom des fichiers — sinon le + chapitre 10 passerait avant le 2. +- **Les titres viennent de la table des matières du livre** (nav EPUB 3 ou NCX + EPUB 2), à défaut du premier titre du document. Ce sont eux qui deviennent les + marqueurs de chapitres du M4B. +- **Les fichiers contenant plusieurs chapitres sont recoupés sur leurs titres.** + Beaucoup de livres — ceux du projet Gutenberg notamment — sont découpés en + fichiers de taille fixe : sans ce recoupage, *Autour de la Lune* donnerait + 6 énormes chapitres au lieu de ses 25 vrais. `--no-epub-split` désactive. +- **Les pages de garde sont écartées** en dessous de `--epub-min-chars` + caractères (140 par défaut) — une couverture n'est pas un chapitre. +- **Un EPUB protégé par DRM est refusé** avec un message clair, plutôt que narré + en bruit binaire. + +Trois limites à connaître : + +- Une **table des matières éditoriale** présente dans le corps du livre est + importée comme le reste du texte. Elle apparaît dans le plan avant génération : + supprime-la de la zone de texte. +- Un livre **entièrement contenu dans un seul fichier** reste un seul chapitre : + avec un seul document, rien ne permet de distinguer un titre de livre au-dessus + de ses chapitres d'un chapitre au-dessus de ses scènes. Insère des `---` pour + découper toi-même. +- Un livre **scanné** (images seules, sans texte) est refusé : il n'y a rien à + lire. Il faut passer par une reconnaissance de caractères d'abord. + +Le texte importé reste **modifiable dans la zone de texte** avant génération : ce +qui est narré est ce que tu y vois, `---` compris. + ## Reprise après interruption C'est le point critique sur CPU, où un chapitre prend des heures. diff --git a/narration/__init__.py b/narration/__init__.py index cff98ae8..d44ee236 100644 --- a/narration/__init__.py +++ b/narration/__init__.py @@ -8,6 +8,7 @@ Stages, in pipeline order:: + epub read an .epub into the plain chapters everything else expects text_fr prepare raw French prose for a TTS engine chunking cut prepared text into engine-sized segments + pause plan cache content-addressed store so an interrupted run resumes per chunk @@ -17,4 +18,13 @@ assemble join chapters into a single MP3/M4B with chapter markers """ -__all__ = ["assemble", "audio", "cache", "chunking", "quality", "repair", "text_fr"] +__all__ = [ + "assemble", + "audio", + "cache", + "chunking", + "epub", + "quality", + "repair", + "text_fr", +] diff --git a/narration/epub.py b/narration/epub.py new file mode 100644 index 00000000..4128f2a7 --- /dev/null +++ b/narration/epub.py @@ -0,0 +1,722 @@ +"""Read an .epub and hand the pipeline the same thing a .txt would. + +An EPUB is a ZIP holding XHTML documents, an OPF manifest that lists them and a +spine that puts them in reading order. Everything downstream of this module — +segmentation, French normalisation, synthesis, assembly — already works on plain +chapters separated by ``---``, so the whole job here is to turn a book into that +text and then get out of the way. Nothing is written to disk and nothing is +extracted: entries are read from the archive by name, so a crafted path in a +manifest cannot escape anywhere. + +Four things earn their complexity: + +* **Reading order comes from the spine, not from the file names.** Sorting the + XHTML files alphabetically puts chapter 10 before chapter 2 and scatters the + front matter, which is exactly the kind of error you only notice six hours + into a narration. +* **Chapter titles are looked up in the table of contents** (the EPUB 3 nav + document, or the EPUB 2 NCX) before falling back to the first heading in the + document. Many books style their headings with a plain ``
``, so the + heading is not always there — but the TOC nearly always is, and its titles are + what a listener expects to see as chapter markers. +* **A file is not a chapter.** Books converted from a single HTML source — every + Project Gutenberg book — are cut into fixed-size files that begin and end + mid-chapter. Left alone, *Autour de la Lune* is six 60,000-character blocks + instead of its twenty-five chapters, so files holding several chapters are cut + at their headings and a file's opening fragment rejoins the chapter it + continues. See :func:`_cut_level` for how the chapter heading level is found. +* **DRM is detected and refused up front.** An encrypted EPUB parses fine and + yields binary noise; failing early with a clear reason beats narrating that. + +Covers and half-titles are usually a handful of characters and would each become +their own one-line chapter, so ``min_chars`` drops the documents too short to be +worth a chapter of their own. +""" +from __future__ import annotations + +import posixpath +import re +import zipfile +from dataclasses import dataclass, replace +from html.parser import HTMLParser +from pathlib import Path +from typing import Dict, List, Optional, Sequence, Tuple +from urllib.parse import unquote +from xml.etree import ElementTree as ET + +__all__ = [ + "DEFAULT_MIN_CHARS", + "EpubBook", + "EpubChapter", + "EpubError", + "is_epub", + "load_book_text", + "read_epub", + "summarize", + "to_book_text", +] + +#: Below this many characters a spine document is front matter (cover, colophon, +#: half-title), not a chapter. Low enough to keep a genuinely short prologue. +DEFAULT_MIN_CHARS = 140 + +_CONTAINER_PATH = "META-INF/container.xml" +_ENCRYPTION_PATH = "META-INF/encryption.xml" + +# Tags whose boundaries are paragraph boundaries in the narration sense. `br` is +# handled apart: it breaks a line without ending a paragraph. +_BLOCK_TAGS = frozenset( + { + "address", "article", "aside", "blockquote", "dd", "div", "dl", "dt", + "figcaption", "figure", "footer", "h1", "h2", "h3", "h4", "h5", "h6", + "header", "hr", "li", "main", "nav", "ol", "p", "pre", "section", + "table", "td", "th", "tr", "ul", + } +) +_SKIPPED_TAGS = frozenset({"head", "script", "style", "svg", "template"}) +_HEADING_TAGS = frozenset({"h1", "h2", "h3", "h4", "h5", "h6"}) + +# Zero-width marks and BOMs survive `\s`, so a collapse leaves them glued +# inside a word on its way to the engine. Non-breaking spaces need no case of +# their own: the collapse turns them into ordinary ones, which is what a +# narrator reads anyway. +_INVISIBLE_RE = re.compile("[\u200b\u200c\u200d\ufeff]") +_HORIZONTAL_SPACE_RE = re.compile(r"[^\S\n]+") +_BLANK_LINES_RE = re.compile(r"\n{3,}") +# A separator line in the assembled text would be read as a chapter break, so +# any the book itself contains has to stop looking like one. +_CHAPTER_SEPARATOR_RE = re.compile(r"(?m)^\s*---\s*$") + + +class EpubError(ValueError): + """The file is not an EPUB we can read, and the reason is worth showing.""" + + +@dataclass(frozen=True) +class EpubChapter: + """One spine document, as narratable text.""" + + title: str + text: str + #: Path of the source document inside the archive, for diagnostics. + href: str = "" + #: Whether the title came from the book (TOC or heading) rather than from + #: the file name. An invented title makes a fine marker but must not be + #: read aloud at the top of the chapter. + titled: bool = True + + @property + def characters(self) -> int: + return len(self.text) + + +@dataclass(frozen=True) +class EpubBook: + """A book, in reading order.""" + + title: str + author: str + chapters: List[EpubChapter] + #: Documents dropped as front matter, kept so the caller can say so. + skipped: List[str] + + @property + def characters(self) -> int: + return sum(chapter.characters for chapter in self.chapters) + + +class _TextExtractor(HTMLParser): + """XHTML to plain text, keeping paragraph breaks and where the headings are. + + Written against ``html.parser`` rather than an XML parser on purpose: EPUB + content is nominally XHTML but real books ship unclosed tags and stray + entities, and a strict parse would reject a book over a typo in its + copyright page. + """ + + def __init__(self) -> None: + super().__init__(convert_charrefs=True) + self._parts: List[str] = [] + self._skip_depth = 0 + self._heading_parts: List[str] = [] + self._heading_level = 0 + self._heading_start = 0 + #: (level, title, index in ``_parts`` where the heading opens). + self.headings: List[Tuple[int, str, int]] = [] + + def handle_starttag(self, tag: str, attrs) -> None: + tag = tag.lower() + if tag in _SKIPPED_TAGS: + self._skip_depth += 1 + return + if self._skip_depth: + return + if tag == "br": + self._parts.append("\n") + return + if tag in _BLOCK_TAGS: + self._parts.append("\n\n") + if tag in _HEADING_TAGS and not self._heading_level: + self._heading_level = int(tag[1]) + self._heading_parts = [] + self._heading_start = len(self._parts) + + def handle_startendtag(self, tag: str, attrs) -> None: + if tag.lower() == "br" and not self._skip_depth: + self._parts.append("\n") + + def handle_endtag(self, tag: str) -> None: + tag = tag.lower() + if tag in _SKIPPED_TAGS: + self._skip_depth = max(0, self._skip_depth - 1) + return + if self._skip_depth: + return + if tag in _HEADING_TAGS and self._heading_level == int(tag[1]): + title = _collapse(" ".join(self._heading_parts)) + if title: + self.headings.append((self._heading_level, title, self._heading_start)) + self._heading_level = 0 + if tag in _BLOCK_TAGS: + self._parts.append("\n\n") + + def handle_data(self, data: str) -> None: + if self._skip_depth or not data: + return + self._parts.append(data) + if self._heading_level: + self._heading_parts.append(data) + + @property + def text(self) -> str: + return _normalize_whitespace("".join(self._parts)) + + @property + def heading(self) -> str: + return self.headings[0][1] if self.headings else "" + + def sections(self, level: int) -> List[Tuple[str, str]]: + """Cut this document at headings of ``level``, or return nothing. + + Returning nothing means "this document is one chapter" — which is the + case whenever it holds a single heading of the cut level and opens on + it, the ordinary one-file-per-chapter layout. + + Text before the first cut becomes an untitled opening section rather + than being dropped: it is either front matter or, in books cut into + fixed-size files, the tail of the chapter running into this one. + """ + cuts = [(title, start) for lv, title, start in self.headings if lv == level] + if not cuts: + return [] + preamble = _normalize_whitespace("".join(self._parts[: cuts[0][1]])) + if len(cuts) == 1 and not preamble: + return [] + + sections: List[Tuple[str, str]] = [] + if preamble: + sections.append(("", preamble)) + for position, (title, start) in enumerate(cuts): + end = cuts[position + 1][1] if position + 1 < len(cuts) else len(self._parts) + text = _normalize_whitespace("".join(self._parts[start:end])) + if text: + sections.append((title, text)) + return sections + + +class _LinkCollector(HTMLParser): + """Every ```` in a document, in order, with its visible text. + + Enough to read an EPUB 3 nav document: its table of contents is a nested + list of links, and the nesting only carries depth, which chapter markers do + not use. + """ + + def __init__(self) -> None: + super().__init__(convert_charrefs=True) + self.links: List[Tuple[str, str]] = [] + self._href: Optional[str] = None + self._parts: List[str] = [] + + def handle_starttag(self, tag: str, attrs) -> None: + if tag.lower() != "a": + return + href = dict(attrs).get("href") + if href: + self._href = href + self._parts = [] + + def handle_endtag(self, tag: str) -> None: + if tag.lower() == "a" and self._href is not None: + self.links.append((self._href, _collapse(" ".join(self._parts)))) + self._href = None + self._parts = [] + + def handle_data(self, data: str) -> None: + if self._href is not None: + self._parts.append(data) + + +def _collapse(text: str) -> str: + """One line, single-spaced.""" + return " ".join((text or "").split()) + + +def _normalize_whitespace(text: str) -> str: + """Single-spaced lines, blank line between paragraphs, nothing else.""" + text = (text or "").replace("\r\n", "\n").replace("\r", "\n") + # Zero-width spaces and BOMs survive `\s` and would end up glued inside + # a word sent to the engine. Non-breaking spaces need no special case: + # the collapse below turns them into ordinary ones, which is what a + # narrator reads anyway. + text = _INVISIBLE_RE.sub("", text) + text = _HORIZONTAL_SPACE_RE.sub(" ", text) + lines = [line.strip() for line in text.split("\n")] + text = "\n".join(lines) + return _BLANK_LINES_RE.sub("\n\n", text).strip() + + +def _local(tag: str) -> str: + """Local name of a possibly namespaced XML tag.""" + return tag.rsplit("}", 1)[-1].lower() + + +def _iter_local(root: ET.Element, name: str): + """Descendants whose local name matches, namespace whatever it may be. + + Case-insensitive on both sides: NCX spells its elements ``navPoint`` while + OPF spells everything in lower case. + """ + name = name.lower() + for element in root.iter(): + if _local(element.tag) == name: + yield element + + +def _attr(element: ET.Element, name: str) -> str: + """Attribute by local name — ``epub:type`` and ``type`` read the same.""" + name = name.lower() + for key, value in element.attrib.items(): + if _local(key) == name: + return value + return "" + + +def _resolve(base: str, href: str) -> str: + """Archive path of ``href`` written relative to the document at ``base``.""" + href = unquote((href or "").split("#", 1)[0].strip()) + if not href: + return "" + directory = posixpath.dirname(base) + joined = posixpath.join(directory, href) if directory else href + return posixpath.normpath(joined).lstrip("/") + + +def is_epub(path) -> bool: + """Whether this path looks like an EPUB, by extension.""" + return Path(path).suffix.lower() == ".epub" + + +def _read(archive: zipfile.ZipFile, name: str) -> bytes: + try: + return archive.read(name) + except KeyError as error: + raise EpubError(f"Missing from the archive: {name}") from error + + +def _parse_xml(data: bytes, what: str) -> ET.Element: + try: + return ET.fromstring(data) + except ET.ParseError as error: + raise EpubError(f"Malformed {what}: {error}") from error + + +def _opf_path(archive: zipfile.ZipFile) -> str: + """Where the manifest lives, per ``META-INF/container.xml``.""" + root = _parse_xml(_read(archive, _CONTAINER_PATH), "container.xml") + for rootfile in _iter_local(root, "rootfile"): + full_path = _attr(rootfile, "full-path") + if full_path: + return unquote(full_path).lstrip("/") + raise EpubError("container.xml names no OPF file") + + +def _metadata(opf: ET.Element) -> Tuple[str, str]: + """Title and author from the Dublin Core metadata, blank when absent.""" + title = author = "" + for element in _iter_local(opf, "title"): + title = _collapse(element.text or "") + if title: + break + for element in _iter_local(opf, "creator"): + author = _collapse(element.text or "") + if author: + break + return title, author + + +def _manifest(opf: ET.Element, opf_path: str) -> Dict[str, Dict[str, str]]: + """Manifest items by id, with archive paths already resolved.""" + items: Dict[str, Dict[str, str]] = {} + for item in _iter_local(opf, "item"): + item_id = _attr(item, "id") + href = _attr(item, "href") + if not item_id or not href: + continue + items[item_id] = { + "path": _resolve(opf_path, href), + "media_type": _attr(item, "media-type").lower(), + "properties": _attr(item, "properties").lower(), + } + return items + + +def _spine_ids(opf: ET.Element) -> List[str]: + """Reading order: the idrefs of the spine, linear items only. + + ``linear="no"`` marks material reachable from the text but outside its flow + — notes, ads, pop-up figures. Narrating it would interleave footnotes with + the prose. + """ + order: List[str] = [] + for spine in _iter_local(opf, "spine"): + for itemref in _iter_local(spine, "itemref"): + idref = _attr(itemref, "idref") + if idref and _attr(itemref, "linear").lower() != "no": + order.append(idref) + break + return order + + +def _toc_from_nav(archive: zipfile.ZipFile, nav_path: str) -> Dict[str, str]: + """Chapter titles by document path, read from an EPUB 3 nav document.""" + try: + data = archive.read(nav_path) + except KeyError: + return {} + collector = _LinkCollector() + try: + collector.feed(data.decode("utf-8", errors="replace")) + except Exception: # a broken TOC costs titles, never the book + return {} + titles: Dict[str, str] = {} + for href, label in collector.links: + target = _resolve(nav_path, href) + if target and label and target not in titles: + titles[target] = label + return titles + + +def _toc_from_ncx(archive: zipfile.ZipFile, ncx_path: str) -> Dict[str, str]: + """Chapter titles by document path, read from an EPUB 2 NCX.""" + try: + root = _parse_xml(archive.read(ncx_path), "NCX") + except (KeyError, EpubError): + return {} + titles: Dict[str, str] = {} + for nav_point in _iter_local(root, "navPoint"): + label = "" + for text_element in _iter_local(nav_point, "text"): + label = _collapse(text_element.text or "") + if label: + break + source = "" + for content in _iter_local(nav_point, "content"): + source = _attr(content, "src") + if source: + break + target = _resolve(ncx_path, source) + if target and label and target not in titles: + titles[target] = label + return titles + + +def _table_of_contents( + archive: zipfile.ZipFile, + opf: ET.Element, + manifest: Dict[str, Dict[str, str]], +) -> Tuple[Dict[str, str], str]: + """Merged TOC titles, and the path of the TOC document itself. + + The nav document is part of the spine in many EPUB 3 books; returning its + path lets the caller drop it rather than narrate a list of chapter names. + """ + titles: Dict[str, str] = {} + nav_path = "" + for item in manifest.values(): + if "nav" in item["properties"].split(): + nav_path = item["path"] + titles.update(_toc_from_nav(archive, nav_path)) + break + + for spine in _iter_local(opf, "spine"): + toc_id = _attr(spine, "toc") + if toc_id and toc_id in manifest: + for path, label in _toc_from_ncx(archive, manifest[toc_id]["path"]).items(): + titles.setdefault(path, label) + break + + return titles, nav_path + + +def _document(archive: zipfile.ZipFile, path: str) -> Optional[_TextExtractor]: + """Parse one spine document, or ``None`` if it cannot be read.""" + try: + data = archive.read(path) + except KeyError: + return None + extractor = _TextExtractor() + try: + extractor.feed(data.decode("utf-8", errors="replace")) + extractor.close() + except Exception: # one unparsable document must not lose the other forty + return None + return extractor + + +def _cut_level(documents: Sequence[_TextExtractor]) -> int: + """The heading level that marks chapters, decided across the whole book. + + Within one document the question is unanswerable: a lone ``

`` above + repeated ``

`` is a file holding twenty chapters, and it is equally one + chapter subdivided into scenes. What tells them apart is which level *opens* + the documents — that is the level at which the book was cut into files. + + One file per chapter: every document opens on its ``

``, so ``

`` is + the chapter level and the ``

`` scene headings inside are left alone. + A book packed into fixed-size files: most documents open straight on an + ``

`` chapter heading, with the ``

`` of the title page appearing in + one file only, so ``

`` wins and the packed chapters come apart. + + Zero means nothing to split on. A book that is a single document keeps + whatever level opens it, so a one-file book stays one chapter — genuinely + ambiguous, and the same thing a ``.txt`` without separators does. + """ + openers: Dict[int, int] = {} + with_headings = 0 + for document in documents: + if not document.headings: + continue + with_headings += 1 + level = document.headings[0][0] + openers[level] = openers.get(level, 0) + 1 + if not openers: + return 0 + for level in sorted(openers): + if openers[level] * 2 >= with_headings: + return level + return min(openers) + + +def _merge_short_sections( + sections: Sequence[Tuple[str, str]], floor: int +) -> List[Tuple[str, str]]: + """Fold sections too short to stand alone into the one before them. + + A heading with two lines under it is a section break, not a chapter. Folding + keeps its words — dropping them would silently lose text from the book. + """ + merged: List[Tuple[str, str]] = [] + for title, text in sections: + if merged and len(text) < floor: + previous_title, previous_text = merged[-1] + body = f"{title}\n\n{text}" if title else text + merged[-1] = (previous_title, f"{previous_text}\n\n{body}") + else: + merged.append((title, text)) + return merged + + +def _text_after_title(text: str, title: str) -> Optional[str]: + """What follows the title when ``text`` opens with it, ignoring whitespace. + + Markup routinely breaks a heading across lines — a chapter number above its + name — so the words match while the layout does not. Comparing whitespace- + insensitively finds the title anyway, and returning the remainder lets the + caller re-lay it as a single line. + """ + title_index = text_index = 0 + while title_index < len(title) and text_index < len(text): + if title[title_index].isspace(): + title_index += 1 + elif text[text_index].isspace(): + text_index += 1 + elif title[title_index].casefold() != text[text_index].casefold(): + return None + else: + title_index += 1 + text_index += 1 + if title[title_index:].strip(): + return None + return text[text_index:].lstrip() + + +def _fallback_title(path: str, index: int) -> str: + """A readable name when neither the TOC nor a heading gives one.""" + stem = posixpath.basename(path).rsplit(".", 1)[0] + stem = _collapse(stem.replace("_", " ").replace("-", " ")) + return stem or f"Chapitre {index}" + + +def read_epub( + path, + *, + min_chars: int = DEFAULT_MIN_CHARS, + split_on_headings: bool = True, +) -> EpubBook: + """Read an EPUB into chapters, in reading order. + + ``split_on_headings`` cuts a spine document that holds several chapters at + its headings; turn it off to keep one chapter per file exactly as the book + packages them. + + Raises :class:`EpubError` when the file is not a readable EPUB — a wrong + extension, a corrupt archive, DRM, or a manifest that lists no text. + """ + file_path = Path(path) + if not file_path.is_file(): + raise EpubError(f"No such file: {file_path}") + + try: + archive = zipfile.ZipFile(file_path) + except zipfile.BadZipFile as error: + raise EpubError("Not a readable EPUB: the file is not a valid ZIP archive") from error + + with archive: + if _ENCRYPTION_PATH in archive.namelist(): + raise EpubError( + "This EPUB is protected by DRM; its text cannot be read. " + "Export or convert it to .txt first." + ) + + opf_path = _opf_path(archive) + opf = _parse_xml(_read(archive, opf_path), "OPF manifest") + title, author = _metadata(opf) + manifest = _manifest(opf, opf_path) + toc_titles, nav_path = _table_of_contents(archive, opf, manifest) + + documents: List[Tuple[str, _TextExtractor]] = [] + for idref in _spine_ids(opf): + item = manifest.get(idref) + if item is None: + continue + document_path = item["path"] + if document_path == nav_path: + continue + if item["media_type"] and "html" not in item["media_type"]: + continue + + extractor = _document(archive, document_path) + if extractor is not None and extractor.text: + documents.append((document_path, extractor)) + + # Front matter is dropped by length — but a book made entirely of short + # documents is a short book, not an empty one, so the floor is only applied + # while something survives it. + floor = max(1, min_chars) + kept = [entry for entry in documents if len(entry[1].text) >= floor] + skipped = [path for path, doc in documents if len(doc.text) < floor] if kept else [] + if not kept: + kept = documents + + level = _cut_level([doc for _, doc in kept]) if split_on_headings else 0 + + chapters: List[EpubChapter] = [] + for document_path, extractor in kept: + text, heading = extractor.text, extractor.heading + sections = extractor.sections(level) if level else [] + split = bool(sections) + pieces = ( + _merge_short_sections(sections, floor) + if split + # Without a split the document's own heading titles it; with one, + # that heading belongs to the first section, not to what precedes it. + else [(toc_titles.get(document_path) or heading, text)] + ) + # A heading shallower than the cut level, standing before the first cut, + # titles what precedes it — a title page above the chapters it opens. + opening_title = ( + heading if split and extractor.headings[0][0] < level else "" + ) + + for position, (piece_title, piece_text) in enumerate(pieces): + if position == 0 and not piece_title: + piece_title = opening_title + + # An untitled opening section in a split document is the tail of the + # chapter that was already running — books cut into fixed-size files + # start mid-chapter — so it continues it instead of pretending to be + # a chapter of its own. Only the very first document has nothing to + # continue, and there the section really is front matter. + if split and position == 0 and not piece_title and chapters: + previous = chapters[-1] + chapters[-1] = replace( + previous, text=f"{previous.text}\n\n{piece_text}".strip() + ) + continue + + chapter_title = piece_title or toc_titles.get(document_path) + chapters.append( + EpubChapter( + title=chapter_title + or _fallback_title(document_path, len(chapters) + 1), + text=piece_text, + href=document_path, + titled=bool(chapter_title), + ) + ) + + if not chapters: + raise EpubError( + "No readable text found in this EPUB — it may be a scanned book " + "(images only) or use a structure we cannot read." + ) + + return EpubBook(title=title, author=author, chapters=chapters, skipped=skipped) + + +def to_book_text(book: EpubBook) -> str: + """The ``---``-separated text the rest of the pipeline already narrates. + + Each chapter starts with its title on exactly one line, because that first + line is what becomes the chapter marker downstream — and because a narrator + does announce the chapter. When the text already opens with the title it is + re-laid onto that single line rather than repeated, so a heading markup + broke in two ("XIV" above its name) still yields a whole marker. A title we + invented from a file name is never read aloud. + """ + blocks: List[str] = [] + for chapter in book.chapters: + text = _CHAPTER_SEPARATOR_RE.sub("* * *", chapter.text).strip() + title = _collapse(chapter.title) if chapter.titled else "" + if title: + remainder = _text_after_title(text, title) + text = f"{title}\n\n{text if remainder is None else remainder}".strip() + blocks.append(text) + return "\n\n---\n\n".join(blocks) + + +def load_book_text( + path, + *, + min_chars: int = DEFAULT_MIN_CHARS, + split_on_headings: bool = True, +) -> Tuple[str, EpubBook]: + """Read an EPUB straight to narratable text, keeping the book for its metadata.""" + book = read_epub(path, min_chars=min_chars, split_on_headings=split_on_headings) + return to_book_text(book), book + + +def summarize(book: EpubBook) -> str: + """One line per chapter, for a plan the user reads before six hours of CPU.""" + lines = [ + f"« {book.title} »" + (f" — {book.author}" if book.author else "") + if book.title + else (book.author or "Sans titre"), + f"{len(book.chapters)} chapitre(s) · {book.characters} caractères", + ] + for index, chapter in enumerate(book.chapters, 1): + lines.append(f" {index:>3}. {chapter.title} ({chapter.characters} car.)") + if book.skipped: + lines.append(f" ({len(book.skipped)} document(s) trop court(s) ignoré(s))") + return "\n".join(lines) diff --git a/scripts/narrate_book.py b/scripts/narrate_book.py index 76a28b9e..f8894a6e 100644 --- a/scripts/narrate_book.py +++ b/scripts/narrate_book.py @@ -6,6 +6,9 @@ The pipeline ------------ +0. **Read** — a ``.txt`` splits into chapters on lines containing only ``---``; + an ``.epub`` is read in spine order, chapters and their titles taken from the + book's own table of contents (``--no-epub-split``, ``--epub-min-chars``). 1. **Prepare** — the text goes through the French normalizer, so ``1789``, ``M. Dupont``, ``XIVe siècle`` and ``14h30`` are read as a narrator would say them (``--no-text-prep`` to disable, ``--lexicon`` for your own proper nouns). @@ -37,6 +40,9 @@ # Custom voice (description + seed): ./.venv/Scripts/python.exe scripts/narrate_book.py livre.txt --description "Voix ..." --seed 123 + # Straight from an EPUB (chapters and titles come from the book): + ./.venv/Scripts/python.exe scripts/narrate_book.py livre.epub --voice "Narrateur profond & calme" --dry-run + # On a CUDA GPU (far faster): ./.venv/Scripts/python.exe scripts/narrate_book.py livre.txt --voice "..." --device cuda """ @@ -58,7 +64,7 @@ from narration import assemble as assembly # noqa: E402 from narration import audio as audio_tools # noqa: E402 from narration import cache as cache_tools # noqa: E402 -from narration import chunking, quality, text_fr # noqa: E402 +from narration import chunking, epub, quality, text_fr # noqa: E402 #: Rough characters-per-second of finished narration, used only to estimate how #: long a book will run before committing hours of CPU to it. @@ -89,7 +95,7 @@ def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter ) - parser.add_argument("input", help="Path to the .txt file to narrate") + parser.add_argument("input", help="Path to the .txt or .epub file to narrate") voice = parser.add_argument_group("voix") voice.add_argument("--voice", help="Preset voice name (see conf/preset_voices.json)") @@ -105,6 +111,12 @@ def build_parser() -> argparse.ArgumentParser: help="Pronunciation lexicon JSON (default: conf/pronunciation_fr.json)") text.add_argument("--no-normalize", action="store_true", help="Disable the engine's own text normalization") text.add_argument("--chapter-regex", help="Regex (MULTILINE) that separates chapters (default: '^---$')") + text.add_argument("--epub-min-chars", type=int, default=epub.DEFAULT_MIN_CHARS, + help="EPUB: below this many characters a document is front matter, " + f"not a chapter (default: {epub.DEFAULT_MIN_CHARS})") + text.add_argument("--no-epub-split", action="store_true", + help="EPUB: keep one chapter per file instead of cutting files that " + "hold several chapters at their headings") text.add_argument("--chunk-max-chars", type=int, default=chunking.DEFAULT_MAX_CHARS, help=f"Max characters per segment (default: {chunking.DEFAULT_MAX_CHARS})") @@ -159,7 +171,19 @@ def main() -> int: in_path = Path(args.input) if not in_path.is_file(): raise SystemExit(f"Input file not found: {in_path}") - raw_text = in_path.read_text(encoding="utf-8").strip() + if epub.is_epub(in_path): + try: + raw_text, book = epub.load_book_text( + in_path, + min_chars=args.epub_min_chars, + split_on_headings=not args.no_epub_split, + ) + except epub.EpubError as error: + raise SystemExit(str(error)) + print(epub.summarize(book)) + print() + else: + raw_text = in_path.read_text(encoding="utf-8").strip() if not raw_text: raise SystemExit(f"Input file is empty: {in_path}") diff --git a/tests/test_narration_epub.py b/tests/test_narration_epub.py new file mode 100644 index 00000000..65ea453b --- /dev/null +++ b/tests/test_narration_epub.py @@ -0,0 +1,521 @@ +"""Tests for EPUB import. + +The fixtures build real .epub archives rather than mocking ``zipfile``: the +whole point of this module is that it copes with how books are actually laid +out, so the tests exercise the two structures in the wild (EPUB 3 with a nav +document, EPUB 2 with an NCX) plus the malformed cases that must fail loudly. +""" +import zipfile + +import pytest + +from narration import epub + + +CONTAINER = """ + + + + +""" + + +def document(body: str, title: str = "") -> str: + """A minimal XHTML content document.""" + return ( + '\n' + '' + f"{title}" + f"{body}" + ) + + +def build_epub( + path, + documents, + *, + title="Le Livre", + author="Une Autrice", + nav=None, + ncx=None, + spine_extra="", + encrypted=False, + prefix="OEBPS/", +): + """Write a working .epub made of ``documents`` — a list of (name, xhtml).""" + opf_path = f"{prefix}content.opf" + manifest_items = [] + spine_items = [] + for index, (name, _) in enumerate(documents, 1): + manifest_items.append( + f'' + ) + spine_items.append(f'') + if nav is not None: + manifest_items.append( + '' + ) + spine_items.insert(0, '') + if ncx is not None: + manifest_items.append( + '' + ) + + opf = ( + '\n' + '' + '' + f"{title}{author}" + "" + f"{''.join(manifest_items)}" + f'' + f"{''.join(spine_items)}{spine_extra}" + "" + ) + + with zipfile.ZipFile(path, "w") as archive: + archive.writestr("mimetype", "application/epub+zip") + archive.writestr("META-INF/container.xml", CONTAINER.format(opf=opf_path)) + if encrypted: + archive.writestr("META-INF/encryption.xml", "") + archive.writestr(opf_path, opf) + for name, content in documents: + archive.writestr(f"{prefix}{name}", content) + if nav is not None: + archive.writestr(f"{prefix}nav.xhtml", nav) + if ncx is not None: + archive.writestr(f"{prefix}toc.ncx", ncx) + return path + + +LONG = "Il faisait un temps splendide sur la ville endormie, et personne ne bougeait. " * 3 + + +@pytest.fixture +def simple_book(tmp_path): + return build_epub( + tmp_path / "livre.epub", + [ + ("ch1.xhtml", document(f"

Premier chapitre

{LONG}

")), + ("ch2.xhtml", document(f"

Deuxième chapitre

{LONG}

")), + ], + ) + + +class TestReadEpub: + def test_reads_metadata_and_chapters(self, simple_book): + book = epub.read_epub(simple_book) + assert book.title == "Le Livre" + assert book.author == "Une Autrice" + assert len(book.chapters) == 2 + assert book.characters > 0 + + def test_titles_come_from_the_headings(self, simple_book): + book = epub.read_epub(simple_book) + assert [c.title for c in book.chapters] == ["Premier chapitre", "Deuxième chapitre"] + + def test_markup_becomes_paragraphs(self, tmp_path): + path = build_epub( + tmp_path / "p.epub", + [("ch1.xhtml", document(f"

{LONG}

Deuxième paragraphe entier.

"))], + ) + text = epub.read_epub(path).chapters[0].text + assert "\n\n" in text + assert "Deuxième paragraphe entier." in text + # Inline styling must not survive, nor leave its tags in the prose. + assert "<" not in text + + def test_style_and_script_are_not_narrated(self, tmp_path): + body = f"

{LONG}

" + path = build_epub(tmp_path / "s.epub", [("ch1.xhtml", document(body))]) + assert "NEPASLIRE" not in epub.read_epub(path).chapters[0].text + + def test_entities_are_decoded(self, tmp_path): + body = f"

{LONG}

L’hôte & l’invité.

" + path = build_epub(tmp_path / "e.epub", [("ch1.xhtml", document(body))]) + text = epub.read_epub(path).chapters[0].text + assert "hôte & l" in text + assert "&" not in text.replace("hôte & l", "") + + def test_br_breaks_a_line_without_ending_the_paragraph(self, tmp_path): + body = f"

{LONG}

Premier vers
Second vers

" + path = build_epub(tmp_path / "br.epub", [("ch1.xhtml", document(body))]) + text = epub.read_epub(path).chapters[0].text + assert "Premier vers\nSecond vers" in text + + +class TestReadingOrder: + def test_order_is_the_spine_not_the_file_names(self, tmp_path): + """Chapter 10 must not be narrated before chapter 2.""" + documents = [ + ("ch10.xhtml", document(f"

Chapitre dix

{LONG}

")), + ("ch2.xhtml", document(f"

Chapitre deux

{LONG}

")), + ] + path = build_epub(tmp_path / "order.epub", documents) + book = epub.read_epub(path) + assert [c.title for c in book.chapters] == ["Chapitre dix", "Chapitre deux"] + + def test_non_linear_items_are_left_out(self, tmp_path): + documents = [ + ("ch1.xhtml", document(f"

Le chapitre

{LONG}

")), + ("notes.xhtml", document(f"

Notes

{LONG}

")), + ] + path = build_epub(tmp_path / "nl.epub", documents) + # Rewrite the spine so the notes are marked as outside the reading flow. + with zipfile.ZipFile(path) as archive: + entries = {name: archive.read(name) for name in archive.namelist()} + opf = entries["OEBPS/content.opf"].decode() + entries["OEBPS/content.opf"] = opf.replace( + '', '' + ).encode() + with zipfile.ZipFile(path, "w") as archive: + for name, data in entries.items(): + archive.writestr(name, data) + + book = epub.read_epub(path) + assert [c.title for c in book.chapters] == ["Le chapitre"] + + +class TestTableOfContents: + def test_nav_supplies_titles_when_headings_do_not(self, tmp_path): + nav = document( + '
" + ) + documents = [ + ("ch1.xhtml", document(f"
Titre stylé

{LONG}

")), + ("ch2.xhtml", document(f"
Autre

{LONG}

")), + ] + path = build_epub(tmp_path / "nav.epub", documents, nav=nav) + book = epub.read_epub(path) + assert [c.title for c in book.chapters] == ["Le manuscrit trouvé", "La traversée"] + + def test_the_nav_document_is_not_narrated(self, tmp_path): + nav = document('') + path = build_epub( + tmp_path / "nav2.epub", + [("ch1.xhtml", document(f"

{LONG}

"))], + nav=nav, + ) + book = epub.read_epub(path) + assert len(book.chapters) == 1 + assert "nav.xhtml" not in book.chapters[0].href + + def test_ncx_titles_are_read_for_epub2_books(self, tmp_path): + ncx = ( + '' + '' + 'Ouverture' + '' + "" + ) + path = build_epub( + tmp_path / "ncx.epub", + [("ch1.xhtml", document(f"
Titre stylé

{LONG}

"))], + ncx=ncx, + ) + assert epub.read_epub(path).chapters[0].title == "Ouverture" + + def test_a_broken_toc_costs_titles_not_the_book(self, tmp_path): + path = build_epub( + tmp_path / "badtoc.epub", + [("ch1.xhtml", document(f"
x

{LONG}

"))], + ncx=" unclosed", + ) + book = epub.read_epub(path) + assert len(book.chapters) == 1 + + def test_titles_survive_percent_encoded_hrefs(self, tmp_path): + nav = document('') + path = build_epub( + tmp_path / "enc.epub", + [("ch 1.xhtml", document(f"
x

{LONG}

"))], + nav=nav, + ) + assert epub.read_epub(path).chapters[0].title == "Le titre" + + def test_nested_content_directories_resolve(self, tmp_path): + """Hrefs are relative to the document that writes them, not to the root.""" + nav = document('') + path = build_epub( + tmp_path / "deep.epub", + [("ch1.xhtml", document(f"
x

{LONG}

"))], + nav=nav, + prefix="EPUB/text/", + ) + book = epub.read_epub(path) + assert book.chapters[0].title == "Le titre" + assert book.chapters[0].href == "EPUB/text/ch1.xhtml" + + +class TestHeadingSplit: + """Books packed several chapters to a file — Gutenberg's layout.""" + + def test_a_file_holding_several_chapters_becomes_several_chapters(self, tmp_path): + body = ( + f"

I Le départ

{LONG}

" + f"

II La traversée

{LONG}

" + f"

III Le retour

{LONG}

" + ) + path = build_epub(tmp_path / "packed.epub", [("all.xhtml", document(body))]) + book = epub.read_epub(path) + assert [c.title for c in book.chapters] == [ + "I Le départ", + "II La traversée", + "III Le retour", + ] + + def test_scene_headings_do_not_split_a_chapter(self, tmp_path): + """One file per chapter: the chapter level repeats across the book. + + Each file holds a single ``

`` and several ``

`` scene headings. + Counted per file the ``

`` looks unique and the ``

`` looks like + the chapter level; counted across the book the ``

`` is the one that + repeats, and each file stays one chapter. + """ + chapter = ( + "

{title}

" + f"

{LONG}

Première scène

{LONG}

" + f"

Seconde scène

{LONG}

" + ) + documents = [ + ("ch1.xhtml", document(chapter.format(title="Le départ"))), + ("ch2.xhtml", document(chapter.format(title="La traversée"))), + ] + path = build_epub(tmp_path / "scenes.epub", documents) + book = epub.read_epub(path) + assert [c.title for c in book.chapters] == ["Le départ", "La traversée"] + assert "Première scène" in book.chapters[0].text + + def test_a_title_page_does_not_prevent_the_split(self, tmp_path): + """The book title in the first file must not decide the cut level. + + The shape Project Gutenberg produces: a title page carrying the only + ``

``, then fixed-size files that open straight on chapter headings. + """ + documents = [ + ( + "f1.xhtml", + document( + f"

Le titre du livre

{LONG}

" + f"

I Le départ

{LONG}

" + ), + ), + ("f2.xhtml", document(f"

II La traversée

{LONG}

")), + ("f3.xhtml", document(f"

III Le retour

{LONG}

")), + ("f4.xhtml", document(f"

IV L'arrivée

{LONG}

")), + ] + path = build_epub(tmp_path / "titled.epub", documents) + titles = [c.title for c in epub.read_epub(path).chapters] + assert titles[-4:] == [ + "I Le départ", + "II La traversée", + "III Le retour", + "IV L'arrivée", + ] + # The title page keeps its own chapter rather than opening chapter one. + assert titles[0] == "Le titre du livre" + + def test_a_one_file_book_is_left_whole(self, tmp_path): + """Known limit, pinned deliberately. + + With a single document there is nothing to compare it against, so a + title above repeated subheadings is read as one chapter with sections — + the same thing a .txt without separators does. Splitting it is the + user's call, by inserting `---`. + """ + body = ( + "

Le titre du livre

" + f"

I Le départ

{LONG}

" + f"

II La traversée

{LONG}

" + ) + path = build_epub(tmp_path / "onefile.epub", [("all.xhtml", document(body))]) + book = epub.read_epub(path) + assert len(book.chapters) == 1 + assert "II La traversée" in book.chapters[0].text + + def test_the_split_can_be_turned_off(self, tmp_path): + body = f"

I Le départ

{LONG}

II La traversée

{LONG}

" + path = build_epub(tmp_path / "off.epub", [("all.xhtml", document(body))]) + assert len(epub.read_epub(path, split_on_headings=False).chapters) == 1 + + def test_text_before_the_first_heading_continues_the_previous_chapter(self, tmp_path): + """A file that starts mid-chapter must not open a chapter of its own.""" + documents = [ + ("f1.xhtml", document(f"

I Le départ

{LONG}

")), + ( + "f2.xhtml", + document(f"

SUITE DU PREMIER.

II La traversée

{LONG}

"), + ), + ] + path = build_epub(tmp_path / "midchapter.epub", documents) + book = epub.read_epub(path) + assert [c.title for c in book.chapters] == ["I Le départ", "II La traversée"] + assert "SUITE DU PREMIER." in book.chapters[0].text + + def test_front_matter_of_the_first_file_stays_its_own_chapter(self, tmp_path): + """With nothing to continue, an opening section is front matter.""" + body = ( + f"

{LONG}

" # title page, before any heading + f"

I Le départ

{LONG}

" + f"

II La traversée

{LONG}

" + ) + path = build_epub(tmp_path / "front.epub", [("all.xhtml", document(body))]) + book = epub.read_epub(path) + assert len(book.chapters) == 3 + assert book.chapters[0].titled is False + + def test_a_section_too_short_to_stand_alone_is_folded_in(self, tmp_path): + body = ( + f"

I Le départ

{LONG}

" + "

Interlude

Trois mots.

" + f"

II La traversée

{LONG}

" + ) + path = build_epub(tmp_path / "fold.epub", [("all.xhtml", document(body))]) + book = epub.read_epub(path) + assert [c.title for c in book.chapters] == ["I Le départ", "II La traversée"] + # Folded, not dropped: the words are still there to be narrated. + assert "Trois mots." in book.chapters[0].text + assert "Interlude" in book.chapters[0].text + + +class TestFrontMatter: + def test_short_documents_are_dropped(self, tmp_path): + documents = [ + ("cover.xhtml", document("

Couverture

")), + ("ch1.xhtml", document(f"

Chapitre

{LONG}

")), + ] + path = build_epub(tmp_path / "fm.epub", documents) + book = epub.read_epub(path) + assert len(book.chapters) == 1 + assert book.skipped == ["OEBPS/cover.xhtml"] + + def test_a_short_book_is_still_a_book(self, tmp_path): + """Dropping front matter must not empty a book that is simply short.""" + path = build_epub( + tmp_path / "short.epub", + [("ch1.xhtml", document("

Un

Très court.

"))], + ) + book = epub.read_epub(path) + assert len(book.chapters) == 1 + assert "Très court." in book.chapters[0].text + + def test_the_floor_is_adjustable(self, tmp_path): + documents = [ + ("cover.xhtml", document("

Couverture

")), + ("ch1.xhtml", document(f"

Chapitre

{LONG}

")), + ] + path = build_epub(tmp_path / "floor.epub", documents) + assert len(epub.read_epub(path, min_chars=1).chapters) == 2 + + +class TestFailures: + def test_missing_file(self, tmp_path): + with pytest.raises(epub.EpubError, match="No such file"): + epub.read_epub(tmp_path / "absent.epub") + + def test_not_a_zip(self, tmp_path): + path = tmp_path / "fake.epub" + path.write_text("ceci n'est pas une archive", encoding="utf-8") + with pytest.raises(epub.EpubError, match="ZIP"): + epub.read_epub(path) + + def test_drm_is_refused_with_a_reason(self, tmp_path): + path = build_epub( + tmp_path / "drm.epub", + [("ch1.xhtml", document(f"

{LONG}

"))], + encrypted=True, + ) + with pytest.raises(epub.EpubError, match="DRM"): + epub.read_epub(path) + + def test_a_book_with_no_text_says_so(self, tmp_path): + path = build_epub(tmp_path / "scan.epub", [("ch1.xhtml", document(""))]) + with pytest.raises(epub.EpubError, match="No readable text"): + epub.read_epub(path) + + def test_container_without_an_opf(self, tmp_path): + path = tmp_path / "noopf.epub" + with zipfile.ZipFile(path, "w") as archive: + archive.writestr("META-INF/container.xml", "") + with pytest.raises(epub.EpubError, match="no OPF"): + epub.read_epub(path) + + def test_missing_container(self, tmp_path): + path = tmp_path / "empty.epub" + with zipfile.ZipFile(path, "w") as archive: + archive.writestr("mimetype", "application/epub+zip") + with pytest.raises(epub.EpubError, match="container"): + epub.read_epub(path) + + +class TestToBookText: + def test_chapters_are_separated_the_way_the_pipeline_expects(self, simple_book): + from narration import chunking + + text = epub.to_book_text(epub.read_epub(simple_book)) + assert len(chunking.split_chapters(text)) == 2 + + def test_each_chapter_opens_with_its_title(self, tmp_path): + nav = document('') + path = build_epub( + tmp_path / "t.epub", + [("ch1.xhtml", document(f"
x

{LONG}

"))], + nav=nav, + ) + text = epub.to_book_text(epub.read_epub(path)) + assert text.split("\n", 1)[0] == "Le grand départ" + + def test_a_title_already_in_the_text_is_not_repeated(self, simple_book): + text = epub.to_book_text(epub.read_epub(simple_book)) + assert text.count("Premier chapitre") == 1 + + def test_a_heading_broken_over_two_lines_is_not_announced_twice(self, tmp_path): + """A number above a title reads as one line in the TOC, two in the text.""" + body = f"

VII
Un moment d'ivresse

{LONG}

" + path = build_epub(tmp_path / "twoline.epub", [("ch1.xhtml", document(body))]) + text = epub.to_book_text(epub.read_epub(path)) + assert text.count("Un moment d'ivresse") == 1 + # …and the whole title lands on the first line, which downstream turns + # into the chapter marker. A marker reading "VII" would be useless. + assert text.split("\n", 1)[0] == "VII Un moment d'ivresse" + + def test_an_invented_title_is_never_read_aloud(self, tmp_path): + """A file name makes a fine chapter marker and a terrible first sentence.""" + path = build_epub( + tmp_path / "untitled.epub", + [("ch1.xhtml", document(f"

{LONG}

"))], + ) + book = epub.read_epub(path) + assert book.chapters[0].titled is False + assert not epub.to_book_text(book).startswith(book.chapters[0].title) + + def test_a_separator_inside_the_book_does_not_split_a_chapter(self, tmp_path): + body = f"

{LONG}

---

{LONG}

" + path = build_epub(tmp_path / "sep.epub", [("ch1.xhtml", document(body))]) + + from narration import chunking + + text = epub.to_book_text(epub.read_epub(path)) + assert len(chunking.split_chapters(text)) == 1 + + +class TestHelpers: + def test_is_epub(self, tmp_path): + assert epub.is_epub("livre.epub") + assert epub.is_epub("LIVRE.EPUB") + assert not epub.is_epub("livre.txt") + + def test_load_book_text_returns_both(self, simple_book): + text, book = epub.load_book_text(simple_book) + assert book.title == "Le Livre" + assert "Premier chapitre" in text + + def test_summary_lists_every_chapter(self, simple_book): + summary = epub.summarize(epub.read_epub(simple_book)) + assert "Le Livre" in summary + assert "Premier chapitre" in summary + assert "Deuxième chapitre" in summary From ca161be3fc89109c4dde070e8be045b54545f91b Mon Sep 17 00:00:00 2001 From: PaxHelios Date: Fri, 31 Jul 2026 13:16:21 +0200 Subject: [PATCH 26/98] feat(epub): stop narrating the licence and the contents page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two passages of every imported book were being read aloud that no listener wants. Project Gutenberg wraps each work in an English notice and closes it with the full licence — some 17,000 characters, around twenty minutes of legalese, in the wrong language, at the end of a French audiobook, and hours of CPU to synthesize. And a book whose own table of contents sits in the text opens on several minutes of chapter titles read one after another. The Gutenberg cut is exact rather than heuristic: the `*** START OF … ***` and `*** END OF … ***` lines are part of the format, so they are the only thing cut on, and a book carrying neither is returned untouched. Removing the header orphans the opening chapter's title, which came from a heading inside it, so the title is re-derived from the title page left behind. A contents page gives itself away by having nearly every line equal to the title of another chapter, which prose never manages; the threshold is blunt on purpose so a real chapter cannot trip it. Nothing is removed silently: every cut is reported in EpubBook.removed, shown in the plan and under the upload button, and `--keep-boilerplate` turns the whole pass off. On the two books tested end to end: 19,066 characters removed from Autour de la Lune, 21,742 from Les trois mousquetaires (contents page included). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Fity58qgKttpD1nLheWrzy --- app.py | 19 ++-- docs/NARRATION.md | 12 ++- narration/epub.py | 168 ++++++++++++++++++++++++++++++++++- scripts/narrate_book.py | 4 + tests/test_narration_epub.py | 102 +++++++++++++++++++++ 5 files changed, 288 insertions(+), 17 deletions(-) diff --git a/app.py b/app.py index 0ea52922..be7cf24a 100644 --- a/app.py +++ b/app.py @@ -850,16 +850,17 @@ def _load_book_file(file_path: Optional[str]): if book is None: return content, gr.update(), gr.update(), gr.update() - return ( - content, - book.title or gr.update(), - book.author or gr.update(), - I18N("book_epub_loaded").format( - chapters=len(book.chapters), - title=book.title or Path(file_path).stem, - author=f" — {book.author}" if book.author else "", - ), + + status = I18N("book_epub_loaded").format( + chapters=len(book.chapters), + title=book.title or Path(file_path).stem, + author=f" — {book.author}" if book.author else "", ) + # What was taken out of the book is said out loud, never assumed + # unwanted: the reader is the one who decides it was boilerplate. + if book.removed: + status += "\n\n" + "\n".join(f"- 🗑️ {note}" for note in book.removed) + return content, book.title or gr.update(), book.author or gr.update(), status def _generate( text: str, diff --git a/docs/NARRATION.md b/docs/NARRATION.md index 54cb6353..59521d7f 100644 --- a/docs/NARRATION.md +++ b/docs/NARRATION.md @@ -142,14 +142,18 @@ Ce qui en est tiré : 6 énormes chapitres au lieu de ses 25 vrais. `--no-epub-split` désactive. - **Les pages de garde sont écartées** en dessous de `--epub-min-chars` caractères (140 par défaut) — une couverture n'est pas un chapitre. +- **L'appareil éditorial est retiré** : l'en-tête et la licence du projet + Gutenberg (17 000 caractères d'anglais juridique, soit ~20 min de narration en + fin de livre) sont coupés sur les marqueurs officiels `*** START OF … ***` et + `*** END OF … ***`, et une table des matières présente dans le corps du livre + est écartée quand la majorité de ses lignes sont des titres de chapitres. + **Rien n'est retiré en silence** : chaque suppression est listée dans le plan + et sous le bouton de chargement. `--keep-boilerplate` désactive. - **Un EPUB protégé par DRM est refusé** avec un message clair, plutôt que narré en bruit binaire. -Trois limites à connaître : +Deux limites à connaître : -- Une **table des matières éditoriale** présente dans le corps du livre est - importée comme le reste du texte. Elle apparaît dans le plan avant génération : - supprime-la de la zone de texte. - Un livre **entièrement contenu dans un seul fichier** reste un seul chapitre : avec un seul document, rien ne permet de distinguer un titre de livre au-dessus de ses chapitres d'un chapitre au-dessus de ses scènes. Insère des `---` pour diff --git a/narration/epub.py b/narration/epub.py index 4128f2a7..5adc0eb3 100644 --- a/narration/epub.py +++ b/narration/epub.py @@ -37,7 +37,7 @@ import posixpath import re import zipfile -from dataclasses import dataclass, replace +from dataclasses import dataclass, field, replace from html.parser import HTMLParser from pathlib import Path from typing import Dict, List, Optional, Sequence, Tuple @@ -87,6 +87,22 @@ # any the book itself contains has to stop looking like one. _CHAPTER_SEPARATOR_RE = re.compile(r"(?m)^\s*---\s*$") +# The lines Project Gutenberg wraps every work in. Part of the format, hence an +# exact match rather than a guess; "THIS" is the spelling of older files. +_GUTENBERG_START_RE = re.compile( + r"(?im)^[^\S\n]*\*\*\*[^\S\n]*START OF (?:THE|THIS) PROJECT GUTENBERG EBOOK.*$" +) +_GUTENBERG_END_RE = re.compile( + r"(?im)^[^\S\n]*\*\*\*[^\S\n]*END OF (?:THE|THIS) PROJECT GUTENBERG EBOOK.*$" +) + +_PUNCTUATION_RE = re.compile(r"[^\w\s]", re.UNICODE) +#: A contents page is recognised by most of its lines being chapter titles. +#: Short enough to catch a slim book's contents, long enough that no ordinary +#: chapter reaches the threshold by accident. +_CONTENTS_MIN_LINES = 5 +_CONTENTS_RATIO = 0.6 + class EpubError(ValueError): """The file is not an EPUB we can read, and the reason is worth showing.""" @@ -119,6 +135,9 @@ class EpubBook: chapters: List[EpubChapter] #: Documents dropped as front matter, kept so the caller can say so. skipped: List[str] + #: Human-readable note per passage removed as boilerplate. Never silent: + #: text taken out of a book has to be reported back to whoever imported it. + removed: List[str] = field(default_factory=list) @property def characters(self) -> int: @@ -527,6 +546,125 @@ def _merge_short_sections( return merged +def _strip_gutenberg( + chapters: List[EpubChapter], +) -> Tuple[List[EpubChapter], List[str]]: + """Remove the Project Gutenberg header and licence around the actual book. + + Every Gutenberg book opens on an English notice and closes on the full + licence — around 17,000 characters of legalese, some twenty minutes of + narration, in the wrong language, at the end of a French audiobook. Worse on + a CPU, where those minutes cost hours of synthesis. + + The two ``*** START OF THE PROJECT GUTENBERG EBOOK … ***`` and ``*** END OF + … ***`` lines delimit the work exactly — they are part of the format, not a + guess — so the cut is made on them and on nothing else. A book carrying + neither marker is returned untouched. + """ + start_at: Optional[Tuple[int, int]] = None # (chapter index, end of match) + end_at: Optional[Tuple[int, int]] = None # (chapter index, start of match) + for index, chapter in enumerate(chapters): + if start_at is None: + match = _GUTENBERG_START_RE.search(chapter.text) + if match: + start_at = (index, match.end()) + match = _GUTENBERG_END_RE.search(chapter.text) + if match: + end_at = (index, match.start()) + break + if start_at is None and end_at is None: + return chapters, [] + + removed: List[str] = [] + kept = list(chapters) + + if end_at is not None: + index, position = end_at + for dropped in kept[index + 1:]: + removed.append(f"« {dropped.title} » (licence Project Gutenberg)") + kept = kept[: index + 1] + tail = len(kept[index].text) - position + kept[index] = replace(kept[index], text=kept[index].text[:position].strip()) + if tail > 0: + removed.append( + f"fin de « {kept[index].title} » : {tail} caractères de licence " + "Project Gutenberg" + ) + + if start_at is not None: + index, position = start_at + for dropped in kept[:index]: + removed.append(f"« {dropped.title} » (en-tête Project Gutenberg)") + kept = kept[index:] + if position > 0: + body = kept[0].text[position:].strip() + if not body: + # The whole chapter was the notice. + removed.append(f"« {kept[0].title} » (en-tête Project Gutenberg)") + kept = kept[1:] + else: + removed.append( + f"début de « {kept[0].title} » : {position} caractères d'en-tête " + "Project Gutenberg" + ) + # Its title came from a heading inside the notice just removed, + # so it now names something the listener will never hear. What + # is left opens on the book's own title page: take that. + opening = body.split("\n", 1)[0].strip() + kept[0] = replace( + kept[0], + text=body, + title=opening[:120] or kept[0].title, + titled=bool(opening), + ) + + surviving = [chapter for chapter in kept if chapter.text] + for empty in (chapter for chapter in kept if not chapter.text): + removed.append(f"« {empty.title} » (vide après nettoyage)") + return surviving, removed + + +def _toc_key(text: str) -> str: + """Comparable form of a line: no punctuation, no case, single spaces. + + A contents page and the heading it points at rarely agree on punctuation — + ``CHAPITRE II`` against ``CHAPITRE II.`` — and always agree on the words. + """ + return " ".join(_PUNCTUATION_RE.sub(" ", text or "").casefold().split()) + + +def _drop_contents_pages( + chapters: List[EpubChapter], +) -> Tuple[List[EpubChapter], List[str]]: + """Remove a chapter that is the book's own table of contents. + + Narrated, a contents page is several minutes of chapter titles read one + after another before the book begins. It gives itself away completely: + nearly every one of its lines *is* the title of another chapter, which no + prose ever manages. + + The test is deliberately blunt — a majority of lines matching known chapter + titles — so a chapter of ordinary text can never trip it, whatever its + length or layout. + """ + titles = {_toc_key(chapter.title) for chapter in chapters} + titles.discard("") + kept: List[EpubChapter] = [] + removed: List[str] = [] + for chapter in chapters: + lines = [line for line in (l.strip() for l in chapter.text.splitlines()) if line] + if len(lines) >= _CONTENTS_MIN_LINES: + matching = sum(1 for line in lines if _toc_key(line) in titles) + if matching >= _CONTENTS_RATIO * len(lines): + removed.append( + f"« {chapter.title} » (table des matières : {matching} de ses " + f"{len(lines)} lignes sont des titres de chapitres)" + ) + continue + kept.append(chapter) + return kept, removed + + def _text_after_title(text: str, title: str) -> Optional[str]: """What follows the title when ``text`` opens with it, ignoring whitespace. @@ -563,12 +701,14 @@ def read_epub( *, min_chars: int = DEFAULT_MIN_CHARS, split_on_headings: bool = True, + strip_boilerplate: bool = True, ) -> EpubBook: """Read an EPUB into chapters, in reading order. ``split_on_headings`` cuts a spine document that holds several chapters at its headings; turn it off to keep one chapter per file exactly as the book - packages them. + packages them. ``strip_boilerplate`` removes the Project Gutenberg header + and licence; what it took out is reported in ``EpubBook.removed``. Raises :class:`EpubError` when the file is not a readable EPUB — a wrong extension, a corrupt archive, DRM, or a manifest that lists no text. @@ -666,13 +806,25 @@ def read_epub( ) ) + removed: List[str] = [] + if strip_boilerplate: + chapters, removed = _strip_gutenberg(chapters) + chapters, contents_removed = _drop_contents_pages(chapters) + removed.extend(contents_removed) + if not chapters: raise EpubError( "No readable text found in this EPUB — it may be a scanned book " "(images only) or use a structure we cannot read." ) - return EpubBook(title=title, author=author, chapters=chapters, skipped=skipped) + return EpubBook( + title=title, + author=author, + chapters=chapters, + skipped=skipped, + removed=removed, + ) def to_book_text(book: EpubBook) -> str: @@ -701,9 +853,15 @@ def load_book_text( *, min_chars: int = DEFAULT_MIN_CHARS, split_on_headings: bool = True, + strip_boilerplate: bool = True, ) -> Tuple[str, EpubBook]: """Read an EPUB straight to narratable text, keeping the book for its metadata.""" - book = read_epub(path, min_chars=min_chars, split_on_headings=split_on_headings) + book = read_epub( + path, + min_chars=min_chars, + split_on_headings=split_on_headings, + strip_boilerplate=strip_boilerplate, + ) return to_book_text(book), book @@ -719,4 +877,6 @@ def summarize(book: EpubBook) -> str: lines.append(f" {index:>3}. {chapter.title} ({chapter.characters} car.)") if book.skipped: lines.append(f" ({len(book.skipped)} document(s) trop court(s) ignoré(s))") + for note in book.removed: + lines.append(f" retiré : {note}") return "\n".join(lines) diff --git a/scripts/narrate_book.py b/scripts/narrate_book.py index f8894a6e..0c025c12 100644 --- a/scripts/narrate_book.py +++ b/scripts/narrate_book.py @@ -117,6 +117,9 @@ def build_parser() -> argparse.ArgumentParser: text.add_argument("--no-epub-split", action="store_true", help="EPUB: keep one chapter per file instead of cutting files that " "hold several chapters at their headings") + text.add_argument("--keep-boilerplate", action="store_true", + help="EPUB: keep the Project Gutenberg header and licence, and any " + "contents page, instead of removing them") text.add_argument("--chunk-max-chars", type=int, default=chunking.DEFAULT_MAX_CHARS, help=f"Max characters per segment (default: {chunking.DEFAULT_MAX_CHARS})") @@ -177,6 +180,7 @@ def main() -> int: in_path, min_chars=args.epub_min_chars, split_on_headings=not args.no_epub_split, + strip_boilerplate=not args.keep_boilerplate, ) except epub.EpubError as error: raise SystemExit(str(error)) diff --git a/tests/test_narration_epub.py b/tests/test_narration_epub.py index 65ea453b..4f481435 100644 --- a/tests/test_narration_epub.py +++ b/tests/test_narration_epub.py @@ -412,6 +412,108 @@ def test_the_floor_is_adjustable(self, tmp_path): assert len(epub.read_epub(path, min_chars=1).chapters) == 2 +class TestBoilerplate: + """The apparatus around a book: Gutenberg wrappers, contents pages.""" + + HEADER = ( + "

The Project Gutenberg eBook of Le Livre

" + "

This eBook is for the use of anyone anywhere at no cost.

" + "

*** START OF THE PROJECT GUTENBERG EBOOK LE LIVRE ***

" + ) + FOOTER = ( + "

*** END OF THE PROJECT GUTENBERG EBOOK LE LIVRE ***

" + "

THE FULL PROJECT GUTENBERG LICENSE — Section 1. General Terms of Use.

" + ) + + def test_the_english_notice_before_the_book_is_removed(self, tmp_path): + body = f"{self.HEADER}

I Le départ

{LONG}

" + path = build_epub(tmp_path / "gh.epub", [("ch1.xhtml", document(body))]) + book = epub.read_epub(path) + assert "Project Gutenberg" not in book.chapters[0].text + assert LONG.strip()[:40] in book.chapters[0].text + + def test_the_licence_after_the_book_is_removed(self, tmp_path): + body = f"

I Le départ

{LONG}

{self.FOOTER}" + path = build_epub(tmp_path / "gf.epub", [("ch1.xhtml", document(body))]) + book = epub.read_epub(path) + assert "FULL PROJECT GUTENBERG LICENSE" not in book.chapters[-1].text + assert LONG.strip()[:40] in book.chapters[-1].text + + def test_a_whole_licence_chapter_is_dropped(self, tmp_path): + documents = [ + ("ch1.xhtml", document(f"

I Le départ

{LONG}

{self.FOOTER}")), + ("ch2.xhtml", document(f"

Licence

{LONG}

")), + ] + path = build_epub(tmp_path / "gc.epub", documents) + book = epub.read_epub(path) + assert [c.title for c in book.chapters] == ["I Le départ"] + + def test_the_opening_chapter_is_retitled_from_what_is_left(self, tmp_path): + """Its title came from a heading inside the notice that was removed.""" + body = ( + "

The Project Gutenberg eBook of Le Livre

" + "

*** START OF THE PROJECT GUTENBERG EBOOK LE LIVRE ***

" + f"

LE LIVRE

par une autrice

{LONG}

" + ) + path = build_epub(tmp_path / "gt.epub", [("ch1.xhtml", document(body))]) + book = epub.read_epub(path) + assert book.chapters[0].title == "LE LIVRE" + + def test_nothing_is_removed_silently(self, tmp_path): + body = f"{self.HEADER}

I Le départ

{LONG}

{self.FOOTER}" + path = build_epub(tmp_path / "gr.epub", [("ch1.xhtml", document(body))]) + book = epub.read_epub(path) + assert len(book.removed) == 2 + assert any("en-tête" in note for note in book.removed) + assert any("licence" in note for note in book.removed) + + def test_stripping_can_be_turned_off(self, tmp_path): + body = f"{self.HEADER}

I Le départ

{LONG}

{self.FOOTER}" + path = build_epub(tmp_path / "gk.epub", [("ch1.xhtml", document(body))]) + book = epub.read_epub(path, strip_boilerplate=False) + assert "Project Gutenberg" in book.chapters[0].text + assert book.removed == [] + + def test_a_book_without_the_markers_is_untouched(self, simple_book): + book = epub.read_epub(simple_book) + assert book.removed == [] + assert len(book.chapters) == 2 + + def test_a_contents_page_is_dropped(self, tmp_path): + contents = ( + "

Table des matières

" + "

I Le départ

II. La traversée

III Le retour

" + "

IV L'arrivée

V La fin

" + ) + documents = [ + ("toc.xhtml", document(contents)), + ("c1.xhtml", document(f"

I Le départ

{LONG}

")), + ("c2.xhtml", document(f"

II La traversée

{LONG}

")), + ("c3.xhtml", document(f"

III Le retour

{LONG}

")), + ("c4.xhtml", document(f"

IV L'arrivée

{LONG}

")), + ("c5.xhtml", document(f"

V La fin

{LONG}

")), + ] + path = build_epub(tmp_path / "toc.epub", documents, prefix="OEBPS/") + book = epub.read_epub(path, min_chars=1) + assert "Table des matières" not in [c.title for c in book.chapters] + assert any("table des matières" in note for note in book.removed) + + def test_punctuation_does_not_hide_a_contents_page(self, tmp_path): + """`CHAPITRE II.` in the list, `CHAPITRE II` in the heading.""" + assert epub._toc_key("CHAPITRE II.") == epub._toc_key("Chapitre II") + + def test_ordinary_prose_is_never_taken_for_a_contents_page(self, tmp_path): + """The rule must not be able to eat a chapter of the actual book.""" + documents = [ + ("c1.xhtml", document(f"

I Le départ

{LONG}

")), + ("c2.xhtml", document(f"

II La traversée

{LONG}

")), + ] + path = build_epub(tmp_path / "prose.epub", documents) + book = epub.read_epub(path) + assert len(book.chapters) == 2 + assert book.removed == [] + + class TestFailures: def test_missing_file(self, tmp_path): with pytest.raises(epub.EpubError, match="No such file"): From 655f6524731dc7a75387ad8ab4b007b3eff3c7b4 Mon Sep 17 00:00:00 2001 From: PaxHelios Date: Fri, 31 Jul 2026 13:48:47 +0200 Subject: [PATCH 27/98] feat(narration): credit the recording the way distributors require MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A finished audiobook is not just the book read aloud. ACX and Audible, and behind them Amazon, Apple Books, Kobo and Google Play, all require the recording to announce itself: the first file opens on title, author and narrator, the last one names them again. A submission without that is rejected at quality review before anyone hears a line of the prose. This fork produced neither. The credits are added as chapters, not as a special case. They then take the same French preparation, the same voice and seed, the same mastering and the same cache as the book — so they sound like the narrator rather than an announcement bolted on afterwards, and they resume and repair like anything else. Where no human narrator is named, the credits say the reading is a synthetic voice. That is the default and turning it off takes a deliberate flag: Audible distributes such titles through a separate programme and labels them, and passing a machine reading off as a performance is what closes an account. The same standard governs the shape of a delivered file, not only its level — 0.5 to 1 second of room tone before the first word, 1 to 5 after the last, no file past two hours. The mastering defaults sat at 0.3 and 0.6 seconds, below the floor: a chapter with textbook loudness was rejectable on shape alone. They now aim at the middle of each window, and acx_report measures all six limits instead of three. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Fity58qgKttpD1nLheWrzy --- app.py | 89 ++++++++++++++-- docs/NARRATION.md | 72 +++++++++++++ narration/__init__.py | 2 + narration/audio.py | 100 +++++++++++++++--- narration/credits.py | 162 +++++++++++++++++++++++++++++ scripts/narrate_book.py | 40 ++++++- tests/test_narrate_book_credits.py | 117 +++++++++++++++++++++ tests/test_narrate_book_qc.py | 7 ++ tests/test_narration_audio.py | 46 +++++++- tests/test_narration_credits.py | 116 +++++++++++++++++++++ 10 files changed, 723 insertions(+), 28 deletions(-) create mode 100644 narration/credits.py create mode 100644 tests/test_narrate_book_credits.py create mode 100644 tests/test_narration_credits.py diff --git a/app.py b/app.py index be7cf24a..77728e32 100644 --- a/app.py +++ b/app.py @@ -28,7 +28,7 @@ from narration import audio as audio_tools from narration import cache as cache_tools from narration import epub as epub_reader -from narration import chunking, quality, repair, text_fr +from narration import chunking, credits, quality, repair, text_fr logging.basicConfig( level=logging.INFO, @@ -215,6 +215,10 @@ "book_text_label": "Book text — separate chapters with a line containing only ---", "book_title_label": "Book title", "book_author_label": "Author / narrator", + "book_narrator_label": "Narrator named in the credits", + "book_narrator_info": "Left empty, the credits state that the reading is a synthetic voice — which is what distributors require.", + "book_credits_label": "Opening and closing credits", + "book_credits_info": "Distributors (ACX/Audible, Amazon, Apple Books) reject a recording that does not announce its title, author and narrator at both ends.", "book_plan_btn": "🔍 Analyse without generating", "book_plan_label": "Plan", "book_generate_btn": "📖 Narrate the book", @@ -288,6 +292,10 @@ "book_text_label": "Texte du livre — séparez les chapitres par une ligne contenant seulement ---", "book_title_label": "Titre du livre", "book_author_label": "Auteur / narrateur", + "book_narrator_label": "Narrateur cité au générique", + "book_narrator_info": "Laissé vide, le générique indique que la lecture est une voix de synthèse — ce que les plateformes exigent.", + "book_credits_label": "Générique de début et de fin", + "book_credits_info": "Les distributeurs (ACX/Audible, Amazon, Apple Books) refusent un enregistrement qui n'annonce pas son titre, son auteur et son narrateur aux deux bouts.", "book_plan_btn": "🔍 Analyser sans générer", "book_plan_label": "Plan", "book_generate_btn": "📖 Narrer le livre", @@ -993,13 +1001,40 @@ def _book_dir(title: str) -> Path: """Where a book's chapters and its resume cache live.""" return _BOOKS_DIR / f"book_{_sanitize_filename(title or 'livre')}" - def _book_prepared_chapters(book_text: str, prepare: bool) -> List[str]: + def _book_credits(title, author, narrator, enabled: bool): + """The credits a distributor requires, or None when switched off.""" + if not enabled: + return None + return credits.BookCredits( + title=(title or "").strip(), + author=(author or "").strip(), + narrator=(narrator or "").strip(), + ) + + def _book_prepared_chapters( + book_text: str, prepare: bool, book_credits=None + ) -> List[str]: + """Chapters as they will be narrated, credits included. + + The credits are chapters like any other on purpose: they then take the + same French preparation, voice, seed and mastering as the book. + """ chapters = chunking.split_chapters(book_text) + if book_credits is not None and chapters: + chapters = [book_credits.opening()] + chapters + [book_credits.closing()] if not prepare: return chapters lexicon = text_fr.load_lexicon(_LEXICON_PATH) return [text_fr.normalize_french(chapter, lexicon=lexicon) for chapter in chapters] + def _book_chapter_title(chapter: str, index: int, count: int, has_credits: bool) -> str: + """Marker title — the credits are named rather than quoted.""" + if has_credits and index == 1: + return credits.OPENING_TITLE + if has_credits and index == count: + return credits.CLOSING_TITLE + return _chapter_title(chapter, index) + def _book_profile(pause_sentence: float, pause_paragraph: float) -> chunking.PauseProfile: default = chunking.PauseProfile() return chunking.PauseProfile( @@ -1008,9 +1043,20 @@ def _book_profile(pause_sentence: float, pause_paragraph: float) -> chunking.Pau paragraph=float(pause_paragraph), ) - def _book_plan(book_text, chunk_max_chars_value, prepare, pause_sentence, pause_paragraph): + def _book_plan( + book_text, + chunk_max_chars_value, + prepare, + pause_sentence, + pause_paragraph, + title="", + author="", + narrator="", + with_credits=True, + ): """Show what would be generated, without loading the model.""" - chapters = _book_prepared_chapters(book_text, prepare) + book_credits = _book_credits(title, author, narrator, with_credits) + chapters = _book_prepared_chapters(book_text, prepare, book_credits) if not chapters: return "*Aucun texte à analyser.*" @@ -1055,6 +1101,8 @@ def _book_narrate( pause_paragraph, preset_name, qc_retries, + narrator="", + with_credits=True, progress=gr.Progress(), ): """Narrate every chapter, writing each one to disk as soon as it is done. @@ -1062,7 +1110,8 @@ def _book_narrate( Yields after each chapter so the UI shows progress on a job that runs for hours, and so a finished chapter is listenable before the book is. """ - chapters = _book_prepared_chapters(book_text, prepare) + book_credits = _book_credits(title, author, narrator, with_credits) + chapters = _book_prepared_chapters(book_text, prepare, book_credits) if not chapters: raise gr.Error("Aucun texte à narrer. Chargez un fichier .txt ou collez le texte.") @@ -1096,7 +1145,9 @@ def _book_narrate( chapter_plans = [ repair.PlannedChapter( index=index, - title=_chapter_title(chapter, index), + title=_book_chapter_title( + chapter, index, len(chapters), book_credits is not None + ), segments=tuple( repair.PlannedSegment(segment.text, segment.pause_after) for segment in chunking.split_into_segments( @@ -1513,6 +1564,18 @@ def _run_asr_if_needed(checked, audio_path): with gr.Row(): book_title = gr.Textbox(value="", label=I18N("book_title_label")) book_author = gr.Textbox(value="", label=I18N("book_author_label")) + with gr.Row(): + book_narrator = gr.Textbox( + value="", + label=I18N("book_narrator_label"), + info=I18N("book_narrator_info"), + ) + book_with_credits = gr.Checkbox( + value=True, + label=I18N("book_credits_label"), + elem_classes=["switch-toggle"], + info=I18N("book_credits_info"), + ) # The book's own voice picker. Deliberately not a mirror # of the Studio one: this dropdown is what the book is @@ -1699,7 +1762,17 @@ def _run_asr_if_needed(checked, audio_path): book_plan_btn.click( fn=_book_plan, - inputs=[book_text, chunk_max_chars, book_prepare, book_pause_sentence, book_pause_paragraph], + inputs=[ + book_text, + chunk_max_chars, + book_prepare, + book_pause_sentence, + book_pause_paragraph, + book_title, + book_author, + book_narrator, + book_with_credits, + ], outputs=[book_status], show_progress=False, ) @@ -1729,6 +1802,8 @@ def _run_asr_if_needed(checked, audio_path): book_pause_paragraph, book_preset_voice, book_qc_retries, + book_narrator, + book_with_credits, ], outputs=[book_status, book_audio], show_progress=True, diff --git a/docs/NARRATION.md b/docs/NARRATION.md index 59521d7f..5ba45c9a 100644 --- a/docs/NARRATION.md +++ b/docs/NARRATION.md @@ -24,6 +24,7 @@ chacune dans un module de `narration/` — testable et utilisable indépendammen | Étape | Module | Ce qu'elle fait | |---|---|---| | **0. Lecture** | `narration/epub.py` | Lit un `.epub` dans l'ordre du *spine* et en tire des chapitres titrés — un `.txt` se découpe lui sur les lignes `---` | +| **0 bis. Générique** | `narration/credits.py` | Ajoute au livre le générique de début et de fin qu'exigent les distributeurs, comme deux chapitres à part entière | | **1. Préparation** | `narration/text_fr.py` | Réécrit le texte tel qu'un narrateur le dirait : `1789` → « mille sept cent quatre-vingt-neuf », `M. Dupont` → « Monsieur Dupont », `XIVe siècle` → « quatorzième siècle », `14h30`, `1 250 €`, `3,5 %`… | | **2. Découpage** | `narration/chunking.py` | Coupe en segments sous la limite du moteur, **sans jamais couper une phrase**, et décide la durée du silence après chaque segment selon la ponctuation | | **3. Synthèse** | moteur VoxCPM2 | Même seed partout → voix identique du début à la fin | @@ -264,6 +265,77 @@ mêmes 15,8 et 24,1 aux deux extrémités. C'est ce qui lui donne du crédit — l'échantillon n'a déplacé aucune borne. Un test verrouille chacune des valeurs mesurées, pour qu'un réglage ultérieur ne puisse pas les faire dériver sans alerte. +## Générique de début et de fin + +Un livre audio n'est pas seulement le livre lu. **Tous les distributeurs** — ACX +et Audible, et derrière eux Amazon, Apple Books, Kobo, Google Play — exigent que +l'enregistrement s'annonce : le premier fichier ouvre sur le titre, l'auteur et +le narrateur, le dernier les nomme à nouveau. Un dépôt sans générique est refusé +au contrôle qualité avant même qu'on écoute une ligne du texte. + +Le générique est donc **ajouté par défaut**, comme deux chapitres à part entière : + +``` +chapitre_001.wav Générique de début +chapitre_002.wav … le livre … +chapitre_027.wav Générique de fin +``` + +En faire des chapitres est délibéré : ils passent par la même préparation du +texte, la **même voix et la même graine**, le même mastering et le même cache que +le livre — ils sonnent donc comme le narrateur, pas comme une annonce rapportée. + +``` +.\.venv\Scripts\python.exe scripts\narrate_book.py livre.epub --voice "..." ^ + --title "Autour de la Lune" --author "Jules Verne" --year 2026 --public-domain +``` + +| Option | Effet | +|---|---| +| `--narrator "Nom"` | Narrateur humain cité au générique | +| `--publisher "Studio"` | Production créditée à la fin | +| `--year 2026` | Année créditée à la fin | +| `--public-domain` | Ajoute « Texte du domaine public » | +| `--no-credits` | N'ajoute aucun générique | + +Ce que ça donne : + +> « Autour de la Lune », de Jules Verne. +> Lu par une voix de synthèse. + +> Vous venez d'écouter « Autour de la Lune », de Jules Verne, lu par une voix de +> synthèse. Enregistrement réalisé en deux mille vingt-six. Texte du domaine public. + +**La voix de synthèse est déclarée** quand aucun narrateur humain n'est nommé. +Ce n'est pas une précaution ajoutée par prudence : Audible distribue ces titres +via un programme séparé et les étiquette comme tels. Faire passer une lecture +machine pour une performance humaine est ce qui fait fermer un compte. Nommer un +narrateur avec `--narrator` remplace la mention. + +Le plan avant génération dit ce qu'il manque pour une distribution : + +``` +Générique : début et fin ajoutés — manque encore l'auteur +``` + +## Forme des fichiers : ce qu'ACX vérifie en plus du niveau + +Un chapitre parfaitement calibré en sonie est quand même refusé s'il **commence +sur la première syllabe**. La norme porte aussi sur la forme du fichier : + +| Contrôle | Norme ACX | Où c'est appliqué | +|---|---|---| +| Sonie (RMS) | −23 à −18 dBFS | mastering, une passe par chapitre | +| Crête | ≤ −3 dBFS | mastering | +| Bruit de fond | ≤ −60 dBFS | mesuré, reporté | +| **Silence en tête** | **0,5 à 1 s** | 0,75 s posé par le mastering | +| **Silence en queue** | **1 à 5 s** | 2 s posées par le mastering | +| **Durée d'un fichier** | **≤ 120 min** | mesurée, reportée | + +`narration/audio.py` mesure les six et `acx_report()` dit lesquels passent. Les +valeurs par défaut visent le **milieu** de chaque fenêtre, pas son bord : un +chapitre reste conforme même si le rognage laisse un peu de silence à lui. + ## Assemblage en un fichier unique ``` diff --git a/narration/__init__.py b/narration/__init__.py index d44ee236..719ce0b7 100644 --- a/narration/__init__.py +++ b/narration/__init__.py @@ -9,6 +9,7 @@ Stages, in pipeline order:: epub read an .epub into the plain chapters everything else expects + credits the opening and closing credits distributors require text_fr prepare raw French prose for a TTS engine chunking cut prepared text into engine-sized segments + pause plan cache content-addressed store so an interrupted run resumes per chunk @@ -23,6 +24,7 @@ "audio", "cache", "chunking", + "credits", "epub", "quality", "repair", diff --git a/narration/audio.py b/narration/audio.py index 5f57a506..0f5a28e2 100644 --- a/narration/audio.py +++ b/narration/audio.py @@ -8,8 +8,11 @@ The reference target is the ACX specification, which every major audiobook platform mirrors: RMS between -23 and -18 dBFS, peak no higher than -3 dBFS, and -a noise floor below -60 dBFS. :func:`acx_report` reports all three so a chapter -can be checked before it is ever uploaded. +a noise floor below -60 dBFS. The same specification also governs the *shape* of +a file — 0.5 to 1 second of room tone before the first word, 1 to 5 after the +last, and no file longer than 120 minutes — so :func:`acx_report` measures those +too. A chapter that passes on level and fails on room tone is rejected on upload +just the same. Pure ``numpy`` on purpose — no resampling library, no loudness package. Frame energies are computed from a cumulative sum rather than a sliding window so that @@ -23,10 +26,15 @@ import numpy as np __all__ = [ + "ACX_HEAD_ROOM_MAX_SEC", + "ACX_HEAD_ROOM_MIN_SEC", + "ACX_MAX_FILE_SEC", "ACX_PEAK_CEILING_DB", "ACX_RMS_MAX_DB", "ACX_RMS_MIN_DB", "ACX_NOISE_FLOOR_DB", + "ACX_TAIL_ROOM_MAX_SEC", + "ACX_TAIL_ROOM_MIN_SEC", "MasteringSettings", "acx_report", "as_float_mono", @@ -38,6 +46,7 @@ "peak_db", "remove_dc", "silence", + "speech_bounds", "speech_rms_db", "stitch", "trim_silence", @@ -49,6 +58,16 @@ ACX_PEAK_CEILING_DB = -3.0 ACX_NOISE_FLOOR_DB = -60.0 +#: Room tone ACX expects around the speech of every delivered file, in seconds. +#: Not decoration: a file starting on the first syllable, or ending on it, is +#: rejected at quality review however clean its levels are. +ACX_HEAD_ROOM_MIN_SEC = 0.5 +ACX_HEAD_ROOM_MAX_SEC = 1.0 +ACX_TAIL_ROOM_MIN_SEC = 1.0 +ACX_TAIL_ROOM_MAX_SEC = 5.0 +#: No single delivered file may run longer than two hours. +ACX_MAX_FILE_SEC = 120 * 60 + _EPS = 1e-12 #: Below this peak level a signal carries no usable level to correct. _SILENCE_FLOOR_DB = -120.0 @@ -75,9 +94,12 @@ class MasteringSettings: trim_keep_ms: float = 60.0 #: Click-free ramp applied to every segment edge. fade_ms: float = 8.0 - #: Silence before the first word and after the last one, in seconds. - lead_sec: float = 0.3 - tail_sec: float = 0.6 + #: Room tone before the first word and after the last one, in seconds. + #: Sits inside the ACX windows (0.5–1 s and 1–5 s) rather than at their + #: edges, so a chapter stays compliant even if trimming leaves a little + #: silence of its own. + lead_sec: float = 0.75 + tail_sec: float = 2.0 # -------------------------------------------------------------------------- @@ -199,21 +221,49 @@ def noise_floor_db(wav: np.ndarray, sr: int, percentile: float = 10.0) -> float: return _to_db(float(np.sqrt(max(np.percentile(power, percentile), 0.0)))) +def room_tone_sec(wav: np.ndarray, sr: int) -> Tuple[float, float]: + """Seconds of silence before the first word and after the last one. + + A file with no speech at all reports its whole length as head silence and + nothing as tail, which is what an all-silence chapter deserves to be told. + """ + wav = as_float_mono(wav) + if wav.size == 0 or not sr: + return 0.0, 0.0 + bounds = speech_bounds(wav, sr) + if bounds is None: + return float(wav.size) / sr, 0.0 + start, end = bounds + return float(start) / sr, float(max(0, wav.size - end)) / sr + + def acx_report(wav: np.ndarray, sr: int) -> dict: - """Measure a chapter against the ACX limits and say which ones it meets.""" + """Measure a chapter against the ACX limits and say which ones it meets. + + Level *and* shape: a chapter can sit perfectly in the loudness window and + still be rejected for opening on its first syllable or running past two + hours, so both are reported side by side. + """ rms = speech_rms_db(wav, sr) peak = peak_db(wav) floor = noise_floor_db(wav, sr) + head, tail = room_tone_sec(wav, sr) + duration = float(np.asarray(wav).shape[0]) / sr if sr else 0.0 checks = { "rms_ok": ACX_RMS_MIN_DB <= rms <= ACX_RMS_MAX_DB, "peak_ok": peak <= ACX_PEAK_CEILING_DB, "noise_floor_ok": floor <= ACX_NOISE_FLOOR_DB, + "head_room_ok": ACX_HEAD_ROOM_MIN_SEC <= head <= ACX_HEAD_ROOM_MAX_SEC, + "tail_room_ok": ACX_TAIL_ROOM_MIN_SEC <= tail <= ACX_TAIL_ROOM_MAX_SEC, + "duration_ok": duration <= ACX_MAX_FILE_SEC, } return { "rms_db": rms, "peak_db": peak, "noise_floor_db": floor, - "duration_sec": float(np.asarray(wav).shape[0]) / sr if sr else 0.0, + "head_room_sec": head, + "tail_room_sec": tail, + "duration_sec": duration, **checks, "compliant": all(checks.values()), } @@ -245,26 +295,46 @@ def trim_silence( all from a loud one. """ wav = as_float_mono(wav) - if wav.size == 0: + bounds = speech_bounds(wav, sr, relative_db=relative_db, frame_ms=frame_ms) + if bounds is None: return wav + start, end = bounds + margin = int(sr * max(0.0, keep_ms) / 1000.0) + return wav[max(0, start - margin) : min(wav.size, end + margin)] + + +def speech_bounds( + wav: np.ndarray, + sr: int, + *, + relative_db: float = 25.0, + frame_ms: float = 20.0, +) -> Optional[Tuple[int, int]]: + """First and last sample carrying speech, or ``None`` if none does. + + The threshold is relative to the signal's own speech level rather than an + absolute dBFS value, because segments arrive un-normalised and a fixed + threshold would either clip the start of a quiet one or trim nothing at all + from a loud one. + """ + wav = as_float_mono(wav) + if wav.size == 0: + return None + hop_ms = frame_ms / 2.0 power = _frame_power(wav, sr, frame_ms, hop_ms) if power.size == 0: - return wav + return None threshold = 10.0 ** ((speech_rms_db(wav, sr) - relative_db) / 10.0) loud = np.flatnonzero(power > threshold) if loud.size == 0: - return wav + return None hop = max(1, int(sr * hop_ms / 1000.0)) frame = max(1, int(sr * frame_ms / 1000.0)) - margin = int(sr * max(0.0, keep_ms) / 1000.0) - - start = max(0, int(loud[0]) * hop - margin) - end = min(wav.size, int(loud[-1]) * hop + frame + margin) - return wav[start:end] + return int(loud[0]) * hop, min(wav.size, int(loud[-1]) * hop + frame) def fade_edges(wav: np.ndarray, sr: int, fade_ms: float = 8.0) -> np.ndarray: diff --git a/narration/credits.py b/narration/credits.py new file mode 100644 index 00000000..2ad1c95e --- /dev/null +++ b/narration/credits.py @@ -0,0 +1,162 @@ +"""Opening and closing credits, the way distributors require them. + +A finished audiobook is not just the book read aloud. Every distributor — ACX +and Audible, and behind them Amazon, Apple Books, Kobo, Google Play — requires +the recording to *announce itself*: the first file opens on the title, the +author and the narrator, and the last one closes by naming them again. A +submission without them is rejected at quality review before anyone listens to +a word of the prose. + +The rules this module encodes: + +* **Opening credits** carry title, subtitle when there is one, author, narrator. + They are the very first thing heard. +* **Closing credits** name the work and its author again, then the narrator, and + may carry production and rights information. +* **Synthetic narration is disclosed.** Where no human narrator is named, the + credit says the reading is a synthetic voice. Audible distributes such titles + through a separate programme and labels them; claiming a machine reading as a + human performance is what gets an account closed, so the disclosure is the + default and switching it off has to be a deliberate act. + +Only the *text* lives here. It is narrated by the same voice, with the same +seed, through the same pipeline as the book — which is exactly why the credits +sound like the same narrator rather than a bolted-on announcement. + +The wording is French, like the rest of the narration this fork produces. +""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import List, Optional + +__all__ = [ + "CLOSING_TITLE", + "OPENING_TITLE", + "BookCredits", + "SYNTHETIC_DISCLOSURE", +] + +#: Chapter titles used for the two credit files, and their marker names in the +#: assembled M4B. +OPENING_TITLE = "Générique de début" +CLOSING_TITLE = "Générique de fin" + +#: Said when no human narrator is named. Not a disclaimer bolted on for safety: +#: distributors require synthetic narration to be identified as such. +SYNTHETIC_DISCLOSURE = "une voix de synthèse" + +# A title already carrying its author ("Autour de la Lune, par Jules Verne") +# would otherwise be announced as "…, par Jules Verne, de Jules Verne". +_AUTHOR_PREPOSITIONS = frozenset({"par", "de", "by"}) + + +def _clean(text: str) -> str: + """One line, single spaces, no trailing punctuation of its own.""" + collapsed = " ".join((text or "").split()) + return collapsed.rstrip(" .;,:") + + +def _sentence(text: str) -> str: + """End a credit line on a full stop, so the narrator lands it.""" + text = _clean(text) + return f"{text}." if text else "" + + +@dataclass(frozen=True) +class BookCredits: + """What the recording says about itself, at its two ends.""" + + title: str + author: str = "" + #: Human narrator. Left empty for a synthetic reading, which is then + #: disclosed rather than passed off as a performance. + narrator: str = "" + subtitle: str = "" + publisher: str = "" + year: str = "" + #: Public-domain works are worth saying so: it answers the rights question + #: a distributor asks about every uploaded recording. + public_domain: bool = False + #: Turning this off is a deliberate act — see the module docstring. + disclose_synthetic: bool = True + + @property + def narrator_credit(self) -> str: + """Who the recording says read it.""" + narrator = _clean(self.narrator) + if narrator: + return narrator + return SYNTHETIC_DISCLOSURE if self.disclose_synthetic else "" + + def _work(self) -> str: + """« Title », de Author — the phrase both credits are built around.""" + title = _clean(self.title) or "Ce livre" + author = _clean(self.author) + piece = f"« {title} »" + if self.subtitle: + piece += f", {_clean(self.subtitle)}" + if author and not _names_the_author(title, author): + piece += f", de {author}" + return piece + + def opening(self) -> str: + """The first thing heard: title, subtitle, author, narrator.""" + lines: List[str] = [_sentence(self._work())] + narrator = self.narrator_credit + if narrator: + lines.append(_sentence(f"Lu par {narrator}")) + return "\n\n".join(line for line in lines if line) + + def closing(self) -> str: + """The last thing heard: the work named again, then the production.""" + narrator = self.narrator_credit + first = f"Vous venez d'écouter {self._work()}" + if narrator: + first += f", lu par {narrator}" + lines: List[str] = [_sentence(first)] + + publisher = _clean(self.publisher) + year = _clean(self.year) + if publisher and year: + lines.append(_sentence(f"Enregistrement produit par {publisher}, {year}")) + elif publisher: + lines.append(_sentence(f"Enregistrement produit par {publisher}")) + elif year: + lines.append(_sentence(f"Enregistrement réalisé en {year}")) + + if self.public_domain: + lines.append(_sentence("Texte du domaine public")) + return "\n\n".join(line for line in lines if line) + + def missing_for_distribution(self) -> List[str]: + """What a distributor would send this recording back for. + + Reported rather than raised: a draft narration is a perfectly reasonable + thing to produce, and the gaps only matter on the day it is uploaded. + """ + missing: List[str] = [] + if not _clean(self.title): + missing.append("le titre") + if not _clean(self.author): + missing.append("l'auteur") + if not self.narrator_credit: + missing.append("le narrateur (ou la mention de voix de synthèse)") + return missing + + +def _names_the_author(title: str, author: str) -> bool: + """Whether the title already ends by naming the author. + + Matched from the end rather than by searching forwards: "Autour **de** la + Lune, par Jules Verne" has a ``de`` long before the one that matters. + """ + title_key = _clean(title).casefold() + author_key = _clean(author).casefold() + if not author_key or not title_key.endswith(author_key): + return False + head = title_key[: -len(author_key)].strip() + if head.endswith((",", "-", "—", "–", ":")): + return True + words = head.rstrip(" ,-—–:").split() + return bool(words) and words[-1] in _AUTHOR_PREPOSITIONS diff --git a/scripts/narrate_book.py b/scripts/narrate_book.py index 0c025c12..26266102 100644 --- a/scripts/narrate_book.py +++ b/scripts/narrate_book.py @@ -64,7 +64,7 @@ from narration import assemble as assembly # noqa: E402 from narration import audio as audio_tools # noqa: E402 from narration import cache as cache_tools # noqa: E402 -from narration import chunking, epub, quality, text_fr # noqa: E402 +from narration import chunking, credits, epub, quality, text_fr # noqa: E402 #: Rough characters-per-second of finished narration, used only to estimate how #: long a book will run before committing hours of CPU to it. @@ -147,8 +147,19 @@ def build_parser() -> argparse.ArgumentParser: run.add_argument("--dry-run", action="store_true", help="Show the plan, generate nothing") run.add_argument("--assemble", nargs="?", const="m4b", choices=["m4b", "m4a", "mp3", "wav"], help="Assemble the chapters into one chaptered file when done") - run.add_argument("--title", default="", help="Book title used for the assembled file") - run.add_argument("--author", default="", help="Author / narrator used for the assembled file") + run.add_argument("--title", default="", help="Book title (assembled file, and credits)") + run.add_argument("--author", default="", help="Author (assembled file, and credits)") + + story = parser.add_argument_group("generique") + story.add_argument("--narrator", default="", + help="Human narrator named in the credits. Left empty, the credits " + "disclose a synthetic voice, as distributors require") + story.add_argument("--publisher", default="", help="Production credited at the end") + story.add_argument("--year", default="", help="Year credited at the end") + story.add_argument("--public-domain", action="store_true", + help="State in the closing credits that the text is public domain") + story.add_argument("--no-credits", action="store_true", + help="Do not add the opening and closing credits distributors require") run.add_argument("--continuity", action="store_true", help="EXPERIMENTAL: chain each segment from the previous one (prompt-cache " "continuation) for smoother joins, instead of same-seed only. Slower; " @@ -187,6 +198,7 @@ def main() -> int: print(epub.summarize(book)) print() else: + book = None raw_text = in_path.read_text(encoding="utf-8").strip() if not raw_text: raise SystemExit(f"Input file is empty: {in_path}") @@ -198,6 +210,22 @@ def main() -> int: raw_chapters = chunking.split_chapters(raw_text, args.chapter_regex) titles = [chapter_title(chapter, i) for i, chapter in enumerate(raw_chapters, 1)] + # Credits are chapters like any other, deliberately: they then go through + # the same French preparation, the same voice and seed, the same mastering + # and the same cache as the book, so they sound like the narrator rather + # than an announcement bolted on afterwards. + book_credits = credits.BookCredits( + title=args.title or (book.title if book else "") or in_path.stem, + author=args.author or (book.author if book else ""), + narrator=args.narrator, + publisher=args.publisher, + year=args.year, + public_domain=args.public_domain, + ) + if not args.no_credits: + raw_chapters = [book_credits.opening()] + raw_chapters + [book_credits.closing()] + titles = [credits.OPENING_TITLE] + titles + [credits.CLOSING_TITLE] + lexicon = {} if not args.no_text_prep: lexicon = text_fr.load_lexicon(args.lexicon) @@ -224,6 +252,12 @@ def main() -> int: print(f"Préparation : {'désactivée' if args.no_text_prep else f'française ({len(lexicon)} entrée(s) de lexique)'}") print(f"Chapitres : {len(chapters)} | segments : {total_segments} | caractères : {total_chars}") print(f"Durée estimée : ~{total_chars / _CHARS_PER_SECOND / 60:.0f} min de narration") + if args.no_credits: + print("Générique : aucun (les distributeurs en exigent un au début et à la fin)") + else: + missing = book_credits.missing_for_distribution() + print("Générique : début et fin ajoutés" + + (f" — manque encore {', '.join(missing)}" if missing else "")) print(f"Sortie : {outdir}") for index, segments in plan: print(f" chapitre {index:03d}: {len(segments)} segment(s) « {titles[index - 1][:50]} »") diff --git a/tests/test_narrate_book_credits.py b/tests/test_narrate_book_credits.py new file mode 100644 index 00000000..9adc4d6b --- /dev/null +++ b/tests/test_narrate_book_credits.py @@ -0,0 +1,117 @@ +"""End-to-end tests that a narrated book carries its credits. + +Reuses the stub engine from the quality tests, so a whole run — planning, +synthesis, mastering, chapter files, titles — happens in milliseconds without +importing torch. What is under test is that the credits are really narrated as +the first and last chapters, in the same voice as the book, and that the ACX +shape of every delivered file holds. +""" +from __future__ import annotations + +import sys +from pathlib import Path + +import numpy as np +import pytest +import soundfile as sf + +from narration import audio, credits + +from test_narrate_book_qc import CALLS, SR, narrate_book # noqa: F401 +from test_narrate_book_qc import book, reset_stub # noqa: F401 (fixtures) + + +def run(monkeypatch, book, outdir, *extra) -> int: + """Run the script over the fixture book with credits left at their default. + + Not the runner from the quality tests: that one switches credits off so its + segment counts stay stable, which is exactly what these tests need on. + """ + monkeypatch.setattr( + sys, + "argv", + ["narrate_book.py", str(book), "--voice", "Voix de test", "--outdir", str(outdir), *extra], + ) + return narrate_book.main() + + +def titles_of(outdir: Path) -> list[str]: + return (outdir / "titles.txt").read_text(encoding="utf-8").strip().split("\n") + + +def chapters_of(outdir: Path) -> list[Path]: + return sorted(outdir.glob("chapitre_*.wav")) + + +class TestCreditsArePresent: + def test_the_book_is_wrapped_in_its_credits(self, monkeypatch, book, tmp_path): + outdir = tmp_path / "out" + assert run(monkeypatch, book, outdir, "--title", "Le Livre", "--author", "Une Autrice") == 0 + + titles = titles_of(outdir) + assert titles[0] == credits.OPENING_TITLE + assert titles[-1] == credits.CLOSING_TITLE + # Two chapters of book, plus the two credits. + assert len(chapters_of(outdir)) == 4 + + def test_the_credits_name_the_work(self, monkeypatch, book, tmp_path): + run(monkeypatch, book, tmp_path / "out", "--title", "Le Livre", "--author", "Une Autrice") + spoken = " ".join(text for text, _ in CALLS) + assert "Le Livre" in spoken + assert "Une Autrice" in spoken + assert "Vous venez d'écouter" in spoken + + def test_they_are_read_in_the_same_voice_as_the_book(self, monkeypatch, book, tmp_path): + """Same seed everywhere is what keeps one narrator across the file.""" + run(monkeypatch, book, tmp_path / "out", "--title", "Le Livre") + seeds = {seed for _, seed in CALLS} + assert len(seeds) == 1 + + def test_a_named_narrator_is_credited(self, monkeypatch, book, tmp_path): + run(monkeypatch, book, tmp_path / "out", "--title", "Le Livre", "--narrator", "Edwin") + spoken = " ".join(text for text, _ in CALLS) + assert "Edwin" in spoken + assert credits.SYNTHETIC_DISCLOSURE not in spoken + + def test_an_unnamed_narrator_is_disclosed_as_synthetic(self, monkeypatch, book, tmp_path): + run(monkeypatch, book, tmp_path / "out", "--title", "Le Livre") + spoken = " ".join(text for text, _ in CALLS) + assert credits.SYNTHETIC_DISCLOSURE in spoken + + def test_public_domain_is_stated_when_asked(self, monkeypatch, book, tmp_path): + run(monkeypatch, book, tmp_path / "out", "--title", "Le Livre", "--public-domain") + assert "domaine public" in " ".join(text for text, _ in CALLS) + + +class TestCreditsCanBeRefused: + def test_no_credits_leaves_the_book_alone(self, monkeypatch, book, tmp_path): + outdir = tmp_path / "out" + assert run(monkeypatch, book, outdir, "--no-credits") == 0 + assert len(chapters_of(outdir)) == 2 + spoken = " ".join(text for text, _ in CALLS) + assert "Vous venez d'écouter" not in spoken + + +class TestDeliveredShape: + """Every written file has to satisfy ACX on shape, not only on level.""" + + def test_every_chapter_carries_its_room_tone(self, monkeypatch, book, tmp_path): + outdir = tmp_path / "out" + run(monkeypatch, book, outdir, "--title", "Le Livre", "--author", "Une Autrice") + + for path in chapters_of(outdir): + data, sample_rate = sf.read(str(path), dtype="float32") + report = audio.acx_report(data, sample_rate) + assert report["head_room_ok"], (path.name, report["head_room_sec"]) + assert report["tail_room_ok"], (path.name, report["tail_room_sec"]) + + def test_the_credits_are_mastered_like_the_book(self, monkeypatch, book, tmp_path): + outdir = tmp_path / "out" + run(monkeypatch, book, outdir, "--title", "Le Livre", "--author", "Une Autrice") + + levels = [] + for path in chapters_of(outdir): + data, sample_rate = sf.read(str(path), dtype="float32") + levels.append(audio.speech_rms_db(data, sample_rate)) + # No file stands out: the credits went through the same loudness pass. + assert max(levels) - min(levels) < 1.0 diff --git a/tests/test_narrate_book_qc.py b/tests/test_narrate_book_qc.py index f320298d..99b3264a 100644 --- a/tests/test_narrate_book_qc.py +++ b/tests/test_narrate_book_qc.py @@ -99,6 +99,12 @@ def book(tmp_path): def run(monkeypatch, book, outdir, *extra) -> int: + """Run the script over the fixture book, credits off. + + These tests count segments and inspect their quality; the opening and + closing credits are two more chapters of real narration, and letting them in + would tie every count here to their wording. + """ argv = [ "narrate_book.py", str(book), @@ -106,6 +112,7 @@ def run(monkeypatch, book, outdir, *extra) -> int: "Voix de test", "--outdir", str(outdir), + "--no-credits", *extra, ] monkeypatch.setattr(sys, "argv", argv) diff --git a/tests/test_narration_audio.py b/tests/test_narration_audio.py index 681b8a1d..609c7b8e 100644 --- a/tests/test_narration_audio.py +++ b/tests/test_narration_audio.py @@ -50,10 +50,17 @@ def test_input_shorter_than_one_analysis_frame(self): class TestAcxReport: + @staticmethod + def delivered(lead=0.75, tail=2.0): + """A chapter shaped the way it would leave the mastering stage.""" + speech = np.concatenate([sine(2.0), audio.silence(SR, 1.5), sine(2.0)]) + mastered, _ = audio.normalize_level(speech, SR, target_rms_db=-20.0) + return np.concatenate( + [audio.silence(SR, lead), mastered, audio.silence(SR, tail)] + ) + def test_a_correctly_mastered_signal_passes(self): - signal = np.concatenate([sine(2.0), audio.silence(SR, 1.5), sine(2.0)]) - mastered, _ = audio.normalize_level(signal, SR, target_rms_db=-20.0) - report = audio.acx_report(mastered, SR) + report = audio.acx_report(self.delivered(), SR) assert report["compliant"], report def test_a_too_loud_signal_is_flagged(self): @@ -65,6 +72,39 @@ def test_a_too_loud_signal_is_flagged(self): def test_duration_is_reported(self): assert audio.acx_report(sine(3.0), SR)["duration_sec"] == pytest.approx(3.0, abs=0.01) + def test_a_file_opening_on_its_first_syllable_is_flagged(self): + """Correct levels, wrong shape — rejected at review all the same.""" + report = audio.acx_report(self.delivered(lead=0.0), SR) + assert not report["head_room_ok"] + assert report["rms_ok"] + assert not report["compliant"] + + def test_a_file_ending_on_its_last_syllable_is_flagged(self): + report = audio.acx_report(self.delivered(tail=0.1), SR) + assert not report["tail_room_ok"] + assert not report["compliant"] + + def test_too_much_room_tone_is_flagged_too(self): + """The windows have an upper bound: dead air is a defect as well.""" + assert not audio.acx_report(self.delivered(lead=3.0), SR)["head_room_ok"] + assert not audio.acx_report(self.delivered(tail=9.0), SR)["tail_room_ok"] + + def test_room_tone_is_measured_in_seconds(self): + head, tail = audio.room_tone_sec(self.delivered(lead=0.75, tail=2.0), SR) + assert head == pytest.approx(0.75, abs=0.05) + assert tail == pytest.approx(2.0, abs=0.05) + + def test_silence_only_is_all_head_room(self): + head, tail = audio.room_tone_sec(audio.silence(SR, 3.0), SR) + assert head == pytest.approx(3.0, abs=0.01) + assert tail == 0.0 + + def test_mastering_defaults_land_inside_the_acx_windows(self): + """The defaults must produce a compliant file without being tuned.""" + settings = audio.MasteringSettings() + assert audio.ACX_HEAD_ROOM_MIN_SEC <= settings.lead_sec <= audio.ACX_HEAD_ROOM_MAX_SEC + assert audio.ACX_TAIL_ROOM_MIN_SEC <= settings.tail_sec <= audio.ACX_TAIL_ROOM_MAX_SEC + class TestNormalizeLevel: def test_reaches_the_target(self): diff --git a/tests/test_narration_credits.py b/tests/test_narration_credits.py new file mode 100644 index 00000000..7946a196 --- /dev/null +++ b/tests/test_narration_credits.py @@ -0,0 +1,116 @@ +"""Tests for the opening and closing credits. + +What is checked is what a distributor checks: that the title, the author and +the narrator are actually said, at both ends, and that a synthetic reading says +so rather than passing for a performance. +""" +import pytest + +from narration.credits import CLOSING_TITLE, OPENING_TITLE, SYNTHETIC_DISCLOSURE, BookCredits + + +class TestOpening: + def test_it_names_the_work_and_its_author(self): + opening = BookCredits(title="Autour de la Lune", author="Jules Verne").opening() + assert "Autour de la Lune" in opening + assert "Jules Verne" in opening + + def test_a_human_narrator_is_named(self): + opening = BookCredits( + title="Le Livre", author="Une Autrice", narrator="Edwin Osayamwen" + ).opening() + assert "Lu par Edwin Osayamwen." in opening + + def test_a_subtitle_is_announced(self): + opening = BookCredits( + title="Le Livre", subtitle="une histoire vraie", author="Une Autrice" + ).opening() + assert "une histoire vraie" in opening + + def test_an_author_already_in_the_title_is_not_said_twice(self): + opening = BookCredits( + title="Autour de la Lune, par Jules Verne", author="Jules Verne" + ).opening() + assert opening.count("Jules Verne") == 1 + + +class TestClosing: + def test_it_names_the_work_again(self): + closing = BookCredits(title="Le Livre", author="Une Autrice").closing() + assert closing.startswith("Vous venez d'écouter") + assert "Le Livre" in closing + assert "Une Autrice" in closing + + def test_production_is_credited_when_given(self): + closing = BookCredits( + title="Le Livre", author="Une Autrice", publisher="Studio X", year="2026" + ).closing() + assert "Studio X" in closing and "2026" in closing + + def test_a_year_alone_still_reads_as_a_sentence(self): + closing = BookCredits(title="Le Livre", year="2026").closing() + assert "réalisé en 2026." in closing + + def test_public_domain_is_stated(self): + closing = BookCredits( + title="Autour de la Lune", author="Jules Verne", public_domain=True + ).closing() + assert "domaine public" in closing + + +class TestSyntheticDisclosure: + """Claiming a machine reading as a human performance is what closes accounts.""" + + def test_a_synthetic_reading_says_so_at_both_ends(self): + credits = BookCredits(title="Le Livre", author="Une Autrice") + assert SYNTHETIC_DISCLOSURE in credits.opening() + assert SYNTHETIC_DISCLOSURE in credits.closing() + + def test_a_named_narrator_replaces_the_disclosure(self): + credits = BookCredits(title="Le Livre", narrator="Edwin") + assert SYNTHETIC_DISCLOSURE not in credits.opening() + assert "Edwin" in credits.opening() + + def test_it_is_on_unless_deliberately_turned_off(self): + assert BookCredits(title="x").disclose_synthetic is True + silent = BookCredits(title="x", disclose_synthetic=False) + assert silent.narrator_credit == "" + assert "Lu par" not in silent.opening() + + +class TestDistributionReadiness: + def test_a_complete_set_of_credits_is_ready(self): + credits = BookCredits(title="Le Livre", author="Une Autrice", narrator="Edwin") + assert credits.missing_for_distribution() == [] + + def test_a_synthetic_reading_counts_as_credited(self): + """The disclosure *is* the narrator credit.""" + assert BookCredits(title="Le Livre", author="Une Autrice").missing_for_distribution() == [] + + def test_what_is_missing_is_named(self): + missing = BookCredits(title="", author="").missing_for_distribution() + assert any("titre" in item for item in missing) + assert any("auteur" in item for item in missing) + + def test_an_undisclosed_synthetic_reading_is_reported_as_uncredited(self): + credits = BookCredits(title="Le Livre", author="Une Autrice", disclose_synthetic=False) + assert any("narrateur" in item for item in credits.missing_for_distribution()) + + +class TestShape: + def test_credits_are_narratable_prose(self): + """No markup, no lists — this text goes straight to the engine.""" + credits = BookCredits(title="Le Livre", author="Une Autrice", publisher="Studio X") + for text in (credits.opening(), credits.closing()): + assert text.strip() == text + assert "<" not in text and "*" not in text + for paragraph in text.split("\n\n"): + assert paragraph.endswith(".") + + def test_a_book_with_no_metadata_still_says_something(self): + opening = BookCredits(title="").opening() + assert opening.strip() + + def test_the_two_files_have_stable_names(self): + assert OPENING_TITLE and CLOSING_TITLE + assert OPENING_TITLE != CLOSING_TITLE From 0adb16d4247c36ef45c5712bcd580400e48d76df Mon Sep 17 00:00:00 2001 From: PaxHelios Date: Fri, 31 Jul 2026 14:04:48 +0200 Subject: [PATCH 28/98] feat(delivery): produce the folder a distributor actually accepts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An M4B is what you listen to. It is not what you upload. ACX takes one file per chapter at a fixed specification, plus a retail sample, and rejects the lot over details that have nothing to do with how the narration sounds. This fork produced a 96 kbps MP3 or a 64 kbps M4B of the whole book: unusable for a submission, however good the reading. scripts/export_acx.py turns finished chapters into that folder, and narration/delivery.py holds the parts worth testing on their own: - 192 kbps CBR MP3 at 44.1 kHz, mono, resampled at encode time. Constant bitrate is why -b:a carries no quality flag; a VBR file is refused whatever it sounds like. - The duration limit is computed, not assumed. 120 minutes and 170 MB are close enough at 192 kbps to swap places, so whichever binds first decides (~118 min). The size cap is read in its strictest sense, because being under a limit that turns out to be looser costs one extra file and being over one costs a rejected submission. - A chapter past that limit is cut in a pause, never mid-word — at the *last* pause before the limit, not the quietest moment in the window, which can be a dip inside a sentence twenty seconds earlier. The search never reaches back past 60% of the limit, which is what stops a chapter with no pause at all from being split into thousands of empty files. - A retail sample of 1 to 5 minutes, taken from the first real chapter and never from the credits: nobody decides on hearing the title read out. - Every file checked against the whole specification, with the reasons in plain French, and a non-zero exit so this fits a pipeline. Without ffmpeg the WAVs and the exact commands are still written. Hours of synthesis must not be held hostage to a missing binary. Verified end to end on real generated audio, which the check correctly refused: never mastered as chapters, it has no room tone and sits outside the loudness window. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Fity58qgKttpD1nLheWrzy --- docs/GUIDE_FR.md | 3 + docs/NARRATION.md | 57 ++++++ narration/__init__.py | 2 + narration/delivery.py | 321 +++++++++++++++++++++++++++++++ scripts/export_acx.py | 287 +++++++++++++++++++++++++++ tests/test_export_acx.py | 170 ++++++++++++++++ tests/test_narration_delivery.py | 208 ++++++++++++++++++++ 7 files changed, 1048 insertions(+) create mode 100644 narration/delivery.py create mode 100644 scripts/export_acx.py create mode 100644 tests/test_export_acx.py create mode 100644 tests/test_narration_delivery.py diff --git a/docs/GUIDE_FR.md b/docs/GUIDE_FR.md index 6a539df6..e774c74b 100644 --- a/docs/GUIDE_FR.md +++ b/docs/GUIDE_FR.md @@ -111,6 +111,9 @@ python scripts/narrate_book.py livre.epub --voice "Narrateur profond & calme" -- # Assembler des chapitres déjà générés python scripts/assemble_audiobook.py output/book_mon_livre --title "Mon Livre" --check + +# Préparer le dossier à déposer chez un distributeur (ACX/Audible, Amazon…) +python scripts/export_acx.py output/book_mon_livre ``` **→ Le guide détaillé est dans [docs/NARRATION.md](NARRATION.md)** : vitesse selon le diff --git a/docs/NARRATION.md b/docs/NARRATION.md index 5ba45c9a..95cfdc6d 100644 --- a/docs/NARRATION.md +++ b/docs/NARRATION.md @@ -30,6 +30,7 @@ chacune dans un module de `narration/` — testable et utilisable indépendammen | **3. Synthèse** | moteur VoxCPM2 | Même seed partout → voix identique du début à la fin | | **4. Mastering** | `narration/audio.py` | Rogne les silences parasites, supprime les clics aux jointures, insère les pauses, normalise la sonie **une fois par chapitre** | | **5. Assemblage** | `narration/assemble.py` | Réunit les chapitres en un seul M4B/MP3 avec marqueurs de chapitres | +| **6. Livraison** | `narration/delivery.py` | Découpe, échantillonne et encode les fichiers qu'un distributeur accepte (MP3 192 kbps CBR, 44,1 kHz) | Entre les étapes 2 et 3, un **cache par segment** (`narration/cache.py`) rend la narration reprenable : voir plus bas. @@ -353,6 +354,62 @@ Les titres de chapitres viennent, dans l'ordre : de `--titles`, puis d'un fichie `titles.txt` à côté des WAV (écrit automatiquement par `narrate_book.py` à partir de la première ligne de chaque chapitre), puis des noms de fichiers. +## Déposer chez un distributeur (ACX, Audible, Amazon…) + +Le M4B est ce qu'on écoute. **Ce n'est pas ce qu'on dépose.** ACX — et les +plateformes qui s'alignent dessus — prend **un fichier par chapitre**, encodé à +une spécification fixe, plus un extrait commercial, et refuse l'ensemble pour des +détails qui n'ont rien à voir avec la qualité de la narration. + +``` +.\.venv\Scripts\python.exe scripts\export_acx.py output\book_mon_livre +``` + +``` +output/book_mon_livre/ -> output/book_mon_livre/acx/ + chapitre_001.wav 001 - Generique de debut.mp3 + chapitre_002.wav 002 - Chapitre premier.mp3 + ... ... + titles.txt extrait_commercial.mp3 + rapport_acx.json +``` + +Ce que le script fait : + +1. **Contrôle chaque chapitre** sur toute la spécification — sonie, crête, bruit + de fond, silence aux deux bouts, durée, taille — et dit lesquels reviendraient, + avec la raison en clair. +2. **Découpe ce qui est trop long**, dans une pause et non au milieu d'un mot. + La limite est calculée, pas supposée : à 192 kbps constant, les 120 minutes et + les 170 Mo se croisent, et c'est le plus contraignant des deux qui décide + (~118 min). +3. **Extrait un extrait commercial** de 1 à 5 min du premier vrai chapitre — + jamais du générique : un acheteur ne se décide pas en entendant le titre. +4. **Encode en MP3 192 kbps CBR, 44,1 kHz, mono**, ce qui demande ffmpeg. + +**Sans ffmpeg, rien n'est perdu** : les WAV sont écrits, les commandes +d'encodage sont listées dans `acx/encoder.txt`, et l'encodage peut se faire plus +tard ou sur une autre machine. Des heures de synthèse ne doivent pas dépendre +d'un binaire manquant. + +| Option | Effet | +|---|---| +| `--check` | Contrôle et n'écrit rien | +| `--sample-seconds 240` | Longueur de l'extrait (60 à 300 s) | +| `--sample-start 60` | Démarre l'extrait plus loin dans le chapitre | +| `--sample-chapter 4` | Choisit le chapitre à échantillonner | +| `--no-sample` | Pas d'extrait | +| `--keep-wav` | Garde les WAV intermédiaires | + +Le script **sort en code d'erreur** s'il reste un fichier hors norme, ce qui le +rend utilisable dans un enchaînement automatisé. + +À savoir : le **44,1 kHz est une exigence de format**, pas un gain de qualité — +la synthèse ne produit pas cette fréquence, le rééchantillonnage se fait à +l'encodage. Et la limite de taille est lue dans son sens le plus strict +(170 × 10⁶ octets) : être sous une limite qui s'avère plus large coûte un +fichier de plus, être au-dessus coûte un dépôt refusé. + ## Prononciation : lexique personnalisé `conf/pronunciation_fr.json` associe ce qui est écrit à ce qui doit être prononcé. diff --git a/narration/__init__.py b/narration/__init__.py index 719ce0b7..4e62ed1b 100644 --- a/narration/__init__.py +++ b/narration/__init__.py @@ -17,6 +17,7 @@ audio trim, master and stitch the generated segments repair re-roll one segment and restitch its chapter, from a saved plan assemble join chapters into a single MP3/M4B with chapter markers + delivery cut, sample and encode the files a distributor accepts """ __all__ = [ @@ -25,6 +26,7 @@ "cache", "chunking", "credits", + "delivery", "epub", "quality", "repair", diff --git a/narration/delivery.py b/narration/delivery.py new file mode 100644 index 00000000..0347abeb --- /dev/null +++ b/narration/delivery.py @@ -0,0 +1,321 @@ +"""Turn finished chapters into the folder a distributor actually accepts. + +An M4B is what you listen to. It is not what you upload. ACX — and every +platform that mirrors it — takes **one file per chapter**, encoded to a fixed +specification, plus a short retail sample, and rejects the lot over details that +have nothing to do with how the narration sounds: + +* **192 kbps CBR MP3 at 44.1 kHz.** Not 96 kbps, not variable bitrate, whatever + the synthesis sample rate was. The resampling happens at encode time. +* **No file longer than 120 minutes, and none larger than 170 MB.** At 192 kbps + constant bitrate those two limits are close enough to swap places — two hours + comes to about 173 MB — so which one binds is computed rather than assumed, + and the split follows whichever is tighter. +* **A retail sample of 1 to 5 minutes**, taken from the book itself. + +This module prepares all of that. What it does *not* do is encode: that needs +ffmpeg, which may not be installed, and the hours of synthesis behind a book +must not be held hostage to a missing binary. Every function here works on +audio and paths and hands back the exact command to run, exactly as +:mod:`narration.assemble` does. + +The cut points matter more than they look. A chapter split at a fixed offset +lands mid-word; a sample that ends at its target second stops mid-sentence. Both +are cut at the quietest moment in a window around the target instead, which is +where the narrator was drawing breath. +""" +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import List, Optional, Sequence, Tuple + +import numpy as np + +from . import audio as audio_tools + +__all__ = [ + "ACX_MAX_FILE_BYTES", + "ACX_PROFILE", + "DeliveryProfile", + "SAMPLE_MAX_SEC", + "SAMPLE_MIN_SEC", + "check_delivered", + "encode_command", + "max_seconds_for", + "retail_sample", + "split_for_delivery", +] + +#: Largest file a distributor accepts. ACX writes "170 MB" without saying +#: whether it means 170 x 1000^2 or 170 x 1024^2 bytes; the smaller reading is +#: taken, because being under a limit that turns out to be larger costs one +#: extra file and being over one costs a rejected submission. +ACX_MAX_FILE_BYTES = 170 * 1000 * 1000 +#: Retail sample bounds, in seconds. +SAMPLE_MIN_SEC = 60.0 +SAMPLE_MAX_SEC = 300.0 +#: What a sample aims for when nothing else is asked: long enough to judge the +#: voice, short enough that nobody stops it halfway. +SAMPLE_TARGET_SEC = 150.0 + +#: How far back from a limit the cut may wander to land in a pause, in seconds. +_CUT_WINDOW_SEC = 20.0 +#: Frame resolution used to hunt for that pause. +_CUT_FRAME_MS = 50.0 +#: A frame this far below the loudest of the window is a pause, not a dip. +_PAUSE_DEPTH_DB = 25.0 +#: The cut never reaches further back than this fraction of the limit, so a +#: chapter with no pause at all still advances instead of emitting empty files. +_CUT_FLOOR_FRACTION = 0.6 + + +@dataclass(frozen=True) +class DeliveryProfile: + """An encoding a distributor will accept, and the limits that come with it.""" + + name: str + codec: str + bitrate_kbps: int + sample_rate: int + channels: int + suffix: str + max_seconds: float + max_bytes: int + + @property + def bytes_per_second(self) -> float: + """Constant bitrate makes file size a straight function of duration.""" + return self.bitrate_kbps * 1000.0 / 8.0 + + +#: The ACX specification, which Amazon, Apple Books, Kobo and Google Play follow. +ACX_PROFILE = DeliveryProfile( + name="ACX", + codec="libmp3lame", + bitrate_kbps=192, + sample_rate=44100, + channels=1, + suffix=".mp3", + max_seconds=float(audio_tools.ACX_MAX_FILE_SEC), + max_bytes=ACX_MAX_FILE_BYTES, +) + + +def max_seconds_for(profile: DeliveryProfile = ACX_PROFILE) -> float: + """Longest a single delivered file may run under *both* limits. + + The clock says 120 minutes and the file size says about 118 at 192 kbps. + Whichever binds first is the one that decides, so it is computed rather + than assumed. + """ + from_size = profile.max_bytes / profile.bytes_per_second + return min(profile.max_seconds, from_size) + + +def _pause_before(wav: np.ndarray, sr: int, limit: int, window: int) -> int: + """The last pause at or before ``limit``, in samples. + + Backwards only, never past the limit: this places a cut that must not + exceed a duration or a file size, and a pause two seconds after the limit + is a pause that puts the file over it. + + The *last* silent frame, not the quietest one. A chapter's quietest moment + can be a dip inside a sentence twenty seconds earlier; taking it would throw + away capacity for no reason. Only when no frame in the window is properly + silent does the quietest one stand in. + + The search never reaches back past :data:`_CUT_FLOOR_FRACTION` of the limit, + which is what guarantees the caller makes progress: a cut at sample one + would loop forever producing empty files. + """ + limit = int(min(max(limit, 0), wav.size)) + start = max(int(limit * _CUT_FLOOR_FRACTION), limit - window) + if limit - start <= 1: + return limit + + hop_ms = _CUT_FRAME_MS / 2.0 + levels = audio_tools.frame_rms_db( + wav[start:limit], sr, frame_ms=_CUT_FRAME_MS, hop_ms=hop_ms + ) + if levels.size == 0: + return limit + + quiet = np.flatnonzero(levels < float(levels.max()) - _PAUSE_DEPTH_DB) + index = int(quiet[-1]) if quiet.size else int(np.argmin(levels)) + hop = max(1, int(sr * hop_ms / 1000.0)) + return int(min(start + index * hop, limit)) + + +def split_for_delivery( + wav: np.ndarray, + sr: int, + profile: DeliveryProfile = ACX_PROFILE, + *, + max_seconds: Optional[float] = None, +) -> List[np.ndarray]: + """Cut a chapter into parts no delivered file limit can reject. + + A chapter that already fits comes back as a single part, untouched — the + common case, and it must stay bit-for-bit what the mastering produced. + + Each part is cut in a pause and given the room tone ACX expects at the two + ends, since each part becomes a file in its own right. + """ + wav = audio_tools.as_float_mono(wav) + limit = float(max_seconds if max_seconds is not None else max_seconds_for(profile)) + if wav.size == 0 or limit <= 0 or wav.size / sr <= limit: + return [wav] + + # Each part becomes a file of its own and gets room tone at both ends, so + # the budget for actual narration is the limit minus that tone. Without + # this the parts come out fitting the limit and the files do not. + settings = audio_tools.MasteringSettings() + room = settings.lead_sec + settings.tail_sec + budget = limit - room + if budget <= 0: + # A limit shorter than its own room tone cannot be honoured; keep the + # audio whole rather than emit a pile of near-empty files. + return [wav] + + parts: List[np.ndarray] = [] + remaining = wav + window = int(sr * _CUT_WINDOW_SEC) + while remaining.size / sr > budget: + cut = _pause_before(remaining, sr, int(sr * budget), window) + cut = min(max(cut, 1), remaining.size - 1) + parts.append(remaining[:cut]) + remaining = remaining[cut:] + parts.append(remaining) + + return [ + np.concatenate( + [ + audio_tools.silence(sr, settings.lead_sec), + audio_tools.trim_silence(part, sr), + audio_tools.silence(sr, settings.tail_sec), + ] + ) + for part in parts + ] + + +def retail_sample( + wav: np.ndarray, + sr: int, + *, + target_seconds: float = SAMPLE_TARGET_SEC, + start_seconds: float = 0.0, +) -> np.ndarray: + """A 1-to-5 minute excerpt, ending where the narrator paused. + + Taken from the book rather than from the credits: a sample is what a buyer + listens to before deciding, and nobody decides on hearing the title read + out. The caller picks the chapter; this picks where to stop inside it. + """ + wav = audio_tools.as_float_mono(wav) + if wav.size == 0: + return wav + + target = float(np.clip(target_seconds, SAMPLE_MIN_SEC, SAMPLE_MAX_SEC)) + begin = int(max(0.0, start_seconds) * sr) + if begin >= wav.size: + begin = 0 + + body = wav[begin:] + if body.size / sr > target: + window = int(sr * _CUT_WINDOW_SEC) + end = _pause_before(body, sr, int(sr * target), window) + body = body[: max(1, end)] + + settings = audio_tools.MasteringSettings() + return np.concatenate( + [ + audio_tools.silence(sr, settings.lead_sec), + audio_tools.fade_edges(audio_tools.trim_silence(body, sr), sr), + audio_tools.silence(sr, settings.tail_sec), + ] + ) + + +def encode_command( + wav_path: str | Path, + out_path: str | Path, + profile: DeliveryProfile = ACX_PROFILE, +) -> List[str]: + """The ffmpeg invocation producing one delivery-ready file. + + ``-b:a`` without any quality flag is what makes libmp3lame constant-bitrate; + a variable-bitrate file is rejected however good it sounds. + """ + return [ + "ffmpeg", + "-y", + "-i", + str(wav_path), + "-c:a", + profile.codec, + "-b:a", + f"{profile.bitrate_kbps}k", + "-ar", + str(profile.sample_rate), + "-ac", + str(profile.channels), + # Neither a VBR header nor an encoder tag belongs in a delivered file. + "-write_xing", + "0", + "-map_metadata", + "-1", + str(out_path), + ] + + +def check_delivered( + wav: np.ndarray, + sr: int, + profile: DeliveryProfile = ACX_PROFILE, + *, + encoded_bytes: Optional[int] = None, +) -> dict: + """Everything a distributor checks about one file, in one report. + + Levels and room tone come from :func:`narration.audio.acx_report`; what is + added here is the pair of limits that only exist once the file is a file — + its duration against the clock, and its size against the 170 MB cap. The + size is predicted from the constant bitrate when the file is not encoded + yet, which is the whole point of checking before spending the encode. + """ + report = dict(audio_tools.acx_report(wav, sr)) + duration = report["duration_sec"] + size = ( + float(encoded_bytes) + if encoded_bytes is not None + else duration * profile.bytes_per_second + ) + report.update( + { + "profile": profile.name, + "encoded_bytes": size, + "encoded_estimated": encoded_bytes is None, + "duration_ok": duration <= profile.max_seconds, + "size_ok": size <= profile.max_bytes, + } + ) + report["compliant"] = all( + value for key, value in report.items() if key.endswith("_ok") + ) + return report + + +def failures(report: dict) -> List[str]: + """Readable reasons a file would be sent back, in the order they matter.""" + reasons = { + "rms_ok": "sonie hors de la fenêtre -23..-18 dBFS", + "peak_ok": "crête au-dessus de -3 dBFS", + "noise_floor_ok": "bruit de fond au-dessus de -60 dBFS", + "head_room_ok": "silence de tête hors de 0,5-1 s", + "tail_room_ok": "silence de queue hors de 1-5 s", + "duration_ok": "fichier de plus de 120 min", + "size_ok": "fichier de plus de 170 Mo", + } + return [text for key, text in reasons.items() if report.get(key) is False] diff --git a/scripts/export_acx.py b/scripts/export_acx.py new file mode 100644 index 00000000..e808ed1c --- /dev/null +++ b/scripts/export_acx.py @@ -0,0 +1,287 @@ +"""Prepare the folder an audiobook distributor accepts, from finished chapters. + +``narrate_book.py`` produces WAV chapters and, with ``--assemble``, one M4B to +listen to. Neither is what ACX, Audible, Amazon, Apple Books or Kobo take. They +take **one file per chapter**, encoded to a fixed specification, plus a retail +sample — and they reject a submission over details that have nothing to do with +how the narration sounds. + +This script turns the one into the other:: + + output/book_mon_livre/ -> output/book_mon_livre/acx/ + chapitre_001.wav 001 - Generique de debut.mp3 + chapitre_002.wav 002 - Chapitre premier.mp3 + ... ... + titles.txt extrait_commercial.mp3 + rapport_acx.json + +What it does +------------ +1. **Checks every chapter** against the whole specification — loudness, peak, + noise floor, room tone at both ends, duration, file size — and says which + ones would come back, with the reason in plain French. +2. **Splits what is too long.** A chapter over the duration or size limit is cut + into parts, in a pause rather than mid-word, each part shaped like a file of + its own. +3. **Extracts a retail sample** of 1 to 5 minutes from the first real chapter, + never from the credits: a sample is what a buyer decides on, and nobody + decides on hearing the title read out. +4. **Encodes to 192 kbps CBR MP3 at 44.1 kHz**, which needs ffmpeg. + +Without ffmpeg the first three steps still run, the WAVs are written, and the +exact commands to encode them later are printed. Hours of synthesis must never +be held hostage to a missing binary. + +Examples +-------- + # Check without producing anything: + ./.venv/Scripts/python.exe scripts/export_acx.py output/book_mon_livre --check + + # Full export: + ./.venv/Scripts/python.exe scripts/export_acx.py output/book_mon_livre + + # A longer sample, taken further into the chapter: + ./.venv/Scripts/python.exe scripts/export_acx.py output/book_mon_livre \ + --sample-seconds 240 --sample-start 60 +""" +from __future__ import annotations + +import argparse +import json +import re +import subprocess +import sys +import unicodedata +from pathlib import Path +from typing import List, Optional, Sequence, Tuple + +import numpy as np +import soundfile as sf + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from narration import assemble as assembly # noqa: E402 +from narration import audio as audio_tools # noqa: E402 +from narration import credits as credits_tools # noqa: E402 +from narration import delivery # noqa: E402 + +#: Where the delivery files land inside the book directory. +DEFAULT_SUBDIR = "acx" +SAMPLE_STEM = "extrait_commercial" +REPORT_NAME = "rapport_acx.json" + + +def ascii_filename(text: str, fallback: str) -> str: + """A file name that survives every upload form. + + Distribution portals are not reliably at ease with accents in file names, + and a rejected upload over "Générique" is a silly way to lose an evening. + """ + stripped = unicodedata.normalize("NFKD", text or "") + stripped = stripped.encode("ascii", "ignore").decode("ascii") + cleaned = re.sub(r"[^\w\s-]", "", stripped).strip() + cleaned = re.sub(r"\s+", " ", cleaned) + return cleaned[:60] or fallback + + +def read_titles(directory: Path, count: int) -> List[str]: + """Chapter titles from titles.txt, padded to the number of chapters.""" + path = directory / "titles.txt" + titles: List[str] = [] + if path.is_file(): + # utf-8-sig, because a titles.txt edited on Windows arrives with a byte + # order mark glued to its first title — enough to stop the opening + # credits being recognised as credits, and so to sample them. + titles = [line.strip() for line in path.read_text(encoding="utf-8-sig").splitlines()] + titles = [title for title in titles if title] + while len(titles) < count: + titles.append(f"Chapitre {len(titles) + 1}") + return titles[:count] + + +def is_credit(title: str) -> bool: + """Whether a chapter is one of the two credit files.""" + return title.strip() in (credits_tools.OPENING_TITLE, credits_tools.CLOSING_TITLE) + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter + ) + parser.add_argument("directory", help="Directory holding the chapter .wav files") + parser.add_argument("--out", help=f"Output directory (default: /{DEFAULT_SUBDIR})") + parser.add_argument("--pattern", default="chapitre_*.wav", + help="Glob selecting the chapters (default: chapitre_*.wav)") + parser.add_argument("--check", action="store_true", + help="Report compliance and write nothing") + parser.add_argument("--no-sample", action="store_true", + help="Do not extract a retail sample") + parser.add_argument("--sample-seconds", type=float, default=delivery.SAMPLE_TARGET_SEC, + help=f"Retail sample length, 60-300 s (default: {delivery.SAMPLE_TARGET_SEC:.0f})") + parser.add_argument("--sample-start", type=float, default=0.0, + help="Seconds into the chapter the sample starts (default: 0)") + parser.add_argument("--sample-chapter", type=int, + help="1-based chapter to sample (default: the first that is not a credit)") + parser.add_argument("--keep-wav", action="store_true", + help="Keep the intermediate WAV of each delivered file") + return parser + + +def describe(report: dict) -> str: + """One line saying whether a file passes, and why not when it does not.""" + if report["compliant"]: + return ( + f"OK — {report['duration_sec'] / 60:.1f} min, " + f"RMS {report['rms_db']:.1f} dBFS, " + f"~{report['encoded_bytes'] / 1e6:.0f} Mo" + ) + return "HORS NORME — " + " ; ".join(delivery.failures(report)) + + +def encode(wav_path: Path, out_path: Path, ffmpeg: Optional[str]) -> Tuple[bool, List[str]]: + """Encode one file, or hand back the command when ffmpeg is missing.""" + command = delivery.encode_command(wav_path, out_path) + if not ffmpeg: + return False, command + command[0] = ffmpeg + result = subprocess.run(command, capture_output=True, text=True) + if result.returncode != 0: + print(f" échec de l'encodage : {result.stderr.strip().splitlines()[-1:]}") + return False, command + return True, command + + +def main() -> int: + args = build_parser().parse_args() + + directory = Path(args.directory) + if not directory.is_dir(): + raise SystemExit(f"Directory not found: {directory}") + + chapter_paths = sorted(p for p in directory.glob(args.pattern) if p.is_file()) + if not chapter_paths: + raise SystemExit(f"No chapter files matching {args.pattern!r} in {directory}") + + titles = read_titles(directory, len(chapter_paths)) + outdir = Path(args.out) if args.out else directory / DEFAULT_SUBDIR + ffmpeg = assembly.find_ffmpeg() + profile = delivery.ACX_PROFILE + limit = delivery.max_seconds_for(profile) + + print(f"Source : {directory}") + print(f"Chapitres : {len(chapter_paths)}") + print(f"Norme : {profile.name} — MP3 {profile.bitrate_kbps} kbps CBR, " + f"{profile.sample_rate} Hz, {profile.channels} canal") + print(f"Limite : {limit / 60:.0f} min par fichier " + f"({profile.max_bytes / 1e6:.0f} Mo)") + if not args.check: + print(f"Sortie : {outdir}") + if not ffmpeg and not args.check: + print("ffmpeg absent : les WAV et les commandes d'encodage seront produits.") + print() + + if not args.check: + outdir.mkdir(parents=True, exist_ok=True) + + entries: List[dict] = [] + commands: List[List[str]] = [] + failures = 0 + sample_source: Optional[Tuple[np.ndarray, int, str]] = None + + for index, path in enumerate(chapter_paths, 1): + title = titles[index - 1] + data, sample_rate = sf.read(str(path), dtype="float32") + data = audio_tools.as_float_mono(data) + parts = delivery.split_for_delivery(data, sample_rate, profile) + + wanted = args.sample_chapter == index if args.sample_chapter else not is_credit(title) + if sample_source is None and wanted: + sample_source = (data, sample_rate, title) + + for part_number, part in enumerate(parts, 1): + suffix = f" (partie {part_number})" if len(parts) > 1 else "" + label = f"{len(entries) + 1:03d} - {ascii_filename(title + suffix, f'Chapitre {index}')}" + report = delivery.check_delivered(part, sample_rate, profile) + print(f" {label}: {describe(report)}") + if not report["compliant"]: + failures += 1 + + entry = { + "file": label + profile.suffix, + "source": path.name, + "title": title + suffix, + **{key: value for key, value in report.items() if key != "profile"}, + } + entries.append(entry) + + if args.check: + continue + + wav_path = outdir / (label + ".wav") + sf.write(str(wav_path), part, sample_rate, subtype="PCM_16") + done, command = encode(wav_path, outdir / (label + profile.suffix), ffmpeg) + if done: + encoded = (outdir / (label + profile.suffix)).stat().st_size + entry.update( + delivery.check_delivered( + part, sample_rate, profile, encoded_bytes=encoded + ) + ) + entry["file"] = label + profile.suffix + if not args.keep_wav: + wav_path.unlink(missing_ok=True) + else: + commands.append(command) + + if not args.no_sample and sample_source is not None: + data, sample_rate, title = sample_source + sample = delivery.retail_sample( + data, + sample_rate, + target_seconds=args.sample_seconds, + start_seconds=args.sample_start, + ) + report = delivery.check_delivered(sample, sample_rate, profile) + print(f"\n {SAMPLE_STEM}: {sample.size / sample_rate / 60:.1f} min, tiré de « {title} » — " + f"{describe(report)}") + entries.append({"file": SAMPLE_STEM + profile.suffix, "title": "Extrait commercial", **report}) + if not args.check: + wav_path = outdir / (SAMPLE_STEM + ".wav") + sf.write(str(wav_path), sample, sample_rate, subtype="PCM_16") + done, command = encode(wav_path, outdir / (SAMPLE_STEM + profile.suffix), ffmpeg) + if done and not args.keep_wav: + wav_path.unlink(missing_ok=True) + elif not done: + commands.append(command) + + if not args.check: + (outdir / REPORT_NAME).write_text( + json.dumps( + {"profile": profile.name, "limit_seconds": limit, "files": entries}, + ensure_ascii=False, + indent=2, + ), + encoding="utf-8", + ) + + print() + if failures: + print(f"{failures} fichier(s) hors norme — voir les raisons ci-dessus.") + else: + print(f"Les {len(entries)} fichier(s) satisfont la norme {profile.name}.") + + if commands: + print(f"\nffmpeg absent : {len(commands)} fichier(s) restent à encoder. Par exemple :") + print(" " + " ".join(commands[0])) + script = outdir / "encoder.txt" + if not args.check: + script.write_text( + "\n".join(" ".join(command) for command in commands) + "\n", encoding="utf-8" + ) + print(f"Toutes les commandes sont dans {script}") + + return 1 if failures else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_export_acx.py b/tests/test_export_acx.py new file mode 100644 index 00000000..a604765f --- /dev/null +++ b/tests/test_export_acx.py @@ -0,0 +1,170 @@ +"""End-to-end tests of scripts/export_acx.py. + +ffmpeg is not assumed to exist — on the machine this was written on it does +not. That is precisely the case worth testing: the WAVs and the encode commands +must still be produced, because the hours of synthesis behind a book cannot +depend on a binary being installed. +""" +from __future__ import annotations + +import importlib.util +import json +import sys +from pathlib import Path + +import numpy as np +import pytest +import soundfile as sf + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +from narration import audio, credits, delivery # noqa: E402 + +spec = importlib.util.spec_from_file_location("export_acx", ROOT / "scripts" / "export_acx.py") +export_acx = importlib.util.module_from_spec(spec) +assert spec.loader is not None +spec.loader.exec_module(export_acx) + +SR = 22050 + + +def chapter_audio(seconds: float) -> np.ndarray: + """A mastered-looking chapter: room tone, speech at level, room tone.""" + rng = np.random.default_rng(int(seconds * 100)) + samples = max(1, int(SR * seconds)) + body = rng.normal(0.0, 1.0, samples).astype(np.float32) + body, _ = audio.normalize_level(body, SR, target_rms_db=-20.0) + settings = audio.MasteringSettings() + return np.concatenate( + [audio.silence(SR, settings.lead_sec), body, audio.silence(SR, settings.tail_sec)] + ) + + +@pytest.fixture +def book(tmp_path): + """A narrated book on disk: credits, two chapters, titles.txt.""" + directory = tmp_path / "book_test" + directory.mkdir() + titles = [credits.OPENING_TITLE, "Chapitre premier", "Chapitre second", credits.CLOSING_TITLE] + for index, seconds in enumerate([2.0, 8.0, 8.0, 2.0], start=1): + sf.write(str(directory / f"chapitre_{index:03d}.wav"), chapter_audio(seconds), SR, + subtype="PCM_16") + (directory / "titles.txt").write_text("\n".join(titles) + "\n", encoding="utf-8") + return directory + + +def run(monkeypatch, *argv) -> int: + monkeypatch.setattr(sys, "argv", ["export_acx.py", *argv]) + return export_acx.main() + + +def report_of(outdir: Path) -> dict: + return json.loads((outdir / export_acx.REPORT_NAME).read_text(encoding="utf-8")) + + +class TestExport: + def test_every_chapter_becomes_a_delivered_file(self, monkeypatch, book, capsys): + assert run(monkeypatch, str(book)) == 0 + files = report_of(book / "acx")["files"] + # Four chapters plus the retail sample. + assert len(files) == 5 + + def test_the_report_records_the_whole_specification(self, monkeypatch, book): + run(monkeypatch, str(book)) + entry = report_of(book / "acx")["files"][0] + for key in ("rms_db", "peak_db", "noise_floor_db", "head_room_sec", + "tail_room_sec", "duration_sec", "encoded_bytes", "compliant"): + assert key in entry + + def test_file_names_survive_an_upload_form(self, monkeypatch, book): + """Accents in a file name are a silly way to lose an evening.""" + run(monkeypatch, str(book)) + names = [entry["file"] for entry in report_of(book / "acx")["files"]] + assert any(name.startswith("001 - Generique de debut") for name in names) + for name in names: + assert name.isascii(), name + + def test_the_order_of_the_book_is_kept(self, monkeypatch, book): + run(monkeypatch, str(book)) + names = [entry["file"] for entry in report_of(book / "acx")["files"]] + assert names[:4] == sorted(names[:4]) + + +class TestRetailSample: + def test_a_sample_is_taken_from_the_book_not_the_credits(self, monkeypatch, book): + run(monkeypatch, str(book)) + files = report_of(book / "acx")["files"] + sample = [entry for entry in files if entry["title"] == "Extrait commercial"] + assert len(sample) == 1 + + def test_it_can_be_refused(self, monkeypatch, book): + run(monkeypatch, str(book), "--no-sample") + files = report_of(book / "acx")["files"] + assert all(entry["title"] != "Extrait commercial" for entry in files) + + def test_a_chapter_can_be_chosen_explicitly(self, monkeypatch, book): + assert run(monkeypatch, str(book), "--sample-chapter", "3") == 0 + + def test_a_byte_order_mark_does_not_hide_the_credits(self, monkeypatch, book): + """A titles.txt edited on Windows arrives with a BOM on its first line. + + Left in, the opening credits stop matching their own name — and the + retail sample ends up being the title read out loud. + """ + titles = (book / "titles.txt").read_text(encoding="utf-8") + (book / "titles.txt").write_text(chr(0xFEFF) + titles, encoding="utf-8") + run(monkeypatch, str(book)) + files = report_of(book / "acx")["files"] + assert files[0]["title"] == credits.OPENING_TITLE + + +class TestWithoutFfmpeg: + """The machine this was written on has no ffmpeg. Neither may the user's.""" + + @pytest.fixture(autouse=True) + def no_ffmpeg(self, monkeypatch): + monkeypatch.setattr(export_acx.assembly, "find_ffmpeg", lambda: None) + + def test_the_audio_is_still_produced(self, monkeypatch, book): + run(monkeypatch, str(book)) + assert sorted(p.name for p in (book / "acx").glob("*.wav")) + + def test_the_commands_to_finish_are_written_down(self, monkeypatch, book): + run(monkeypatch, str(book)) + script = (book / "acx" / "encoder.txt").read_text(encoding="utf-8") + assert "libmp3lame" in script + assert script.count("ffmpeg") == 5 + + def test_it_says_so_rather_than_failing_silently(self, monkeypatch, book, capsys): + run(monkeypatch, str(book)) + assert "ffmpeg absent" in capsys.readouterr().out + + +class TestCheckOnly: + def test_check_writes_nothing(self, monkeypatch, book): + run(monkeypatch, str(book), "--check") + assert not (book / "acx").exists() + + def test_check_still_reports_every_file(self, monkeypatch, book, capsys): + run(monkeypatch, str(book), "--check") + out = capsys.readouterr().out + assert "Generique de debut" in out + assert "satisfont la norme" in out or "hors norme" in out + + def test_a_defective_chapter_is_reported_and_exits_non_zero(self, monkeypatch, book, capsys): + """A chapter with no room tone passes on level and fails on shape.""" + raw = np.concatenate([chapter_audio(3.0)[int(SR * 0.75):]]) + sf.write(str(book / "chapitre_002.wav"), raw, SR, subtype="PCM_16") + assert run(monkeypatch, str(book), "--check") == 1 + assert "silence de tête" in capsys.readouterr().out + + +class TestSplitting: + def test_an_over_long_chapter_becomes_several_files(self, monkeypatch, book): + """The limit is the distributor's; here it is forced down to test it.""" + monkeypatch.setattr(delivery, "max_seconds_for", lambda profile=None: 4.0) + run(monkeypatch, str(book)) + titles = [entry["title"] for entry in report_of(book / "acx")["files"]] + assert any("partie 1" in title for title in titles) + assert any("partie 2" in title for title in titles) diff --git a/tests/test_narration_delivery.py b/tests/test_narration_delivery.py new file mode 100644 index 00000000..f33dd46b --- /dev/null +++ b/tests/test_narration_delivery.py @@ -0,0 +1,208 @@ +"""Tests for preparing the folder a distributor accepts. + +No ffmpeg is involved: what is under test is the audio work (where a file is +cut, how long a sample runs) and the exact command that would be handed to the +encoder — which is what has to be right, since the encode itself may happen on +another machine entirely. +""" +import numpy as np +import pytest + +from narration import audio, delivery + +SR = 22050 # deliberately not 44100: the profile has to resample, not assume + + +def speech(seconds: float, level: float = 0.2) -> np.ndarray: + """Signal that reads as speech to the level and pause detectors.""" + samples = max(1, int(SR * seconds)) + rng = np.random.default_rng(int(seconds * 1000) % (2**32)) + syllables = max(2, int(seconds * 5)) + envelope = np.interp( + np.linspace(0.0, syllables, samples), + np.arange(syllables + 1), + rng.uniform(0.4, 1.0, syllables + 1), + ) + body = (rng.normal(0.0, 1.0, samples) * envelope).astype(np.float32) + return body * (level / max(float(np.max(np.abs(body))), 1e-9)) + + +def with_pause_at(seconds_before: float, pause: float, seconds_after: float) -> np.ndarray: + """Speech, a clear pause, then more speech — a cut point that is obvious.""" + return np.concatenate( + [speech(seconds_before), audio.silence(SR, pause), speech(seconds_after)] + ) + + +class TestLimits: + def test_the_binding_limit_is_computed_not_assumed(self): + """At 192 kbps CBR, 170 MB runs out before the 120-minute clock does.""" + limit = delivery.max_seconds_for(delivery.ACX_PROFILE) + assert limit < audio.ACX_MAX_FILE_SEC + assert limit == pytest.approx( + delivery.ACX_MAX_FILE_BYTES / delivery.ACX_PROFILE.bytes_per_second + ) + + def test_the_profile_is_the_acx_specification(self): + profile = delivery.ACX_PROFILE + assert profile.bitrate_kbps >= 192 + assert profile.sample_rate == 44100 + assert profile.suffix == ".mp3" + + +class TestSplitting: + def test_a_chapter_that_fits_is_returned_untouched(self): + chapter = speech(3.0) + parts = delivery.split_for_delivery(chapter, SR) + assert len(parts) == 1 + assert np.array_equal(parts[0], chapter) + + def test_an_over_long_chapter_is_cut_into_parts(self): + chapter = speech(10.0) + parts = delivery.split_for_delivery(chapter, SR, max_seconds=4.0) + assert len(parts) >= 3 + assert all(part.size / SR <= 4.0 for part in parts) + + def test_nothing_is_lost_in_the_split(self): + """Every second of narration has to survive into some part.""" + chapter = speech(10.0) + parts = delivery.split_for_delivery(chapter, SR, max_seconds=4.0) + rooms = audio.MasteringSettings() + room_per_part = (rooms.lead_sec + rooms.tail_sec) * len(parts) + total = sum(part.size for part in parts) / SR + assert total >= 10.0 - 1.0 + assert total <= 10.0 + room_per_part + 1.0 + + def test_the_cut_lands_in_the_pause(self): + """Cutting mid-word is the failure this is written to prevent.""" + chapter = with_pause_at(3.0, 1.0, 3.0) + rooms = audio.MasteringSettings() + # A limit leaving a 4-second budget, so the 7-second chapter must be + # split and the pause at 3 s falls inside the backwards search window. + parts = delivery.split_for_delivery( + chapter, SR, max_seconds=4.0 + rooms.lead_sec + rooms.tail_sec + ) + assert len(parts) == 2 + spoken = audio.trim_silence(parts[0], SR).size / SR + assert 2.5 <= spoken <= 4.3, spoken + + def test_every_part_is_shaped_like_a_delivered_file(self): + parts = delivery.split_for_delivery(speech(10.0), SR, max_seconds=4.0) + for part in parts: + head, tail = audio.room_tone_sec(part, SR) + assert head >= audio.ACX_HEAD_ROOM_MIN_SEC + assert tail >= audio.ACX_TAIL_ROOM_MIN_SEC + + def test_empty_audio_does_not_explode(self): + parts = delivery.split_for_delivery(np.zeros(0, dtype=np.float32), SR) + assert len(parts) == 1 + assert parts[0].size == 0 + + def test_a_limit_shorter_than_its_own_room_tone_keeps_the_audio_whole(self): + """Better one over-long file than a pile of files made of silence.""" + chapter = speech(10.0) + parts = delivery.split_for_delivery(chapter, SR, max_seconds=1.0) + assert len(parts) == 1 + assert np.array_equal(parts[0], chapter) + + +class TestRetailSample: + def test_it_lands_inside_the_one_to_five_minute_window(self): + sample = delivery.retail_sample(speech(400.0), SR, target_seconds=120.0) + duration = sample.size / SR + assert delivery.SAMPLE_MIN_SEC <= duration <= delivery.SAMPLE_MAX_SEC + + def test_a_target_outside_the_window_is_pulled_back_into_it(self): + too_long = delivery.retail_sample(speech(700.0), SR, target_seconds=600.0) + assert too_long.size / SR <= delivery.SAMPLE_MAX_SEC + 3.0 + + def test_a_short_chapter_gives_a_short_sample_rather_than_an_error(self): + sample = delivery.retail_sample(speech(20.0), SR) + assert sample.size > 0 + assert sample.size / SR < 30.0 + + def test_it_stops_at_a_pause(self): + """It stops at the last pause before the target, not on the target.""" + chapter = with_pause_at(55.0, 1.5, 60.0) + sample = delivery.retail_sample(chapter, SR, target_seconds=60.0) + # Ends in the 55-second pause rather than 5 seconds into the next + # sentence — and not 10 seconds early either. + assert 53.0 <= sample.size / SR <= 61.0, sample.size / SR + + def test_it_can_start_further_in(self): + chapter = speech(300.0) + late = delivery.retail_sample(chapter, SR, target_seconds=60.0, start_seconds=120.0) + assert late.size > 0 + + def test_it_is_shaped_like_a_delivered_file(self): + sample = delivery.retail_sample(speech(200.0), SR, target_seconds=90.0) + head, tail = audio.room_tone_sec(sample, SR) + assert head >= audio.ACX_HEAD_ROOM_MIN_SEC + assert tail >= audio.ACX_TAIL_ROOM_MIN_SEC + + +class TestEncodeCommand: + def test_it_asks_for_constant_bitrate_mp3(self): + command = delivery.encode_command("in.wav", "out.mp3") + assert "libmp3lame" in command + assert "192k" in command + # A quality flag would make it variable bitrate, which is rejected. + assert "-q:a" not in command + + def test_it_resamples_to_the_required_rate(self): + command = delivery.encode_command("in.wav", "out.mp3") + assert command[command.index("-ar") + 1] == "44100" + + def test_it_delivers_mono(self): + command = delivery.encode_command("in.wav", "out.mp3") + assert command[command.index("-ac") + 1] == "1" + + def test_the_paths_are_where_ffmpeg_expects_them(self): + command = delivery.encode_command("chapitre.wav", "sortie/001.mp3") + assert command[command.index("-i") + 1] == "chapitre.wav" + assert command[-1] == "sortie/001.mp3" + + +class TestComplianceReport: + @staticmethod + def delivered(seconds=5.0): + settings = audio.MasteringSettings() + body, _ = audio.normalize_level(speech(seconds), SR, target_rms_db=-20.0) + return np.concatenate( + [ + audio.silence(SR, settings.lead_sec), + body, + audio.silence(SR, settings.tail_sec), + ] + ) + + def test_a_well_made_file_passes_everything(self): + report = delivery.check_delivered(self.delivered(), SR) + assert report["compliant"], report + assert delivery.failures(report) == [] + + def test_size_is_predicted_before_the_encode(self): + report = delivery.check_delivered(self.delivered(60.0), SR) + assert report["encoded_estimated"] is True + assert report["encoded_bytes"] == pytest.approx( + report["duration_sec"] * delivery.ACX_PROFILE.bytes_per_second, rel=0.01 + ) + + def test_a_measured_size_replaces_the_estimate(self): + report = delivery.check_delivered(self.delivered(), SR, encoded_bytes=1234) + assert report["encoded_estimated"] is False + assert report["encoded_bytes"] == 1234 + + def test_an_oversized_file_is_refused(self): + report = delivery.check_delivered( + self.delivered(), SR, encoded_bytes=delivery.ACX_MAX_FILE_BYTES + 1 + ) + assert not report["size_ok"] + assert not report["compliant"] + assert any("170 Mo" in reason for reason in delivery.failures(report)) + + def test_failures_are_named_in_french_for_the_operator(self): + report = delivery.check_delivered(speech(2.0), SR) # no room tone at all + reasons = delivery.failures(report) + assert reasons + assert any("silence de tête" in reason for reason in reasons) From cc700dde5459cd9eb81ef80bfc0ed65f66532c6a Mon Sep 17 00:00:00 2001 From: PaxHelios Date: Fri, 31 Jul 2026 14:20:19 +0200 Subject: [PATCH 29/98] feat(app): check a book against distribution standards from the tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The delivery rules were only reachable from a script, which is not where the book gets narrated. The audiobook tab now has a compliance button that reads the finished chapters and says, chapter by chapter, what a distributor would accept or send back — loudness, room tone at both ends, duration, predicted file size — with the reasons in plain French. It reads and encodes nothing. ffmpeg may not be installed, and the answer worth having before an evening spent uploading is whether the files pass, not the MP3s themselves; the full export stays one command away and is printed under the table. narrate_book.py also takes --export-acx, so a book goes from text to the folder that gets uploaded in one command. It runs the exporter as a subprocess rather than importing it: a book that narrated for nine hours must not lose its chapters to an exception raised while preparing the delivery. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Fity58qgKttpD1nLheWrzy --- app.py | 75 +++++++++++++++++++++++++++++- docs/NARRATION.md | 8 +++- scripts/narrate_book.py | 18 ++++++- tests/test_narrate_book_credits.py | 22 +++++++++ 4 files changed, 119 insertions(+), 4 deletions(-) diff --git a/app.py b/app.py index 77728e32..8757d460 100644 --- a/app.py +++ b/app.py @@ -28,7 +28,7 @@ from narration import audio as audio_tools from narration import cache as cache_tools from narration import epub as epub_reader -from narration import chunking, credits, quality, repair, text_fr +from narration import chunking, credits, delivery, quality, repair, text_fr logging.basicConfig( level=logging.INFO, @@ -223,6 +223,7 @@ "book_plan_label": "Plan", "book_generate_btn": "📖 Narrate the book", "book_assemble_btn": "📦 Assemble the audiobook", + "book_check_btn": "✅ Check against distribution standards", "book_format_label": "Format", "book_status_label": "Progress", "book_audio_label": "Last finished chapter", @@ -300,6 +301,7 @@ "book_plan_label": "Plan", "book_generate_btn": "📖 Narrer le livre", "book_assemble_btn": "📦 Assembler le livre audio", + "book_check_btn": "✅ Vérifier la conformité de dépôt", "book_format_label": "Format", "book_status_label": "Avancement", "book_audio_label": "Dernier chapitre terminé", @@ -1383,6 +1385,68 @@ def _book_assemble(title, author, output_format): delivered = result.output_path or result.wav_path return "\n".join(message), str(delivered) + def _book_check_delivery(title): + """Check the finished chapters against what a distributor accepts. + + Reading only — no encoding, because ffmpeg may not be installed and + because the answer worth having before spending an evening on an + upload is *whether* the files pass, not the MP3s themselves. + """ + outdir = _book_dir(title) + chapter_files = sorted(outdir.glob("chapitre_*.wav")) + if not chapter_files: + raise gr.Error(f"Aucun chapitre trouvé dans {outdir}. Lancez d'abord la narration.") + + titles = [] + titles_path = outdir / "titles.txt" + if titles_path.is_file(): + # utf-8-sig: a titles.txt edited on Windows carries a byte order mark. + titles = [ + line.strip() + for line in titles_path.read_text(encoding="utf-8-sig").splitlines() + if line.strip() + ] + + profile = delivery.ACX_PROFILE + limit = delivery.max_seconds_for(profile) + rows, failing, total_sec = [], 0, 0.0 + for index, path in enumerate(chapter_files, 1): + data, sample_rate = sf.read(str(path), dtype="float32") + parts = delivery.split_for_delivery(data, sample_rate, profile) + name = titles[index - 1] if index <= len(titles) else path.stem + for part_number, part in enumerate(parts, 1): + report = delivery.check_delivered(part, sample_rate, profile) + total_sec += report["duration_sec"] + suffix = f" (partie {part_number})" if len(parts) > 1 else "" + reasons = delivery.failures(report) + if reasons: + failing += 1 + rows.append( + f"| {name[:40]}{suffix} | {report['duration_sec'] / 60:.1f} | " + f"{report['rms_db']:.1f} | {report['head_room_sec']:.2f} | " + f"{report['tail_room_sec']:.2f} | " + f"{'✅' if not reasons else '❌ ' + ' ; '.join(reasons)} |" + ) + + header = [ + f"### Conformité {profile.name} — {len(rows)} fichier(s), " + f"{total_sec / 60:.0f} min\n", + f"MP3 {profile.bitrate_kbps} kbps CBR · {profile.sample_rate} Hz · " + f"mono · ≤ {limit / 60:.0f} min par fichier\n", + "| Fichier | min | RMS dBFS | tête s | queue s | Verdict |", + "|---|---|---|---|---|---|", + ] + footer = ( + f"\n**{failing} fichier(s) hors norme.**" + if failing + else "\n**Tous les fichiers satisfont la norme.**" + ) + footer += ( + "\n\nPour produire le dossier à déposer (MP3 encodés + extrait commercial) :\n\n" + f"```\npython scripts/export_acx.py {outdir}\n```" + ) + return "\n".join(header + rows) + footer + def _on_toggle_instant(checked): """Instant UI toggle — no ASR, no blocking.""" if checked: @@ -1652,6 +1716,7 @@ def _run_asr_if_needed(checked, audio_path): scale=1, ) book_assemble_btn = gr.Button(I18N("book_assemble_btn"), scale=2) + book_check_btn = gr.Button(I18N("book_check_btn"), scale=2) with gr.Accordion(I18N("book_repair_title"), open=False): gr.Markdown(I18N("book_repair_info")) @@ -1845,6 +1910,14 @@ def _run_asr_if_needed(checked, audio_path): api_name="repair_book_segment", ) + book_check_btn.click( + fn=_book_check_delivery, + inputs=[book_title], + outputs=[book_status], + show_progress=True, + api_name="check_delivery", + ) + book_assemble_btn.click( fn=_book_assemble, inputs=[book_title, book_author, book_format], diff --git a/docs/NARRATION.md b/docs/NARRATION.md index 95cfdc6d..f32075a2 100644 --- a/docs/NARRATION.md +++ b/docs/NARRATION.md @@ -82,7 +82,9 @@ lancer l'app (ou export sous bash). abréviation mal interprétés — avant d'engager des heures de calcul. 4. Clique **« 📖 Narrer le livre »**. Chaque chapitre terminé est écrit sur disque et devient écoutable immédiatement ; l'avancement s'affiche au fur et à mesure. -5. Clique **« 📦 Assembler le livre audio »** pour obtenir un fichier unique. +5. Clique **« 📦 Assembler le livre audio »** pour obtenir un fichier unique, + et **« ✅ Vérifier la conformité de dépôt »** pour savoir, chapitre par + chapitre, ce qu'un distributeur accepterait ou renverrait. ### 2. Script `narrate_book.py` — pour un livre entier en ligne de commande @@ -365,6 +367,10 @@ détails qui n'ont rien à voir avec la qualité de la narration. .\.venv\Scripts\python.exe scripts\export_acx.py output\book_mon_livre ``` +Ou d'un seul trait depuis le texte : ajoute `--export-acx` à `narrate_book.py`. +Le contrôle seul, sans rien produire, est aussi dans l'onglet **📚 Livre audio**, +bouton **« ✅ Vérifier la conformité de dépôt »**. + ``` output/book_mon_livre/ -> output/book_mon_livre/acx/ chapitre_001.wav 001 - Generique de debut.mp3 diff --git a/scripts/narrate_book.py b/scripts/narrate_book.py index 26266102..fcdf96f9 100644 --- a/scripts/narrate_book.py +++ b/scripts/narrate_book.py @@ -49,6 +49,7 @@ import argparse import json import os +import subprocess import sys import tempfile import time @@ -147,6 +148,9 @@ def build_parser() -> argparse.ArgumentParser: run.add_argument("--dry-run", action="store_true", help="Show the plan, generate nothing") run.add_argument("--assemble", nargs="?", const="m4b", choices=["m4b", "m4a", "mp3", "wav"], help="Assemble the chapters into one chaptered file when done") + run.add_argument("--export-acx", action="store_true", + help="Prepare the folder a distributor accepts (one file per chapter, " + "192 kbps CBR MP3, retail sample) once the narration is done") run.add_argument("--title", default="", help="Book title (assembled file, and credits)") run.add_argument("--author", default="", help="Author (assembled file, and credits)") @@ -448,14 +452,24 @@ def render(current_seed, _segment=segment): print(f"Durée totale : {result.duration_sec / 60:.1f} min") print(result.message) if result.pending_command: - import subprocess - print("À exécuter une fois ffmpeg installé :") print(" " + subprocess.list2cmdline(result.pending_command)) else: print("Astuce : ajoutez --assemble m4b pour produire un fichier unique avec chapitres, " "ou lancez scripts/assemble_audiobook.py plus tard.") + # ---- deliver ------------------------------------------------------- + if args.export_acx: + # Run as a subprocess rather than imported: the exporter is a script in + # its own right, and a book that narrated for nine hours must not lose + # its chapters to an exception raised while preparing the delivery. + print() + result = subprocess.run( + [sys.executable, str(Path(__file__).with_name("export_acx.py")), str(outdir)] + ) + if result.returncode: + print("Export : des fichiers sont hors norme, voir ci-dessus.") + if args.qc_strict and defective: print(f"--qc-strict : {defective} segment(s) toujours défectueux.") return 1 diff --git a/tests/test_narrate_book_credits.py b/tests/test_narrate_book_credits.py index 9adc4d6b..f0375b79 100644 --- a/tests/test_narrate_book_credits.py +++ b/tests/test_narrate_book_credits.py @@ -92,6 +92,28 @@ def test_no_credits_leaves_the_book_alone(self, monkeypatch, book, tmp_path): assert "Vous venez d'écouter" not in spoken +class TestChainedExport: + def test_export_acx_produces_the_delivery_folder(self, monkeypatch, book, tmp_path): + """One command from text to the folder that gets uploaded.""" + outdir = tmp_path / "out" + run(monkeypatch, book, outdir, "--title", "Le Livre", "--export-acx") + + acx = outdir / "acx" + assert acx.is_dir() + assert (acx / "rapport_acx.json").is_file() + + def test_a_failed_export_does_not_lose_the_chapters(self, monkeypatch, book, tmp_path): + """Nine hours of narration must survive anything the exporter does.""" + outdir = tmp_path / "out" + monkeypatch.setattr( + narrate_book.subprocess, + "run", + lambda *a, **k: type("Result", (), {"returncode": 1})(), + ) + assert run(monkeypatch, book, outdir, "--title", "Le Livre", "--export-acx") == 0 + assert sorted(outdir.glob("chapitre_*.wav")) + + class TestDeliveredShape: """Every written file has to satisfy ACX on shape, not only on level.""" From c4b4e23f516f3b1b61345d65aeae12737b8077cf Mon Sep 17 00:00:00 2001 From: PaxHelios Date: Fri, 31 Jul 2026 14:26:15 +0200 Subject: [PATCH 30/98] feat(assemble): let the assembled file choose its bitrate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The encoder settings were hard-coded, so an M4B was 64 kbps AAC and nothing else. That default is right and stays: 64k AAC mono is around what Audible itself streams for a finished audiobook, and speech gains very little above it. What was wrong is that it could not be changed — an archive copy, or a file that will be re-encoded downstream, is worth more. --bitrate on both scripts and a Débit menu in the audiobook tab. The MP3 default moves from 96k to 128k, which is what MP3 needs to match the AAC at 64. A bitrate is accepted however the user writes it (128, "128", "128k") and nonsense is refused here rather than handed to ffmpeg to fail on later. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Fity58qgKttpD1nLheWrzy --- app.py | 16 ++++++++++-- docs/NARRATION.md | 6 +++++ narration/assemble.py | 42 +++++++++++++++++++++++++++++--- scripts/assemble_audiobook.py | 2 ++ scripts/narrate_book.py | 4 +++ tests/test_narration_assemble.py | 36 +++++++++++++++++++++++++++ 6 files changed, 100 insertions(+), 6 deletions(-) diff --git a/app.py b/app.py index 8757d460..3f5ff61b 100644 --- a/app.py +++ b/app.py @@ -225,6 +225,8 @@ "book_assemble_btn": "📦 Assemble the audiobook", "book_check_btn": "✅ Check against distribution standards", "book_format_label": "Format", + "book_bitrate_label": "Bitrate", + "book_bitrate_info": "64k AAC is what Audible itself streams; speech gains little above it. Raise it for an archive copy.", "book_status_label": "Progress", "book_audio_label": "Last finished chapter", "book_file_output_label": "Assembled file", @@ -303,6 +305,8 @@ "book_assemble_btn": "📦 Assembler le livre audio", "book_check_btn": "✅ Vérifier la conformité de dépôt", "book_format_label": "Format", + "book_bitrate_label": "Débit", + "book_bitrate_info": "64k AAC est ce qu'Audible diffuse lui-même ; la parole gagne peu au-dessus. À monter pour une copie d'archive.", "book_status_label": "Avancement", "book_audio_label": "Dernier chapitre terminé", "book_file_output_label": "Fichier assemblé", @@ -1354,7 +1358,7 @@ def render(seed): ) return "\n".join(lines), str(rebuilt.path) - def _book_assemble(title, author, output_format): + def _book_assemble(title, author, output_format, bitrate=""): """Join the generated chapters into one chaptered file.""" outdir = _book_dir(title) chapter_files = sorted(outdir.glob("chapitre_*.wav")) @@ -1367,6 +1371,7 @@ def _book_assemble(title, author, output_format): target, title=title or outdir.name, author=author or "", + bitrate=bitrate or None, ) message = [ f"### Assemblage\n", @@ -1715,6 +1720,13 @@ def _run_asr_if_needed(checked, audio_path): label=I18N("book_format_label"), scale=1, ) + book_bitrate = gr.Dropdown( + choices=["64k", "96k", "128k", "192k", "256k"], + value="64k", + label=I18N("book_bitrate_label"), + info=I18N("book_bitrate_info"), + scale=1, + ) book_assemble_btn = gr.Button(I18N("book_assemble_btn"), scale=2) book_check_btn = gr.Button(I18N("book_check_btn"), scale=2) @@ -1920,7 +1932,7 @@ def _run_asr_if_needed(checked, audio_path): book_assemble_btn.click( fn=_book_assemble, - inputs=[book_title, book_author, book_format], + inputs=[book_title, book_author, book_format, book_bitrate], outputs=[book_status, book_output_file], show_progress=True, api_name="assemble_book", diff --git a/docs/NARRATION.md b/docs/NARRATION.md index f32075a2..079cb9da 100644 --- a/docs/NARRATION.md +++ b/docs/NARRATION.md @@ -352,6 +352,12 @@ produit quand même le WAV complet et le fichier de marqueurs, puis affiche la c exacte à lancer une fois ffmpeg installé. Les heures de synthèse ne sont jamais perdues à cause d'un encodeur manquant. +**Le débit** vaut par défaut **64 kbps AAC** pour un M4B et **128 kbps** pour un MP3. +Ce n'est pas un compromis : 64k AAC mono est à peu près ce qu'Audible diffuse +lui-même pour un livre audio fini, et la parole ne gagne quasiment rien au-dessus. +`--bitrate 192k` (ou le menu **Débit** dans l'onglet) le monte, pour une copie +d'archive ou un fichier qui sera ré-encodé ensuite. + Les titres de chapitres viennent, dans l'ordre : de `--titles`, puis d'un fichier `titles.txt` à côté des WAV (écrit automatiquement par `narrate_book.py` à partir de la première ligne de chaque chapitre), puis des noms de fichiers. diff --git a/narration/assemble.py b/narration/assemble.py index ad724a17..5b625f0a 100644 --- a/narration/assemble.py +++ b/narration/assemble.py @@ -32,15 +32,44 @@ "concat_chapters", "ffmpeg_command", "find_ffmpeg", + "normalize_bitrate", ] -#: Encoder settings per container. Audiobook speech does not benefit from more. +#: Encoder settings per container. +#: +#: 64 kbps AAC mono is not a compromise here: it is at or above what Audible +#: itself streams for a finished audiobook (its enhanced format sits around +#: that figure), and speech gains very little above it. MP3 needs more to +#: sound the same, hence 128. Both are overridable — a book that will be +#: re-encoded downstream, or archived, is worth more. _ENCODERS: Dict[str, Dict[str, str]] = { "m4b": {"codec": "aac", "bitrate": "64k"}, "m4a": {"codec": "aac", "bitrate": "64k"}, - "mp3": {"codec": "libmp3lame", "bitrate": "96k"}, + "mp3": {"codec": "libmp3lame", "bitrate": "128k"}, } + +def normalize_bitrate(bitrate: str | int | None) -> Optional[str]: + """``128``, ``"128"`` and ``"128k"`` all mean the same thing to a user. + + Returns None for anything empty, which the callers read as "keep the + default for this container". + """ + if bitrate is None: + return None + text = str(bitrate).strip().lower() + if not text: + return None + if text.endswith("k"): + text = text[:-1] + try: + value = int(float(text)) + except ValueError as error: + raise ValueError(f"Not a bitrate: {bitrate!r}") from error + if value <= 0: + raise ValueError(f"Not a bitrate: {bitrate!r}") + return f"{value}k" + #: Silence inserted between chapters in the concatenated file, in seconds. DEFAULT_CHAPTER_GAP_SEC = 1.5 @@ -207,10 +236,12 @@ def ffmpeg_command( out_path: str | Path, *, cover_path: Optional[str | Path] = None, + bitrate: str | int | None = None, ) -> List[str]: """The ffmpeg invocation that turns the WAV into the final chaptered file.""" out = Path(out_path) encoder = _ENCODERS.get(out.suffix.lstrip(".").lower(), _ENCODERS["m4b"]) + chosen = normalize_bitrate(bitrate) or encoder["bitrate"] command = ["ffmpeg", "-y", "-i", str(wav_path), "-i", str(metadata_path)] if cover_path: @@ -218,7 +249,7 @@ def ffmpeg_command( command += ["-map", "0:a", "-map_metadata", "1"] if cover_path: command += ["-map", "2:v", "-disposition:v", "attached_pic", "-c:v", "copy"] - command += ["-c:a", encoder["codec"], "-b:a", encoder["bitrate"], "-ac", "1"] + command += ["-c:a", encoder["codec"], "-b:a", chosen, "-ac", "1"] if out.suffix.lower() in (".m4b", ".m4a"): command += ["-movflags", "+faststart"] command.append(str(out)) @@ -235,6 +266,7 @@ def assemble( gap_sec: float = DEFAULT_CHAPTER_GAP_SEC, cover_path: Optional[str | Path] = None, keep_wav: bool = True, + bitrate: str | int | None = None, ) -> AssemblyResult: """Build a single chaptered audiobook from per-chapter WAVs. @@ -271,7 +303,9 @@ def assemble( result.message = "Assembled to WAV (chapter markers written alongside)." return result - command = ffmpeg_command(wav_path, metadata_path, out, cover_path=cover_path) + command = ffmpeg_command( + wav_path, metadata_path, out, cover_path=cover_path, bitrate=bitrate + ) if not find_ffmpeg(): result.pending_command = command result.message = ( diff --git a/scripts/assemble_audiobook.py b/scripts/assemble_audiobook.py index f4f3d881..4c8bf825 100644 --- a/scripts/assemble_audiobook.py +++ b/scripts/assemble_audiobook.py @@ -86,6 +86,7 @@ def main() -> int: parser.add_argument("--author", default="", help="Author / narrator") parser.add_argument("--titles", help="File with one chapter title per line") parser.add_argument("--cover", help="Cover image embedded in the finished file") + parser.add_argument("--bitrate", help="Audio bitrate, e.g. 96, 128k (default: 64k AAC, 128k MP3)") parser.add_argument( "--gap", type=float, @@ -126,6 +127,7 @@ def main() -> int: titles=_read_titles(directory, args.titles), gap_sec=args.gap, cover_path=args.cover, + bitrate=args.bitrate, ) print(f"\nDurée totale : {result.duration_sec / 60:.1f} min ({len(result.chapters)} chapitres)") diff --git a/scripts/narrate_book.py b/scripts/narrate_book.py index fcdf96f9..38b17a32 100644 --- a/scripts/narrate_book.py +++ b/scripts/narrate_book.py @@ -148,6 +148,9 @@ def build_parser() -> argparse.ArgumentParser: run.add_argument("--dry-run", action="store_true", help="Show the plan, generate nothing") run.add_argument("--assemble", nargs="?", const="m4b", choices=["m4b", "m4a", "mp3", "wav"], help="Assemble the chapters into one chaptered file when done") + run.add_argument("--assemble-bitrate", + help="Bitrate of the assembled file, e.g. 96, 128k " + "(default: 64k AAC, 128k MP3)") run.add_argument("--export-acx", action="store_true", help="Prepare the folder a distributor accepts (one file per chapter, " "192 kbps CBR MP3, retail sample) once the narration is done") @@ -448,6 +451,7 @@ def render(current_seed, _segment=segment): title=args.title or in_path.stem, author=args.author, titles=titles, + bitrate=args.assemble_bitrate, ) print(f"Durée totale : {result.duration_sec / 60:.1f} min") print(result.message) diff --git a/tests/test_narration_assemble.py b/tests/test_narration_assemble.py index 281d4158..11bef7d1 100644 --- a/tests/test_narration_assemble.py +++ b/tests/test_narration_assemble.py @@ -130,6 +130,42 @@ def test_a_cover_is_attached_as_a_picture(self, tmp_path): assert "attached_pic" in command +class TestBitrate: + def test_each_container_has_a_default(self, tmp_path): + """64k AAC is what Audible streams; MP3 needs more to sound the same.""" + m4b = assemble.ffmpeg_command(tmp_path / "b.wav", tmp_path / "b.txt", tmp_path / "b.m4b") + mp3 = assemble.ffmpeg_command(tmp_path / "b.wav", tmp_path / "b.txt", tmp_path / "b.mp3") + assert m4b[m4b.index("-b:a") + 1] == "64k" + assert mp3[mp3.index("-b:a") + 1] == "128k" + + def test_it_can_be_raised_for_an_archive_copy(self, tmp_path): + command = assemble.ffmpeg_command( + tmp_path / "b.wav", tmp_path / "b.txt", tmp_path / "b.m4b", bitrate="192k" + ) + assert command[command.index("-b:a") + 1] == "192k" + + def test_a_user_may_write_it_however_they_like(self): + assert assemble.normalize_bitrate(128) == "128k" + assert assemble.normalize_bitrate("128") == "128k" + assert assemble.normalize_bitrate("128k") == "128k" + assert assemble.normalize_bitrate("128K") == "128k" + + def test_nothing_means_keep_the_default(self): + assert assemble.normalize_bitrate(None) is None + assert assemble.normalize_bitrate("") is None + assert assemble.normalize_bitrate(" ") is None + + def test_nonsense_is_refused_rather_than_passed_to_ffmpeg(self): + for value in ("beaucoup", "-64", "0", "12x8"): + with pytest.raises(ValueError): + assemble.normalize_bitrate(value) + + def test_it_reaches_the_encoder_through_assemble(self, chapter_files, tmp_path, monkeypatch): + monkeypatch.setattr(assemble, "find_ffmpeg", lambda: None) + result = assemble.assemble(chapter_files, tmp_path / "livre.m4b", bitrate=96) + assert "96k" in result.pending_command + + class TestAssemble: def test_audio_and_markers_survive_a_missing_ffmpeg(self, chapter_files, tmp_path, monkeypatch): monkeypatch.setattr(assemble, "find_ffmpeg", lambda: None) From 00dc9e0cd02bd018c07491c9c20df4aae16c0d3a Mon Sep 17 00:00:00 2001 From: PaxHelios Date: Fri, 31 Jul 2026 17:04:15 +0200 Subject: [PATCH 31/98] feat(epub): carry the book's cover through to the assembled file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The assembly could embed a cover and the EPUB was carrying one, and nothing connected the two — every M4B produced from a book came out blank, and the only way to get the picture in was to find it by hand inside the archive. extract_cover finds it by the three routes real books use, in order: the EPUB 3 properties="cover-image", the EPUB 2 meta name="cover" pointing into the manifest, and failing both, an image the manifest simply calls something with "cover" in it. The two books tested against declare it two different ways, and one of them both ways at once. The extension comes from the declared media type rather than the file name, because a cover stored as cover.bin is still a JPEG and ffmpeg cares. In the script it happens when assembling an EPUB, overridable with --cover and refusable with --no-cover. In the tab it happens at import, written into the book directory where the assembly already looks. That directory is derived from the title, so a title edited afterwards loses the picture and nothing else. A book with no cover, an unreadable archive and a missing file all return None rather than raise: none of them is a reason to stop. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Fity58qgKttpD1nLheWrzy --- app.py | 15 ++++++ docs/NARRATION.md | 4 ++ narration/epub.py | 91 ++++++++++++++++++++++++++++++++++-- scripts/narrate_book.py | 15 ++++++ tests/test_narration_epub.py | 76 ++++++++++++++++++++++++++++++ 5 files changed, 198 insertions(+), 3 deletions(-) diff --git a/app.py b/app.py index 3f5ff61b..3b152a3a 100644 --- a/app.py +++ b/app.py @@ -874,6 +874,18 @@ def _load_book_file(file_path: Optional[str]): # unwanted: the reader is the one who decides it was boilerplate. if book.removed: status += "\n\n" + "\n".join(f"- 🗑️ {note}" for note in book.removed) + + # The book directory is derived from the title we are about to fill in, + # so the cover can be put where the assembly will look for it. A title + # edited afterwards moves that directory; the cover is then simply not + # found, which costs a picture and nothing else. + try: + cover = epub_reader.extract_cover(file_path, _book_dir(book.title)) + if cover: + status += f"\n\n- 🖼️ Couverture importée (`{cover.name}`)" + except OSError as error: # a read-only or full disk, nothing worse + logger.warning(f"Could not extract the EPUB cover: {error}") + return content, book.title or gr.update(), book.author or gr.update(), status def _generate( @@ -1366,12 +1378,15 @@ def _book_assemble(title, author, output_format, bitrate=""): raise gr.Error(f"Aucun chapitre trouvé dans {outdir}. Lancez d'abord la narration.") target = outdir / f"{outdir.name}_complet.{output_format}" + # Written by the EPUB import, if the book carried one. + covers = sorted(outdir.glob("couverture.*")) result = assembly.assemble( chapter_files, target, title=title or outdir.name, author=author or "", bitrate=bitrate or None, + cover_path=covers[0] if covers else None, ) message = [ f"### Assemblage\n", diff --git a/docs/NARRATION.md b/docs/NARRATION.md index 079cb9da..e1a0fe19 100644 --- a/docs/NARRATION.md +++ b/docs/NARRATION.md @@ -352,6 +352,10 @@ produit quand même le WAV complet et le fichier de marqueurs, puis affiche la c exacte à lancer une fois ffmpeg installé. Les heures de synthèse ne sont jamais perdues à cause d'un encodeur manquant. +**La couverture du livre est reprise automatiquement** quand la source est un +`.epub` : elle est extraite à côté des chapitres (`couverture.jpg`) et intégrée +au M4B. `--cover mon_image.jpg` impose la tienne, `--no-cover` n'en met aucune. + **Le débit** vaut par défaut **64 kbps AAC** pour un M4B et **128 kbps** pour un MP3. Ce n'est pas un compromis : 64k AAC mono est à peu près ce qu'Audible diffuse lui-même pour un livre audio fini, et la parole ne gagne quasiment rien au-dessus. diff --git a/narration/epub.py b/narration/epub.py index 5adc0eb3..bf7cdb07 100644 --- a/narration/epub.py +++ b/narration/epub.py @@ -4,9 +4,10 @@ spine that puts them in reading order. Everything downstream of this module — segmentation, French normalisation, synthesis, assembly — already works on plain chapters separated by ``---``, so the whole job here is to turn a book into that -text and then get out of the way. Nothing is written to disk and nothing is -extracted: entries are read from the archive by name, so a crafted path in a -manifest cannot escape anywhere. +text and then get out of the way. Entries are read from the archive by name and +never unpacked, so a crafted path in a manifest cannot escape anywhere; the one +exception is :func:`extract_cover`, which writes a single image to a path the +caller chose. Four things earn their complexity: @@ -49,6 +50,7 @@ "EpubBook", "EpubChapter", "EpubError", + "extract_cover", "is_epub", "load_book_text", "read_epub", @@ -331,6 +333,89 @@ def _resolve(base: str, href: str) -> str: return posixpath.normpath(joined).lstrip("/") +def _cover_item(opf: ET.Element, manifest: Dict[str, Dict[str, str]]) -> Optional[Dict[str, str]]: + """The manifest entry holding the cover image, by any of the three routes. + + EPUB 3 marks it with ``properties="cover-image"``; EPUB 2 points at it from + a ````; and a book that does neither usually + still calls the file something with "cover" in it. Real books use all three, + sometimes two at once, so all three are tried in that order. + """ + for item in manifest.values(): + if "cover-image" in item["properties"].split(): + return item + + for meta in _iter_local(opf, "meta"): + if _attr(meta, "name").lower() == "cover": + item = manifest.get(_attr(meta, "content")) + if item and item["media_type"].startswith("image/"): + return item + + for item in manifest.values(): + if item["media_type"].startswith("image/") and "cover" in item["path"].lower(): + return item + return None + + +def _cover_suffix(media_type: str, path: str) -> str: + """File extension for the cover, from its declared type or its own name.""" + known = { + "image/jpeg": ".jpg", + "image/jpg": ".jpg", + "image/png": ".png", + "image/gif": ".gif", + "image/webp": ".webp", + "image/svg+xml": ".svg", + } + if media_type in known: + return known[media_type] + suffix = posixpath.splitext(path)[1].lower() + return suffix if suffix else ".img" + + +def extract_cover(path, destination) -> Optional[Path]: + """Write the book's cover image next to its chapters, and return its path. + + Returns None when the EPUB carries no cover, which is common enough not to + be an error — a book without a cover is still a book. + + ``destination`` may be a directory, in which case the file is named after + the image it came from, or a full path. + """ + file_path = Path(path) + if not file_path.is_file(): + return None + + try: + archive = zipfile.ZipFile(file_path) + except zipfile.BadZipFile: + return None + + with archive: + try: + opf_path = _opf_path(archive) + opf = _parse_xml(_read(archive, opf_path), "OPF manifest") + except EpubError: + return None + manifest = _manifest(opf, opf_path) + item = _cover_item(opf, manifest) + if item is None: + return None + try: + data = archive.read(item["path"]) + except KeyError: + return None + + target = Path(destination) + if target.is_dir() or not target.suffix: + target.mkdir(parents=True, exist_ok=True) + target = target / ("couverture" + _cover_suffix(item["media_type"], item["path"])) + else: + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(data) + return target + + def is_epub(path) -> bool: """Whether this path looks like an EPUB, by extension.""" return Path(path).suffix.lower() == ".epub" diff --git a/scripts/narrate_book.py b/scripts/narrate_book.py index 38b17a32..7d2d9232 100644 --- a/scripts/narrate_book.py +++ b/scripts/narrate_book.py @@ -148,6 +148,10 @@ def build_parser() -> argparse.ArgumentParser: run.add_argument("--dry-run", action="store_true", help="Show the plan, generate nothing") run.add_argument("--assemble", nargs="?", const="m4b", choices=["m4b", "m4a", "mp3", "wav"], help="Assemble the chapters into one chaptered file when done") + run.add_argument("--cover", help="Cover image for the assembled file " + "(default: the EPUB's own, when there is one)") + run.add_argument("--no-cover", action="store_true", + help="Do not embed a cover in the assembled file") run.add_argument("--assemble-bitrate", help="Bitrate of the assembled file, e.g. 96, 128k " "(default: 64k AAC, 128k MP3)") @@ -443,6 +447,16 @@ def render(current_seed, _segment=segment): if not chapter_files: print("Rien à assembler.") return 1 if (args.qc_strict and defective) else 0 + # The book carries its own cover; only an explicit --cover beats it. + cover_path = Path(args.cover) if args.cover else None + if cover_path is None and not args.no_cover and epub.is_epub(in_path): + cover_path = epub.extract_cover(in_path, outdir) + if cover_path: + print(f"Couverture : {cover_path.name} (tirée de l'EPUB)") + if cover_path and not cover_path.is_file(): + print(f"Couverture introuvable, ignorée : {cover_path}") + cover_path = None + target = outdir / f"{outdir.name}_complet.{args.assemble}" print(f"\nAssemblage de {len(chapter_files)} chapitre(s) -> {target.name}") result = assembly.assemble( @@ -452,6 +466,7 @@ def render(current_seed, _segment=segment): author=args.author, titles=titles, bitrate=args.assemble_bitrate, + cover_path=cover_path, ) print(f"Durée totale : {result.duration_sec / 60:.1f} min") print(result.message) diff --git a/tests/test_narration_epub.py b/tests/test_narration_epub.py index 4f481435..780c308f 100644 --- a/tests/test_narration_epub.py +++ b/tests/test_narration_epub.py @@ -605,6 +605,82 @@ def test_a_separator_inside_the_book_does_not_split_a_chapter(self, tmp_path): assert len(chunking.split_chapters(text)) == 1 +class TestCover: + """A cover is found by any of the three routes real books use.""" + + COVER = b"\x89PNG\r\n\x1a\n" + b"pixels" + + def with_cover(self, tmp_path, name, manifest_extra, meta="", image="cover.png", + media_type="image/png"): + """An EPUB whose cover is declared the way ``manifest_extra`` says.""" + path = build_epub( + tmp_path / name, + [("ch1.xhtml", document(f"

{LONG}

"))], + ) + with zipfile.ZipFile(path) as archive: + entries = {entry: archive.read(entry) for entry in archive.namelist()} + opf = entries["OEBPS/content.opf"].decode() + opf = opf.replace("", manifest_extra + "") + opf = opf.replace("", meta + "") + entries["OEBPS/content.opf"] = opf.encode() + entries[f"OEBPS/{image}"] = self.COVER + with zipfile.ZipFile(path, "w") as archive: + for entry, data in entries.items(): + archive.writestr(entry, data) + return path + + def test_epub3_marks_it_with_a_property(self, tmp_path): + path = self.with_cover( + tmp_path, "c3.epub", + '', + ) + out = epub.extract_cover(path, tmp_path / "out") + assert out is not None and out.read_bytes() == self.COVER + assert out.suffix == ".png" + + def test_epub2_points_at_it_from_the_metadata(self, tmp_path): + path = self.with_cover( + tmp_path, "c2.epub", + '', + meta='', + ) + assert epub.extract_cover(path, tmp_path / "out2") is not None + + def test_a_book_that_declares_nothing_is_found_by_name(self, tmp_path): + path = self.with_cover( + tmp_path, "c1.epub", + '', + ) + assert epub.extract_cover(path, tmp_path / "out3") is not None + + def test_the_extension_follows_the_declared_type(self, tmp_path): + path = self.with_cover( + tmp_path, "cj.epub", + '', + image="cover.bin", + ) + out = epub.extract_cover(path, tmp_path / "out4") + assert out is not None and out.suffix == ".jpg" + + def test_a_book_without_a_cover_is_not_an_error(self, simple_book, tmp_path): + assert epub.extract_cover(simple_book, tmp_path / "out5") is None + + def test_an_unreadable_file_is_not_an_error_either(self, tmp_path): + broken = tmp_path / "broken.epub" + broken.write_text("pas une archive", encoding="utf-8") + assert epub.extract_cover(broken, tmp_path / "out6") is None + assert epub.extract_cover(tmp_path / "absent.epub", tmp_path / "out7") is None + + def test_an_explicit_path_is_honoured(self, tmp_path): + path = self.with_cover( + tmp_path, "cp.epub", + '', + ) + target = tmp_path / "ailleurs" / "image.png" + assert epub.extract_cover(path, target) == target + assert target.read_bytes() == self.COVER + + class TestHelpers: def test_is_epub(self, tmp_path): assert epub.is_epub("livre.epub") From 7fe91656189b75988ff2b4a59c3eccdab443a219 Mon Sep 17 00:00:00 2001 From: PaxHelios Date: Fri, 31 Jul 2026 17:46:46 +0200 Subject: [PATCH 32/98] feat(narrate_book): make a book narrated by the script repairable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Repairing one defective segment instead of re-narrating a nine-hour book is the whole point of the repair machinery, and it only worked for books narrated from the tab. The script — the path recommended for whole books, and so the path where a repair is actually worth something — never wrote the plan.json that records which cached entry holds which sentence. A book that cost nine hours could not have one sentence fixed. It writes it now, before any audio, so a narration interrupted after those nine hours is still repairable. scripts/repair_segment.py makes the machinery reachable from the command line: --list to see what is wrong worst-first, --segment to re-roll one, --all-fatal to sweep. Listing reads the cache and deliberately does not load the model, because finding out what is wrong must not cost a minute. A new take that comes back worse than the one it replaces is refused and said so — silence there would have the user believe the repair took. A book narrated before this commit is caught up by re-running narrate_book.py with the same arguments: every segment is a cache hit, so no model is loaded at all. Measured on the test book: 23 seconds. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Fity58qgKttpD1nLheWrzy --- docs/NARRATION.md | 21 +++++ scripts/narrate_book.py | 24 ++++- scripts/repair_segment.py | 178 +++++++++++++++++++++++++++++++++++ tests/test_repair_segment.py | 158 +++++++++++++++++++++++++++++++ 4 files changed, 380 insertions(+), 1 deletion(-) create mode 100644 scripts/repair_segment.py create mode 100644 tests/test_repair_segment.py diff --git a/docs/NARRATION.md b/docs/NARRATION.md index e1a0fe19..f4094b5f 100644 --- a/docs/NARRATION.md +++ b/docs/NARRATION.md @@ -339,6 +339,27 @@ sur la première syllabe**. La norme porte aussi sur la forme du fichier : valeurs par défaut visent le **milieu** de chaque fenêtre, pas son bord : un chapitre reste conforme même si le rognage laisse un peu de silence à lui. +## Réparer un segment sans renarrer le livre + +Un livre, c'est des heures de calcul. Quand **une** phrase sort tronquée ou +bafouillée, tout régénérer — même seulement son chapitre — est absurde : le reste +était bon, et le cache le contient encore. + +``` +.\.venv\Scripts\python.exe scripts\repair_segment.py output\book_mon_livre --list +.\.venv\Scripts\python.exe scripts\repair_segment.py output\book_mon_livre --segment ch003/seg012 +``` + +`--list` lit le cache et **ne charge pas le modèle** : savoir ce qui cloche ne doit +pas coûter une minute d'attente. `--all-fatal` répare d'un coup tout ce qui est +fatalement défectueux. Une nouvelle prise moins bonne que l'ancienne est **refusée** +et signalée — relancer la commande en tire une autre. + +Cela repose sur le `plan.json` écrit à côté des chapitres, qui mémorise quelle +entrée du cache contient quelle phrase. Un livre narré avant que ce fichier existe +se rattrape en relançant `narrate_book.py` avec les mêmes arguments : tout vient du +cache, donc **c'est affaire de secondes** (mesuré : 23 s sur un livre déjà narré). + ## Assemblage en un fichier unique ``` diff --git a/scripts/narrate_book.py b/scripts/narrate_book.py index 7d2d9232..dbaeecba 100644 --- a/scripts/narrate_book.py +++ b/scripts/narrate_book.py @@ -47,6 +47,7 @@ ./.venv/Scripts/python.exe scripts/narrate_book.py livre.txt --voice "..." --device cuda """ import argparse +import dataclasses import json import os import subprocess @@ -65,7 +66,7 @@ from narration import assemble as assembly # noqa: E402 from narration import audio as audio_tools # noqa: E402 from narration import cache as cache_tools # noqa: E402 -from narration import chunking, credits, epub, quality, text_fr # noqa: E402 +from narration import chunking, credits, epub, quality, repair, text_fr # noqa: E402 #: Rough characters-per-second of finished narration, used only to estimate how #: long a book will run before committing hours of CPU to it. @@ -295,6 +296,27 @@ def main() -> int: cache = cache_tools.ChunkCache(outdir / ".cache", enabled=not args.no_cache) mastering = audio_tools.MasteringSettings(target_rms_db=args.target_rms) + # The plan is what makes a later repair possible: without it, which cache + # entry holds which sentence is lost the moment this run ends. Written + # before any audio, so a narration interrupted after nine hours is still + # repairable — which is exactly the narration worth repairing rather than + # running again. + repair.BookPlan( + voice=dataclasses.asdict(voice_spec), + mastering=dataclasses.asdict(mastering), + chapters=tuple( + repair.PlannedChapter( + index=index, + title=titles[index - 1], + segments=tuple( + repair.PlannedSegment(segment.text, segment.pause_after) + for segment in segments + ), + ) + for index, segments in plan + ), + ).save(outdir) + qc = not args.no_qc thresholds = quality.QualityThresholds() qc_reports: list[tuple[str, quality.SegmentReport]] = [] diff --git a/scripts/repair_segment.py b/scripts/repair_segment.py new file mode 100644 index 00000000..1970a72f --- /dev/null +++ b/scripts/repair_segment.py @@ -0,0 +1,178 @@ +"""Find and repair a single defective segment of an already narrated book. + +A book is nine hours of CPU. When one sentence in it comes out truncated, or +babbling, or silent, re-narrating the book — or even the chapter — to fix that +sentence is absurd: everything else was fine, and the cache still holds it. + +This re-generates **one segment** with a derived seed and stitches its chapter +back together from the cache. Nothing else is synthesized. + +It needs the ``plan.json`` written beside the chapters, which records which +cached entry holds which sentence. Books narrated before that file existed can +be repaired by re-running ``narrate_book.py`` with the same arguments: the +segments all come from the cache, so it costs seconds and writes the plan. + +Examples +-------- + # What is wrong, worst first: + ./.venv/Scripts/python.exe scripts/repair_segment.py output/book_mon_livre --list + + # Repair one, then rebuild its chapter: + ./.venv/Scripts/python.exe scripts/repair_segment.py output/book_mon_livre --segment ch003/seg012 + + # Not happy with the new take? Ask again — a different one comes out: + ./.venv/Scripts/python.exe scripts/repair_segment.py output/book_mon_livre --segment ch003/seg012 + + # Repair every fatally defective segment in one pass: + ./.venv/Scripts/python.exe scripts/repair_segment.py output/book_mon_livre --all-fatal +""" +from __future__ import annotations + +import argparse +import sys +from pathlib import Path +from typing import List, Optional, Tuple + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +import app # noqa: E402 +from narration import cache as cache_tools # noqa: E402 +from narration import quality, repair # noqa: E402 + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter + ) + parser.add_argument("directory", help="Directory holding the chapters and plan.json") + + what = parser.add_argument_group("quoi réparer") + what.add_argument("--list", action="store_true", + help="List the defective segments and exit") + what.add_argument("--segment", metavar="LABEL", + help="Segment to re-generate, e.g. ch003/seg012") + what.add_argument("--all-fatal", action="store_true", + help="Repair every fatally defective segment") + what.add_argument("--attempt", type=int, + help="Force an attempt number, so a given take is reproducible") + what.add_argument("--keep-worse", action="store_true", + help="Keep the new take even when it is worse than the old one") + + run = parser.add_argument_group("exécution") + run.add_argument("--device", default="cpu", help="auto, cpu, mps, cuda (default: cpu)") + run.add_argument("--model-id", default="openbmb/VoxCPM2", help="Model path or HF repo id") + return parser + + +def describe(label: str, report: quality.SegmentReport) -> str: + kind = "FATAL " if report.fatal else "suspect" + return f" {label} {kind} {report.describe()}" + + +def main() -> int: + args = build_parser().parse_args() + + outdir = Path(args.directory) + try: + plan = repair.BookPlan.load(outdir) + except FileNotFoundError: + raise SystemExit( + f"No {repair.PLAN_FILENAME} in {outdir}. Re-run narrate_book.py with the same " + "arguments to write one — every segment comes from the cache, so it costs seconds." + ) + except ValueError as error: + raise SystemExit(str(error)) + + cache = cache_tools.ChunkCache(outdir / ".cache") + reports = repair.inspect_book(plan, cache) + if not reports: + raise SystemExit( + f"No cached segment found in {outdir / '.cache'}. Nothing can be repaired without " + "the cache the narration wrote." + ) + + flagged = repair.flagged_segments(reports) + print(f"Livre : {outdir}") + print(f"Segments : {len(reports)} en cache · {len(flagged)} signalé(s)") + for label, report in flagged: + print(describe(label, report)) + if not flagged: + print(" (aucun défaut détecté)") + + if args.list or (not args.segment and not args.all_fatal): + if not args.list: + print("\nRien à faire : précisez --segment LABEL ou --all-fatal.") + return 0 + + targets: List[str] + if args.all_fatal: + targets = [label for label, report in flagged if report.fatal] + if not targets: + print("\nAucun segment fatalement défectueux : rien à régénérer.") + return 0 + else: + targets = [args.segment] + + # The model is loaded once, and only now: listing defects reads the cache + # and must not cost a minute of model load. + demo = app.VoxCPMDemo(model_id=args.model_id, device=args.device, load_denoiser=False) + spec = plan.voice_spec() + + def render(seed: Optional[int]) -> Tuple[int, "object"]: + sample_rate, wav, _ = demo.generate_tts_audio( + text_input=segment.text, + control_instruction=spec.description, + cfg_value_input=spec.cfg, + do_normalize=spec.normalize, + inference_timesteps=int(spec.steps), + seed=seed, + ) + return sample_rate, wav + + touched_chapters = set() + failures = 0 + for label in targets: + try: + chapter_index, position = repair.parse_label(label) + segment = plan.segment(chapter_index, position) + except (ValueError, KeyError) as error: + print(f"\n{label} : {error}") + failures += 1 + continue + + print(f"\n{label} — « {segment.text[:90]}{'…' if len(segment.text) > 90 else ''} »") + result = repair.reroll_segment( + plan, + chapter_index, + position, + cache, + render, + attempt=args.attempt, + keep_worse=args.keep_worse, + ) + print(f" essai {result.attempt}, graine {result.seed}") + print(f" avant : {result.previous.describe() if result.previous else '(rien en cache)'}") + print(f" après : {result.report.describe()}") + if not result.improved: + # Saying so matters: the user would otherwise believe the repair + # took, and hear the same defect on the next listen. + print(" le nouvel essai est moins bon, l'ancien est conservé — relancez pour " + "en tirer un autre") + failures += 1 + continue + touched_chapters.add(chapter_index) + + for chapter_index in sorted(touched_chapters): + rebuilt = repair.rebuild_chapter(plan, chapter_index, cache, outdir) + if rebuilt.ok: + print(f"\nChapitre {chapter_index:03d} reconstruit -> {rebuilt.path.name}") + else: + print(f"\nChapitre {chapter_index:03d} NON reconstruit : " + f"{len(rebuilt.missing)} segment(s) absent(s) du cache") + failures += 1 + + return 1 if failures else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_repair_segment.py b/tests/test_repair_segment.py new file mode 100644 index 00000000..feeb078f --- /dev/null +++ b/tests/test_repair_segment.py @@ -0,0 +1,158 @@ +"""End-to-end tests of scripts/repair_segment.py. + +Same stub engine as the quality tests, so a narration and a repair both run in +milliseconds. What matters here is the promise the script makes: a book that +cost nine hours can have one sentence fixed without re-synthesizing anything +else — and that promise only holds if narrate_book leaves a plan behind. +""" +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path + +import pytest +import soundfile as sf + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +from narration import repair # noqa: E402 + +from test_narrate_book_qc import CALLS, SR, _noise, narrate_book # noqa: E402,F401 +from test_narrate_book_qc import book, reset_stub # noqa: E402,F401 (fixtures) + +spec = importlib.util.spec_from_file_location( + "repair_segment", ROOT / "scripts" / "repair_segment.py" +) +repair_segment = importlib.util.module_from_spec(spec) +assert spec.loader is not None +spec.loader.exec_module(repair_segment) + + +def narrate(monkeypatch, book, outdir, *extra) -> int: + monkeypatch.setattr( + sys, + "argv", + ["narrate_book.py", str(book), "--voice", "Voix de test", "--outdir", str(outdir), + "--no-credits", *extra], + ) + return narrate_book.main() + + +def repair_run(monkeypatch, outdir, *extra) -> int: + monkeypatch.setattr(sys, "argv", ["repair_segment.py", str(outdir), *extra]) + return repair_segment.main() + + +@pytest.fixture +def narrated(monkeypatch, book, tmp_path): + """A narrated book, with its plan and its cache.""" + outdir = tmp_path / "out" + assert narrate(monkeypatch, book, outdir) == 0 + CALLS.clear() + return outdir + + +class TestThePlanIsWritten: + """Without it, a book narrated by the script can never be repaired.""" + + def test_narration_leaves_a_plan_behind(self, narrated): + assert (narrated / repair.PLAN_FILENAME).is_file() + + def test_the_plan_names_every_segment(self, narrated): + plan = repair.BookPlan.load(narrated) + assert [chapter.index for chapter in plan.chapters] == [1, 2] + assert all(chapter.segments for chapter in plan.chapters) + + def test_the_plan_reproduces_the_cache_keys(self, narrated): + """A plan that cannot find the audio it describes is worthless.""" + from narration import cache as cache_tools + + plan = repair.BookPlan.load(narrated) + cache = cache_tools.ChunkCache(narrated / ".cache") + spec = plan.voice_spec() + for chapter in plan.chapters: + for segment in chapter.segments: + assert cache.get(cache.key(segment.text, spec)) is not None + + def test_it_is_written_before_the_audio(self, monkeypatch, book, tmp_path): + """An interrupted nine-hour narration must still be repairable.""" + outdir = tmp_path / "interrupted" + + written = {} + + def stop_after_the_plan(path, *args, **kwargs): + written["plan"] = (outdir / repair.PLAN_FILENAME).is_file() + raise KeyboardInterrupt + + monkeypatch.setattr(narrate_book.sf, "write", stop_after_the_plan) + with pytest.raises(KeyboardInterrupt): + narrate(monkeypatch, book, outdir) + assert written["plan"] is True + + +class TestListing: + def test_a_healthy_book_reports_no_defect(self, monkeypatch, narrated, capsys): + assert repair_run(monkeypatch, narrated, "--list") == 0 + assert "aucun défaut détecté" in capsys.readouterr().out + + def test_listing_never_loads_the_model(self, monkeypatch, narrated): + """Reading the cache must not cost a minute of model load.""" + monkeypatch.setattr( + repair_segment.app, "VoxCPMDemo", lambda **_: pytest.fail("model loaded") + ) + assert repair_run(monkeypatch, narrated, "--list") == 0 + + def test_a_missing_plan_says_how_to_get_one(self, monkeypatch, tmp_path): + empty = tmp_path / "vide" + empty.mkdir() + with pytest.raises(SystemExit) as raised: + repair_run(monkeypatch, empty, "--list") + assert "narrate_book.py" in str(raised.value) + + +class TestRepairing: + def test_one_segment_is_regenerated_and_its_chapter_restitched( + self, monkeypatch, narrated, capsys + ): + before = (narrated / "chapitre_001.wav").read_bytes() + assert repair_run(monkeypatch, narrated, "--segment", "ch001/seg001") == 0 + + out = capsys.readouterr().out + assert "reconstruit" in out + assert (narrated / "chapitre_001.wav").read_bytes() != before + + def test_only_that_segment_is_synthesized(self, monkeypatch, narrated): + repair_run(monkeypatch, narrated, "--segment", "ch001/seg001") + assert len(CALLS) == 1 + + def test_the_other_chapter_is_left_alone(self, monkeypatch, narrated): + untouched = (narrated / "chapitre_002.wav").read_bytes() + repair_run(monkeypatch, narrated, "--segment", "ch001/seg001") + assert (narrated / "chapitre_002.wav").read_bytes() == untouched + + def test_a_worse_take_is_refused_and_said_so(self, monkeypatch, narrated, capsys): + """A re-roll can come back worse; the old take then stays. + + The engine is made to return a truncated take for every seed, which is + what a bad re-roll looks like from here. + """ + class TruncatingDemo: + def __init__(self, **_kwargs): + pass + + def generate_tts_audio(self, *, text_input, seed=None, **_kwargs): + return SR, _noise(0.3), None + + monkeypatch.setattr(repair_segment.app, "VoxCPMDemo", TruncatingDemo) + assert repair_run(monkeypatch, narrated, "--segment", "ch001/seg001") == 1 + assert "moins bon" in capsys.readouterr().out + + def test_an_unknown_label_is_reported_not_crashed(self, monkeypatch, narrated, capsys): + assert repair_run(monkeypatch, narrated, "--segment", "ch009/seg001") == 1 + assert "ch009/seg001" in capsys.readouterr().out + + def test_nothing_happens_without_a_target(self, monkeypatch, narrated, capsys): + assert repair_run(monkeypatch, narrated) == 0 + assert "Rien à faire" in capsys.readouterr().out From 21a446dca76bce34316d13b8d599da8b17cba84a Mon Sep 17 00:00:00 2001 From: PaxHelios Date: Fri, 31 Jul 2026 17:56:31 +0200 Subject: [PATCH 33/98] docs: ffmpeg is installable, and the encoding is now verified MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The guide stated as fact that ffmpeg was absent from this machine, which stopped being true the moment it was installed — a document that asserts something false about the reader's own setup is worse than one that says nothing. It now gives the user-scope winget command instead, and notes that the terminal has to be reopened for the PATH to take. The delivery section also records what the encoding actually produces, since it has now run rather than only been argued for: mp3, 44100 Hz, one channel, a stream bitrate of exactly 192000 bps and no Xing header — constant bitrate, not variable bitrate wearing a hat. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Fity58qgKttpD1nLheWrzy --- docs/NARRATION.md | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/docs/NARRATION.md b/docs/NARRATION.md index f4094b5f..2b2fb300 100644 --- a/docs/NARRATION.md +++ b/docs/NARRATION.md @@ -368,10 +368,17 @@ cache, donc **c'est affaire de secondes** (mesuré : 23 s sur un livre déjà na --title "Mon Livre" --author "Edwin" --format m4b ``` -**ffmpeg n'est pas installé sur cette machine.** Ce n'est pas bloquant : le script -produit quand même le WAV complet et le fichier de marqueurs, puis affiche la commande -exacte à lancer une fois ffmpeg installé. Les heures de synthèse ne sont jamais perdues -à cause d'un encodeur manquant. +**Sans ffmpeg, rien n'est perdu** : le script produit quand même le WAV complet et le +fichier de marqueurs, puis affiche la commande exacte à lancer une fois ffmpeg installé. +Les heures de synthèse ne dépendent jamais d'un encodeur manquant. + +Pour l'installer sous Windows, sans droits administrateur : + +``` +winget install --id Gyan.FFmpeg -e --scope user +``` + +Il faut ensuite **rouvrir le terminal** pour que le `PATH` soit pris en compte. **La couverture du livre est reprise automatiquement** quand la source est un `.epub` : elle est extraite à côté des chapitres (`couverture.jpg`) et intégrée @@ -438,6 +445,10 @@ d'un binaire manquant. | `--no-sample` | Pas d'extrait | | `--keep-wav` | Garde les WAV intermédiaires | +**Vérifié pour de vrai** sur un livre narré de bout en bout : les quatre fichiers +produits sortent en `mp3`, `44100 Hz`, `1 canal`, **débit constant de 192 000 bps** +exactement, sans en-tête Xing — c'est-à-dire du CBR, et non du VBR déguisé. + Le script **sort en code d'erreur** s'il reste un fichier hors norme, ce qui le rend utilisable dans un enchaînement automatisé. From 8aa50bb1b48f6bf27a3efad0dabd9d1d2635bcf5 Mon Sep 17 00:00:00 2001 From: PaxHelios Date: Tue, 4 Aug 2026 15:13:51 +0200 Subject: [PATCH 34/98] feat(polish): the studio chain, and the loudness the platforms actually use MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mastering got a chapter to the right RMS, the right peak and the right silences — which is what a distributor checks and not what a listener hears. Missing from it were the four things every audiobook studio does, and one measurement. A high-pass at 80 Hz, because below that a voice has nothing and a room has plenty. A de-esser, because sibilance is the first thing that gives synthetic French away — and it watches the high band against where that band sits while someone is *talking*, not against itself, or a lone harsh `s` would be its own reference and never exceed it. A compressor, because an audiobook is heard while walking and driving. And a limiter, which was not in the plan. Compression alone made things worse: it pulls sustained speech down without touching short peaks, so the crest factor grew from 17.7 to 19.0 dB on a real chapter and the -3 dBFS ceiling then dragged the whole thing 1.3 LU quiet to make room for a handful of samples. Holding the peaks is what lets the rest sit where it belongs. With it the same chapter comes out at 14.4 dB crest and keeps its loudness. The detector window matters as much as the ratios: measured over a single millisecond it follows every glottal pulse and the compressor squashes vowels while letting transients through, which is how the crest factor went up in the first place. Thirty milliseconds is a syllable, which is the scale speech levelling works at. LUFS (ITU-R BS.1770-4 / EBU R128) joins the report: ACX reasons in RMS, the streaming platforms normalise in LUFS, and a chapter can be correct by one and wrong by the other. Reported, never gated on — no standard states a single target, and inventing one would be worse than giving the number. The implementation was cross-checked against ffmpeg's ebur128 on real chapters: -20.98 against -20.9, -20.27 against -20.2, -20.96 against -20.9. Measured on the narrated test book, sibilant band down 1.3 to 3.9 dB, crest factor down where it was high, loudness held, and all four delivery files still pass ACX. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Fity58qgKttpD1nLheWrzy --- app.py | 16 +- docs/NARRATION.md | 40 +++ narration/__init__.py | 6 +- narration/audio.py | 27 +- narration/polish.py | 450 +++++++++++++++++++++++++++++++++ scripts/narrate_book.py | 7 +- tests/test_narration_audio.py | 11 +- tests/test_narration_polish.py | 272 ++++++++++++++++++++ 8 files changed, 821 insertions(+), 8 deletions(-) create mode 100644 narration/polish.py create mode 100644 tests/test_narration_polish.py diff --git a/app.py b/app.py index 3b152a3a..2fa2a58d 100644 --- a/app.py +++ b/app.py @@ -235,6 +235,8 @@ "book_target_rms_info": "Audiobook platforms expect RMS between -23 and -18 dBFS.", "book_pause_sentence_label": "Pause after a sentence (s)", "book_pause_paragraph_label": "Pause after a paragraph (s)", + "book_polish_label": "Studio chain", + "book_polish_info": "High-pass, de-esser, compressor and limiter over each chapter before its level is set — what a listener hears, beyond the levels a distributor checks.", "book_repair_title": "🔧 Repair a flagged segment", "book_repair_info": "Re-generate a single defective segment and restitch its chapter " "from the cache. The other segments are never re-synthesized.", @@ -315,6 +317,8 @@ "book_target_rms_info": "Les plateformes de livres audio attendent un RMS entre -23 et -18 dBFS.", "book_pause_sentence_label": "Pause après une phrase (s)", "book_pause_paragraph_label": "Pause après un paragraphe (s)", + "book_polish_label": "Chaîne studio", + "book_polish_info": "Passe-haut, dé-esseur, compresseur et limiteur sur chaque chapitre avant le calage du niveau — ce que l'auditeur entend, au-delà des niveaux que contrôle un distributeur.", "book_repair_title": "🔧 Réparer un segment signalé", "book_repair_info": "Régénère un seul segment défectueux et reconstruit son chapitre " "à partir du cache. Les autres segments ne sont jamais recalculés.", @@ -1121,6 +1125,7 @@ def _book_narrate( qc_retries, narrator="", with_credits=True, + polish_on=True, progress=gr.Progress(), ): """Narrate every chapter, writing each one to disk as soon as it is done. @@ -1145,7 +1150,9 @@ def _book_narrate( outdir = _book_dir(title) outdir.mkdir(parents=True, exist_ok=True) profile = _book_profile(pause_sentence, pause_paragraph) - mastering = audio_tools.MasteringSettings(target_rms_db=float(target_rms)) + mastering = audio_tools.MasteringSettings( + target_rms_db=float(target_rms), polish=bool(polish_on) + ) voice_spec = cache_tools.VoiceSpec( description=description, seed=seed, @@ -1705,6 +1712,12 @@ def _run_asr_if_needed(checked, audio_path): step=0.05, label=I18N("book_pause_sentence_label"), ) + book_polish = gr.Checkbox( + value=True, + label=I18N("book_polish_label"), + elem_classes=["switch-toggle"], + info=I18N("book_polish_info"), + ) book_pause_paragraph = gr.Slider( minimum=0.0, maximum=3.0, @@ -1896,6 +1909,7 @@ def _run_asr_if_needed(checked, audio_path): book_qc_retries, book_narrator, book_with_credits, + book_polish, ], outputs=[book_status, book_audio], show_progress=True, diff --git a/docs/NARRATION.md b/docs/NARRATION.md index 2b2fb300..d58a6211 100644 --- a/docs/NARRATION.md +++ b/docs/NARRATION.md @@ -29,6 +29,7 @@ chacune dans un module de `narration/` — testable et utilisable indépendammen | **2. Découpage** | `narration/chunking.py` | Coupe en segments sous la limite du moteur, **sans jamais couper une phrase**, et décide la durée du silence après chaque segment selon la ponctuation | | **3. Synthèse** | moteur VoxCPM2 | Même seed partout → voix identique du début à la fin | | **4. Mastering** | `narration/audio.py` | Rogne les silences parasites, supprime les clics aux jointures, insère les pauses, normalise la sonie **une fois par chapitre** | +| **4 bis. Chaîne studio** | `narration/polish.py` | Passe-haut à 80 Hz, dé-esseur, compresseur et limiteur avant le calage du niveau — et mesure de la sonie en **LUFS** (EBU R128), la norme des plateformes de streaming | | **5. Assemblage** | `narration/assemble.py` | Réunit les chapitres en un seul M4B/MP3 avec marqueurs de chapitres | | **6. Livraison** | `narration/delivery.py` | Découpe, échantillonne et encode les fichiers qu'un distributeur accepte (MP3 192 kbps CBR, 44,1 kHz) | @@ -321,6 +322,45 @@ Le plan avant génération dit ce qu'il manque pour une distribution : Générique : début et fin ajoutés — manque encore l'auteur ``` +## La chaîne studio : ce que le distributeur ne contrôle pas + +Les niveaux ACX disent qu'un chapitre est *acceptable*. Ils ne disent rien de ce +qu'on entend. Quatre traitements tournent donc sur chaque chapitre assemblé, +**avant** le calage du niveau (`--no-polish`, ou la case « Chaîne studio ») : + +| Étage | Pourquoi | +|---|---| +| **Passe-haut à 80 Hz** | Sous 80 Hz il n'y a rien d'une voix, mais du grondement qui mange de la marge et fatigue au casque | +| **Dé-esseur** | Les sifflantes sont le premier défaut qui trahit une voix de synthèse en français | +| **Compresseur** | Un livre audio s'écoute en marchant, en voiture : l'écart entre une phrase murmurée et une phrase appuyée doit se resserrer | +| **Limiteur** | Sans lui, le compresseur **dégrade** le résultat — voir plus bas | + +**Le limiteur n'était pas prévu, la mesure l'a imposé.** Le compresseur seul baisse +les tenues sans toucher les crêtes courtes : le facteur de crête *monte* (17,7 → +19,0 dB mesuré sur un vrai chapitre), et le plafond de −3 dBFS oblige alors la +normalisation à reculer, faisant perdre 1,3 LU à tout le chapitre pour quelques +échantillons. Tenir les crêtes est ce qui permet au reste de sonner à son niveau. + +Mesuré sur les trois chapitres d'un livre réellement narré : + +| | crête/RMS | bande sifflante | sonie | +|---|---|---|---| +| Générique de début | 17,7 → **14,4 dB** | −13,9 → **−17,8 dB** | −20,98 → −20,52 LUFS | +| Le texte | 14,4 → 14,4 dB | −14,0 → **−15,3 dB** | −20,27 → −19,64 LUFS | +| Générique de fin | 14,7 → **14,0 dB** | −23,4 → **−25,9 dB** | −20,96 → −20,94 LUFS | + +### La sonie en LUFS + +ACX raisonne en RMS ; **Spotify, Apple Books et YouTube normalisent en LUFS** +(ITU-R BS.1770 / EBU R128), qui pondère le spectre comme l'oreille. Un chapitre +parfaitement calé à −20 dBFS RMS peut arriver trop fort ou trop faible chez eux, et +rien dans le rapport ACX ne l'aurait dit. La mesure est donc ajoutée au rapport — +**reportée, jamais éliminatoire** : aucune norme ne fixe une cible unique, et +inventer un seuil que personne n'exige serait pire que donner le chiffre. + +L'implémentation a été **confrontée à ffmpeg** (`-af ebur128`) sur de vrais +chapitres : −20,98 contre −20,9 ; −20,27 contre −20,2 ; −20,96 contre −20,9. + ## Forme des fichiers : ce qu'ACX vérifie en plus du niveau Un chapitre parfaitement calibré en sonie est quand même refusé s'il **commence diff --git a/narration/__init__.py b/narration/__init__.py index 4e62ed1b..73f7d96a 100644 --- a/narration/__init__.py +++ b/narration/__init__.py @@ -1,7 +1,7 @@ """Audiobook narration toolkit built on top of the VoxCPM engine. -This package deliberately depends only on the standard library, ``numpy`` and -``soundfile`` — never on ``torch`` or ``gradio``. That keeps every stage of the +This package deliberately depends only on the standard library, ``numpy``, +``scipy`` and ``soundfile`` — never on ``torch`` or ``gradio``. That keeps every stage of the production chain (text preparation, segmentation, audio mastering, assembly) importable and unit-testable without loading a multi-gigabyte model, which matters a lot on a CPU-only machine where model load alone takes minutes. @@ -15,6 +15,7 @@ cache content-addressed store so an interrupted run resumes per chunk quality flag the segments the engine got wrong, and re-roll those only audio trim, master and stitch the generated segments + polish high-pass, de-ess, compress, limit; and measure LUFS repair re-roll one segment and restitch its chapter, from a saved plan assemble join chapters into a single MP3/M4B with chapter markers delivery cut, sample and encode the files a distributor accepts @@ -28,6 +29,7 @@ "credits", "delivery", "epub", + "polish", "quality", "repair", "text_fr", diff --git a/narration/audio.py b/narration/audio.py index 0f5a28e2..01dbb599 100644 --- a/narration/audio.py +++ b/narration/audio.py @@ -100,6 +100,11 @@ class MasteringSettings: #: silence of its own. lead_sec: float = 0.75 tail_sec: float = 2.0 + #: Run the studio chain — high-pass, de-esser, compressor — over the + #: assembled chapter before its level is set. A plain bool rather than the + #: settings themselves, so a saved plan stays a flat JSON object; the + #: amounts are passed to :func:`stitch` separately when they need changing. + polish: bool = True # -------------------------------------------------------------------------- @@ -244,6 +249,8 @@ def acx_report(wav: np.ndarray, sr: int) -> dict: still be rejected for opening on its first syllable or running past two hours, so both are reported side by side. """ + from . import polish as polish_tools # circular at module scope, see stitch + rms = speech_rms_db(wav, sr) peak = peak_db(wav) floor = noise_floor_db(wav, sr) @@ -261,6 +268,10 @@ def acx_report(wav: np.ndarray, sr: int) -> dict: "rms_db": rms, "peak_db": peak, "noise_floor_db": floor, + # Reported, never gated on: ACX states its limits in RMS, while the + # streaming platforms normalise in LUFS and disagree on the target. + # Inventing a pass/fail no standard states would be worse than a number. + "lufs": polish_tools.loudness_lufs(wav, sr), "head_room_sec": head, "tail_room_sec": tail, "duration_sec": duration, @@ -420,12 +431,14 @@ def stitch( settings: MasteringSettings = MasteringSettings(), *, normalize: bool = True, + polish_settings=None, ) -> np.ndarray: """Assemble ``(audio, pause_after_seconds)`` pairs into one mastered chapter. Each segment is trimmed and faded, the requested pause is inserted after it, - and the finished chapter is normalised once so the level is consistent from - the first word to the last. + the studio chain runs over the whole chapter, and only then is it normalised + — once, so the level is consistent from the first word to the last, and so + that nothing after the levelling can move it again. """ if not segments: return np.zeros(0, dtype=np.float32) @@ -446,6 +459,16 @@ def stitch( pieces.append(silence(sr, settings.tail_sec)) chapter = np.concatenate(pieces) if pieces else np.zeros(0, dtype=np.float32) + + if settings.polish and chapter.size: + # Imported here rather than at module scope: polish builds on this + # module, and importing it at the top would close the circle. + from . import polish as polish_tools + + chapter = polish_tools.polish( + chapter, sr, polish_settings or polish_tools.PolishSettings() + ) + if normalize and chapter.size: chapter, _ = normalize_level( chapter, diff --git a/narration/polish.py b/narration/polish.py new file mode 100644 index 00000000..64abcc4c --- /dev/null +++ b/narration/polish.py @@ -0,0 +1,450 @@ +"""The processing that separates correct levels from a produced sound. + +:mod:`narration.audio` gets a chapter to the right loudness, the right peak and +the right silences — which is what a distributor *checks*. It is not what a +listener hears. Three things are missing from it, and every audiobook studio +does all three: + +* **A high-pass.** Below 80 Hz there is nothing a voice needs and plenty a room + produces: a rumble that is inaudible on a laptop, eats headroom, and wears the + ear down over an hour in headphones. +* **De-essing.** Sibilance is the first thing that gives synthetic French away — + the ``s`` of *ses histoires* arriving several decibels above the vowels around + it. It is a narrow, high band, and it only needs pulling down when it spikes. +* **Compression.** An audiobook is listened to while walking, driving, falling + asleep. The gap between a murmured line and an emphatic one has to close, or + half the sentences are lost under the road noise. +* **Limiting.** Compression alone made two of the three test chapters *worse*: + it pulls sustained speech down without touching the short peaks, the crest + factor grows, and the -3 dBFS ceiling then drags the whole chapter quiet to + make room for a handful of samples — 1.3 LU lost, measured. Holding the peaks + is what lets the rest sit where it belongs. + +And one measurement is missing. ACX reasons in RMS, which this pipeline already +reports; Spotify, Apple Books and YouTube normalise in **LUFS** (ITU-R BS.1770 / +EBU R128), which weights the spectrum the way an ear does. A chapter sitting +perfectly at -20 dBFS RMS can still arrive too loud or too quiet on those +platforms, and nothing in the ACX report would have said so. + +Order matters and is not negotiable: correct first (high-pass), then control +dynamics (de-ess, compress, limit), then set the level. Normalising before +compressing would undo the level; compressing before the high-pass would make +the compressor duck on rumble nobody can hear; limiting before compressing would +leave the limiter working on peaks the compressor is about to move anyway. + +Gains are computed at a **control rate** of one point per millisecond and +interpolated back, rather than per sample. That is how hardware does it, it is +inaudible at these time constants, and it keeps an hour-long chapter to a couple +of seconds of work instead of minutes of Python loop. +""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import Tuple + +import numpy as np +from scipy import signal + +from . import audio as audio_tools + +__all__ = [ + "PolishSettings", + "compress", + "deess", + "highpass", + "limit", + "loudness_lufs", + "polish", +] + +#: Gain is computed this often, then interpolated back to the sample rate. +_CONTROL_MS = 1.0 + +#: ITU-R BS.1770-4 K-weighting, stage 1: a high shelf standing in for the head. +_SHELF_F0 = 1681.974450955533 +_SHELF_GAIN_DB = 3.999843853973347 +_SHELF_Q = 0.7071752369554196 +#: Stage 2: the RLB high-pass that discards what the ear barely weighs. +_RLB_F0 = 38.13547087602444 +_RLB_Q = 0.5003270373238773 +#: The offset in the BS.1770 loudness equation. +_LUFS_OFFSET = -0.691 +#: Gating, in LUFS and LU: silence never counts, and neither do the quiet parts. +_ABSOLUTE_GATE_LUFS = -70.0 +_RELATIVE_GATE_LU = -10.0 +_BLOCK_SEC = 0.400 +_BLOCK_OVERLAP = 0.75 + + +@dataclass(frozen=True) +class PolishSettings: + """How much of each treatment. Defaults are deliberately conservative. + + A narrator's voice is the product; processing it heavily is how an audiobook + starts sounding like a radio advert. These values remove the defects and + stop there. + """ + + #: Everything below this is rumble, not voice. + highpass_hz: float = 80.0 + + deess: bool = True + #: Sibilance lives above this. French ``s`` and ``ch`` sit around 5-8 kHz. + deess_band_hz: float = 5000.0 + #: How far above the band's own average a peak has to be to be pulled down. + deess_threshold_db: float = 6.0 + #: Ratio applied to the excess. 3:1 tames without lisping. + deess_ratio: float = 3.0 + #: Never pull the band down by more than this, whatever the excess. + deess_max_reduction_db: float = 8.0 + + compress: bool = True + #: Relative to the signal's own speech level, not an absolute dBFS value: + #: the chapter arrives un-normalised and a fixed threshold would either do + #: nothing or crush it. + compress_threshold_db: float = -6.0 + compress_ratio: float = 2.5 + compress_attack_ms: float = 25.0 + compress_release_ms: float = 300.0 + + limit: bool = True + #: How far the loudest instants may stand above the speech level. A + #: narrated chapter naturally sits around 14 dB; letting it run to 19 costs + #: real loudness, because the -3 dBFS ceiling then forces the whole chapter + #: down to make room for a handful of samples. + limit_crest_db: float = 14.0 + limit_release_ms: float = 80.0 + + @property + def enabled(self) -> bool: + return bool(self.highpass_hz or self.deess or self.compress or self.limit) + + +# -------------------------------------------------------------------------- +# Filters +# -------------------------------------------------------------------------- + + +def _shelf_biquad(sr: int) -> np.ndarray: + """BS.1770 stage 1, from the audio EQ cookbook so it follows the rate.""" + amplitude = 10.0 ** (_SHELF_GAIN_DB / 40.0) + omega = 2.0 * np.pi * _SHELF_F0 / sr + alpha = np.sin(omega) / (2.0 * _SHELF_Q) + cosine = np.cos(omega) + root = 2.0 * np.sqrt(amplitude) * alpha + + b0 = amplitude * ((amplitude + 1) + (amplitude - 1) * cosine + root) + b1 = -2.0 * amplitude * ((amplitude - 1) + (amplitude + 1) * cosine) + b2 = amplitude * ((amplitude + 1) + (amplitude - 1) * cosine - root) + a0 = (amplitude + 1) - (amplitude - 1) * cosine + root + a1 = 2.0 * ((amplitude - 1) - (amplitude + 1) * cosine) + a2 = (amplitude + 1) - (amplitude - 1) * cosine - root + return np.array([b0 / a0, b1 / a0, b2 / a0, 1.0, a1 / a0, a2 / a0]) + + +def _rlb_biquad(sr: int) -> np.ndarray: + """BS.1770 stage 2: a plain high-pass.""" + omega = 2.0 * np.pi * _RLB_F0 / sr + alpha = np.sin(omega) / (2.0 * _RLB_Q) + cosine = np.cos(omega) + + b0 = (1.0 + cosine) / 2.0 + b1 = -(1.0 + cosine) + b2 = (1.0 + cosine) / 2.0 + a0 = 1.0 + alpha + a1 = -2.0 * cosine + a2 = 1.0 - alpha + return np.array([b0 / a0, b1 / a0, b2 / a0, 1.0, a1 / a0, a2 / a0]) + + +def _bands(wav: np.ndarray, sr: int, cutoff_hz: float) -> Tuple[np.ndarray, np.ndarray]: + """Split into (below cutoff, above cutoff), summing back to the original.""" + high = highpass(wav, sr, cutoff_hz) + return wav - high, high + + +def highpass(wav: np.ndarray, sr: int, cutoff_hz: float = 80.0, order: int = 2) -> np.ndarray: + """Remove everything below ``cutoff_hz``, without phase smear. + + Zero-phase (forward then backward), because this runs offline on a finished + chapter and there is no reason to accept the group delay a live filter would + impose on the transients of the consonants. + """ + wav = audio_tools.as_float_mono(wav) + if wav.size == 0 or cutoff_hz <= 0 or cutoff_hz >= sr / 2: + return wav + sos = signal.butter(order, cutoff_hz / (sr / 2.0), btype="highpass", output="sos") + # filtfilt needs a few times the filter length to work on; a very short + # segment is left alone rather than raising. + if wav.size <= 3 * (sos.shape[0] * 2 + 1): + return wav + return signal.sosfiltfilt(sos, wav).astype(np.float32) + + +# -------------------------------------------------------------------------- +# Dynamics +# -------------------------------------------------------------------------- + + +def _control_envelope_db( + wav: np.ndarray, sr: int, window_ms: float = 30.0 +) -> Tuple[np.ndarray, int]: + """RMS in dB over a sliding window, one point per control period. + + The window is what makes this a *level* detector rather than a peak + detector. Measured over a single millisecond, the envelope follows every + glottal pulse: the compressor then lets short transients through and squashes + sustained vowels, which raises the crest factor instead of lowering it — + measured at +3 dB on a real chapter before this window existed. Thirty + milliseconds is a syllable, which is the scale speech levelling works at. + """ + hop = max(1, int(sr * _CONTROL_MS / 1000.0)) + window = max(hop, int(sr * window_ms / 1000.0)) + if wav.size < window: + return np.zeros(0, dtype=np.float64), hop + + cumulative = np.concatenate(([0.0], np.cumsum(np.square(wav, dtype=np.float64)))) + starts = np.arange(0, wav.size - window + 1, hop) + power = (cumulative[starts + window] - cumulative[starts]) / window + return 10.0 * np.log10(np.maximum(power, 1e-20)), hop + + +def _smooth_gain(gain_db: np.ndarray, attack_ms: float, release_ms: float) -> np.ndarray: + """Attack/release smoothing of a gain curve, at control rate. + + Two different time constants mean a branch per point, so this is the one + genuine loop in the module — over milliseconds, not samples, which is what + makes it affordable. + """ + if gain_db.size == 0: + return gain_db + attack = np.exp(-_CONTROL_MS / max(attack_ms, 1e-6)) + release = np.exp(-_CONTROL_MS / max(release_ms, 1e-6)) + out = np.empty_like(gain_db) + current = gain_db[0] + for index, target in enumerate(gain_db): + # Going down (more reduction) is the attack; coming back is the release. + coefficient = attack if target < current else release + current = coefficient * current + (1.0 - coefficient) * target + out[index] = current + return out + + +def _apply_control_gain(wav: np.ndarray, gain_db: np.ndarray, hop: int) -> np.ndarray: + """Interpolate a control-rate gain back onto the samples and apply it.""" + if gain_db.size == 0: + return wav + positions = np.arange(gain_db.size) * hop + hop / 2.0 + gain = np.interp(np.arange(wav.size), positions, gain_db) + return (wav * (10.0 ** (gain / 20.0))).astype(np.float32) + + +def deess( + wav: np.ndarray, + sr: int, + settings: PolishSettings = PolishSettings(), +) -> np.ndarray: + """Pull down sibilance when it spikes, and only then. + + The high band is measured against **its own average**, not against the whole + signal: what makes an ``s`` harsh is that it stands out from the other + ``s`` sounds and from the vowels, and that comparison has to be made in the + band where it happens. + + Only the high band is attenuated — the rest of the voice passes untouched, + which is what keeps this from sounding like a blanket over the narrator. + """ + wav = audio_tools.as_float_mono(wav) + if wav.size == 0 or not settings.deess: + return wav + + low, high = _bands(wav, sr, settings.deess_band_hz) + # A sibilant lasts a fraction of a syllable, so it is watched over a shorter + # window than the levelling uses, or it would be averaged away. + envelope_db, hop = _control_envelope_db(high, sr, window_ms=12.0) + voice_db, _ = _control_envelope_db(wav, sr, window_ms=12.0) + if envelope_db.size == 0 or voice_db.size == 0: + return wav + + # The reference is where the band normally sits *while someone is talking*. + # Measuring it over frames where the band itself is loud would compare + # sibilance to sibilance — a lone harsh `s` would then be its own reference + # and never exceed it. Gating on the voice instead makes vowels the + # baseline, which is what an `s` actually stands out from. The median, not + # the mean, so a handful of spikes cannot lift the very threshold meant to + # catch them. + voiced = voice_db > voice_db.max() - 40.0 + band_while_voiced = envelope_db[: voiced.size][voiced[: envelope_db.size]] + if band_while_voiced.size == 0: + return wav + threshold = float(np.median(band_while_voiced)) + settings.deess_threshold_db + + excess = np.maximum(0.0, envelope_db - threshold) + reduction = -excess * (1.0 - 1.0 / max(settings.deess_ratio, 1.0)) + reduction = np.maximum(reduction, -abs(settings.deess_max_reduction_db)) + reduction = _smooth_gain(reduction, attack_ms=2.0, release_ms=40.0) + + return (low + _apply_control_gain(high, reduction, hop)).astype(np.float32) + + +def compress( + wav: np.ndarray, + sr: int, + settings: PolishSettings = PolishSettings(), +) -> np.ndarray: + """Close the gap between the quiet lines and the loud ones. + + The threshold is relative to the chapter's own speech level, because a + chapter arrives here un-normalised: an absolute dBFS threshold would crush + a loud take and leave a quiet one untouched, which is the opposite of what + consistency means. + + Make-up gain is deliberately *not* applied. The level is set once, later, + by the normalisation — adding gain here would only move the target. + """ + wav = audio_tools.as_float_mono(wav) + if wav.size == 0 or not settings.compress: + return wav + + envelope_db, hop = _control_envelope_db(wav, sr) + if envelope_db.size == 0: + return wav + + speech_db = audio_tools.speech_rms_db(wav, sr) + if not np.isfinite(speech_db): + return wav + threshold = speech_db + settings.compress_threshold_db + + excess = np.maximum(0.0, envelope_db - threshold) + reduction = -excess * (1.0 - 1.0 / max(settings.compress_ratio, 1.0)) + reduction = _smooth_gain( + reduction, settings.compress_attack_ms, settings.compress_release_ms + ) + return _apply_control_gain(wav, reduction, hop) + + +def _peak_envelope_db(wav: np.ndarray, sr: int, window_ms: float = 3.0) -> Tuple[np.ndarray, int]: + """Highest sample in a short window, per control period. + + A limiter has to see the sample that will breach the ceiling, so this looks + at the peak rather than the RMS the compressor uses. + """ + hop = max(1, int(sr * _CONTROL_MS / 1000.0)) + window = max(hop, int(sr * window_ms / 1000.0)) + if wav.size < window: + return np.zeros(0, dtype=np.float64), hop + starts = np.arange(0, wav.size - window + 1, hop) + # A strided view costs no copy: window is a few dozen samples. + frames = np.lib.stride_tricks.sliding_window_view(np.abs(wav), window)[starts] + return 20.0 * np.log10(np.maximum(frames.max(axis=1), 1e-10)), hop + + +def limit( + wav: np.ndarray, + sr: int, + settings: PolishSettings = PolishSettings(), +) -> np.ndarray: + """Hold the loudest instants down so the whole chapter can sit louder. + + Without this the compressor makes things *worse* on some chapters: it pulls + sustained speech down without touching short peaks, the crest factor grows, + and the -3 dBFS ceiling then drags the entire chapter quiet to accommodate a + few samples. Measured on a real chapter: crest 17.7 dB before, 19.0 after + compression alone, and 1.3 LU of loudness lost to it. + + Attack is immediate by construction — the gain is computed from a peak + envelope, so a breach is caught in the millisecond it happens — and only the + release is smoothed, which is what keeps it from pumping. + """ + wav = audio_tools.as_float_mono(wav) + if wav.size == 0 or not settings.limit: + return wav + + speech_db = audio_tools.speech_rms_db(wav, sr) + if not np.isfinite(speech_db): + return wav + + envelope_db, hop = _peak_envelope_db(wav, sr) + if envelope_db.size == 0: + return wav + + threshold = speech_db + settings.limit_crest_db + reduction = np.minimum(0.0, threshold - envelope_db) + # Attack of zero: never let a peak through. Release smoothed, or the gain + # would step back up inside a syllable and pump audibly. + reduction = _smooth_gain(reduction, attack_ms=0.01, release_ms=settings.limit_release_ms) + return _apply_control_gain(wav, reduction, hop) + + +# -------------------------------------------------------------------------- +# Loudness +# -------------------------------------------------------------------------- + + +def loudness_lufs(wav: np.ndarray, sr: int) -> float: + """Integrated loudness in LUFS, per ITU-R BS.1770-4 / EBU R128. + + Two K-weighting biquads, 400 ms blocks overlapping by three quarters, then + the two gates: everything below -70 LUFS is silence and never counts, and + everything more than 10 LU below the ungated average is the quiet part of + the programme and does not count either. + + Returns ``-inf`` for a signal with nothing in it, which is the honest answer + rather than a number. + """ + wav = audio_tools.as_float_mono(wav) + if wav.size == 0 or sr <= 0: + return float("-inf") + + weighted = signal.sosfilt( + np.vstack([_shelf_biquad(sr), _rlb_biquad(sr)]), wav.astype(np.float64) + ) + + block = int(round(_BLOCK_SEC * sr)) + step = max(1, int(round(block * (1.0 - _BLOCK_OVERLAP)))) + if weighted.size < block: + return float("-inf") + + starts = np.arange(0, weighted.size - block + 1, step) + cumulative = np.concatenate(([0.0], np.cumsum(np.square(weighted)))) + power = (cumulative[starts + block] - cumulative[starts]) / block + + loudness = _LUFS_OFFSET + 10.0 * np.log10(np.maximum(power, 1e-20)) + above_absolute = power[loudness > _ABSOLUTE_GATE_LUFS] + if above_absolute.size == 0: + return float("-inf") + + ungated = _LUFS_OFFSET + 10.0 * np.log10(np.mean(above_absolute)) + relative_gate = ungated + _RELATIVE_GATE_LU + kept = above_absolute[ + _LUFS_OFFSET + 10.0 * np.log10(np.maximum(above_absolute, 1e-20)) > relative_gate + ] + if kept.size == 0: + return float(ungated) + return float(_LUFS_OFFSET + 10.0 * np.log10(np.mean(kept))) + + +# -------------------------------------------------------------------------- +# The chain +# -------------------------------------------------------------------------- + + +def polish( + wav: np.ndarray, + sr: int, + settings: PolishSettings = PolishSettings(), +) -> np.ndarray: + """Correct, then control, then hand back for levelling. + + Deliberately does not set the level: :func:`narration.audio.stitch` + normalises the chapter once, after this, and doing it in both places would + mean neither is in charge. + """ + wav = audio_tools.as_float_mono(wav) + if wav.size == 0 or not settings.enabled: + return wav + if settings.highpass_hz: + wav = highpass(wav, sr, settings.highpass_hz) + wav = deess(wav, sr, settings) + wav = compress(wav, sr, settings) + return limit(wav, sr, settings) diff --git a/scripts/narrate_book.py b/scripts/narrate_book.py index dbaeecba..75ebfa93 100644 --- a/scripts/narrate_book.py +++ b/scripts/narrate_book.py @@ -139,6 +139,9 @@ def build_parser() -> argparse.ArgumentParser: help="Loudness target in dBFS (ACX window is -23..-18, default: -20)") pauses.add_argument("--no-master", action="store_true", help="Skip trimming, de-clicking and loudness normalization") + pauses.add_argument("--no-polish", action="store_true", + help="Skip the studio chain (high-pass, de-esser, compressor, limiter) " + "applied to each chapter before its level is set") run = parser.add_argument_group("exécution") run.add_argument("--outdir", help="Output directory (default: output/book_)") @@ -294,7 +297,9 @@ def main() -> int: model_id=args.model_id, ) cache = cache_tools.ChunkCache(outdir / ".cache", enabled=not args.no_cache) - mastering = audio_tools.MasteringSettings(target_rms_db=args.target_rms) + mastering = audio_tools.MasteringSettings( + target_rms_db=args.target_rms, polish=not args.no_polish + ) # The plan is what makes a later repair possible: without it, which cache # entry holds which sentence is lost the moment this run ends. Written diff --git a/tests/test_narration_audio.py b/tests/test_narration_audio.py index 609c7b8e..a2c4fd19 100644 --- a/tests/test_narration_audio.py +++ b/tests/test_narration_audio.py @@ -213,9 +213,16 @@ def test_the_finished_chapter_is_normalized_once(self): assert audio.speech_rms_db(result, SR) == pytest.approx(-20.0, abs=0.5) def test_normalization_can_be_skipped(self): + # Polish off as well: the studio chain deliberately changes the level + # before the normalisation does, and what is under test here is only + # that the normalisation itself can be skipped. loud = sine(1.0, amplitude=0.5) - result = audio.stitch([(loud, 0.0)], SR, audio.MasteringSettings(trim_silence=False), - normalize=False) + result = audio.stitch( + [(loud, 0.0)], + SR, + audio.MasteringSettings(trim_silence=False, polish=False), + normalize=False, + ) assert audio.peak_db(result) == pytest.approx(audio.peak_db(loud), abs=0.1) def test_no_segments(self): diff --git a/tests/test_narration_polish.py b/tests/test_narration_polish.py new file mode 100644 index 00000000..68601a9d --- /dev/null +++ b/tests/test_narration_polish.py @@ -0,0 +1,272 @@ +"""Tests for the studio chain and the LUFS measurement. + +The loudness figure is the one number here that has a right answer defined +outside this repository, and it was cross-checked against ffmpeg's ebur128 +filter on real narrated chapters: -20.98 against -20.9, -20.27 against -20.2, +-20.96 against -20.9. What the tests below pin is the behaviour that follows +from the specification — the gates, the 6 dB relation, the offset — so a change +that breaks it fails here rather than on someone's upload. +""" +import numpy as np +import pytest + +from narration import audio, polish + +SR = 24000 + + +def sine(seconds: float, frequency: float = 1000.0, amplitude: float = 0.1) -> np.ndarray: + t = np.arange(int(SR * seconds), dtype=np.float32) / SR + return (amplitude * np.sin(2 * np.pi * frequency * t)).astype(np.float32) + + +def voice_like(seconds: float = 3.0) -> np.ndarray: + """Something with a fundamental, harmonics and an envelope.""" + t = np.arange(int(SR * seconds), dtype=np.float32) / SR + body = sum(np.sin(2 * np.pi * f * t) / (i + 1) for i, f in enumerate([180, 360, 720, 1440])) + envelope = 0.5 + 0.5 * np.sin(2 * np.pi * 3.0 * t) + signal = (body * envelope).astype(np.float32) + return signal * (0.1 / max(float(np.max(np.abs(signal))), 1e-9)) + + +class TestHighpass: + # Measured away from the edges: a zero-phase filter rings briefly at both + # ends of a signal that starts mid-cycle. Real chapters open and close on + # room tone, so this never happens to them — only to a bare test sine. + STEADY = slice(int(SR * 0.5), int(SR * 1.5)) + + def test_rumble_is_removed(self): + rumble = sine(2.0, frequency=35.0, amplitude=0.3) + cleaned = polish.highpass(rumble, SR, 80.0) + assert audio.peak_db(cleaned[self.STEADY]) < audio.peak_db(rumble[self.STEADY]) - 20 + + def test_the_voice_band_is_left_alone(self): + voice = sine(2.0, frequency=400.0, amplitude=0.2) + cleaned = polish.highpass(voice, SR, 80.0) + assert audio.peak_db(cleaned[self.STEADY]) == pytest.approx( + audio.peak_db(voice[self.STEADY]), abs=0.5 + ) + + def test_it_does_not_shift_the_signal_in_time(self): + """Zero-phase: a transient must not move, or consonants smear.""" + click = np.zeros(SR, dtype=np.float32) + click[SR // 2] = 0.5 + filtered = polish.highpass(click, SR, 80.0) + assert abs(int(np.argmax(np.abs(filtered))) - SR // 2) <= 2 + + def test_a_very_short_signal_is_returned_rather_than_refused(self): + tiny = sine(0.001) + assert polish.highpass(tiny, SR, 80.0).size == tiny.size + + def test_a_nonsense_cutoff_is_ignored(self): + voice = voice_like(0.5) + assert np.array_equal(polish.highpass(voice, SR, 0.0), voice) + assert np.array_equal(polish.highpass(voice, SR, SR), voice) + + +class TestDeess: + @staticmethod + def with_sibilance() -> np.ndarray: + """A voice with one harsh 7 kHz burst in the middle of it.""" + voice = voice_like(3.0) + burst = np.zeros_like(voice) + start, end = int(SR * 1.4), int(SR * 1.6) + burst[start:end] = sine(0.2, frequency=7000.0, amplitude=0.35) + return voice + burst + + def test_the_sibilant_peak_is_pulled_down(self): + harsh = self.with_sibilance() + treated = polish.deess(harsh, SR) + start, end = int(SR * 1.4), int(SR * 1.6) + before = audio.peak_db(harsh[start:end]) + after = audio.peak_db(treated[start:end]) + assert after < before - 1.5 + + def test_the_rest_of_the_voice_is_untouched(self): + harsh = self.with_sibilance() + treated = polish.deess(harsh, SR) + quiet = slice(0, int(SR * 1.0)) + assert audio.peak_db(treated[quiet]) == pytest.approx( + audio.peak_db(harsh[quiet]), abs=0.6 + ) + + def test_it_can_be_switched_off(self): + harsh = self.with_sibilance() + off = polish.PolishSettings(deess=False) + assert np.array_equal(polish.deess(harsh, SR, off), harsh) + + def test_a_voice_without_sibilance_is_barely_changed(self): + """It must not sound like a blanket over a narrator who never hisses.""" + voice = voice_like(3.0) + treated = polish.deess(voice, SR) + assert audio.speech_rms_db(treated, SR) == pytest.approx( + audio.speech_rms_db(voice, SR), abs=1.0 + ) + + +class TestCompress: + @staticmethod + def uneven() -> np.ndarray: + """A quiet line followed by a loud one — the case that loses sentences.""" + return np.concatenate([voice_like(2.0) * 0.15, voice_like(2.0)]) + + def test_the_gap_between_quiet_and_loud_closes(self): + source = self.uneven() + treated = polish.compress(source, SR) + half = source.size // 2 + before = audio.speech_rms_db(source[half:], SR) - audio.speech_rms_db(source[:half], SR) + after = audio.speech_rms_db(treated[half:], SR) - audio.speech_rms_db(treated[:half], SR) + assert after < before + + def test_it_never_raises_the_level(self): + """Make-up gain belongs to the normalisation, not here.""" + source = self.uneven() + treated = polish.compress(source, SR) + assert audio.peak_db(treated) <= audio.peak_db(source) + 0.01 + + def test_the_threshold_follows_the_signal_not_dbfs(self): + """A quiet chapter and a loud one must be treated the same way.""" + loud = self.uneven() + quiet = loud * 0.05 + loud_reduction = audio.peak_db(loud) - audio.peak_db(polish.compress(loud, SR)) + quiet_reduction = audio.peak_db(quiet) - audio.peak_db(polish.compress(quiet, SR)) + assert loud_reduction == pytest.approx(quiet_reduction, abs=0.5) + + def test_it_can_be_switched_off(self): + source = self.uneven() + off = polish.PolishSettings(compress=False) + assert np.array_equal(polish.compress(source, SR, off), source) + + +class TestLimit: + @staticmethod + def with_peaks() -> np.ndarray: + """Sustained speech with a few short spikes standing well above it.""" + voice = voice_like(4.0) + for position in (0.7, 1.9, 3.1): + start = int(SR * position) + voice[start : start + int(SR * 0.004)] *= 6.0 + return np.clip(voice, -1.0, 1.0) + + def test_the_crest_factor_comes_down(self): + source = self.with_peaks() + limited = polish.limit(source, SR) + before = audio.peak_db(source) - audio.speech_rms_db(source, SR) + after = audio.peak_db(limited) - audio.speech_rms_db(limited, SR) + assert after < before - 2.0 + + def test_it_leaves_a_chapter_that_is_already_controlled_alone(self): + voice = voice_like(4.0) + limited = polish.limit(voice, SR) + assert audio.speech_rms_db(limited, SR) == pytest.approx( + audio.speech_rms_db(voice, SR), abs=0.5 + ) + + def test_the_target_crest_is_respected(self): + source = self.with_peaks() + settings = polish.PolishSettings(limit_crest_db=10.0) + limited = polish.limit(source, SR, settings) + crest = audio.peak_db(limited) - audio.speech_rms_db(limited, SR) + assert crest <= 10.0 + 2.0 + + def test_it_can_be_switched_off(self): + source = self.with_peaks() + off = polish.PolishSettings(limit=False) + assert np.array_equal(polish.limit(source, SR, off), source) + + def test_the_chain_ends_with_more_headroom_than_it_started(self): + """This is what stops the ceiling from dragging the chapter quiet.""" + source = self.with_peaks() + treated = polish.polish(source, SR) + before = audio.peak_db(source) - audio.speech_rms_db(source, SR) + after = audio.peak_db(treated) - audio.speech_rms_db(treated, SR) + assert after < before + + +class TestLoudness: + def test_doubling_the_amplitude_adds_six_units(self): + voice = voice_like(4.0) + assert polish.loudness_lufs(voice * 2, SR) == pytest.approx( + polish.loudness_lufs(voice, SR) + 6.02, abs=0.05 + ) + + def test_silence_has_no_loudness(self): + assert polish.loudness_lufs(audio.silence(SR, 3.0), SR) == float("-inf") + + def test_a_signal_shorter_than_a_block_has_none_either(self): + assert polish.loudness_lufs(sine(0.2), SR) == float("-inf") + + def test_silence_between_sentences_does_not_drag_it_down(self): + """The gates exist for exactly this: a pause is not quiet programme.""" + speech = voice_like(4.0) + with_pauses = np.concatenate([speech, audio.silence(SR, 4.0), speech]) + assert polish.loudness_lufs(with_pauses, SR) == pytest.approx( + polish.loudness_lufs(speech, SR), abs=0.6 + ) + + def test_it_is_reported_in_the_acx_report(self): + report = audio.acx_report(voice_like(4.0), SR) + assert "lufs" in report + assert np.isfinite(report["lufs"]) + + def test_it_is_reported_but_never_gated_on(self): + """No standard states one LUFS target, so none is invented here.""" + report = audio.acx_report(voice_like(4.0), SR) + assert not any(key.startswith("lufs") and key.endswith("_ok") for key in report) + + +class TestTheChain: + def test_order_is_correct_then_control(self): + """Rumble must be gone before the compressor can duck on it.""" + rumble = sine(3.0, frequency=30.0, amplitude=0.4) + source = voice_like(3.0) + rumble + treated = polish.polish(source, SR) + # What is left below 80 Hz is a fraction of what went in. + low_before = np.std(source - polish.highpass(source, SR, 80.0)) + low_after = np.std(treated - polish.highpass(treated, SR, 80.0)) + assert low_after < low_before / 4 + + def test_nothing_happens_when_everything_is_off(self): + source = voice_like(2.0) + off = polish.PolishSettings( + highpass_hz=0.0, deess=False, compress=False, limit=False + ) + assert not off.enabled + assert np.array_equal(polish.polish(source, SR, off), source) + + def test_it_does_not_set_the_level(self): + """Levelling happens once, later, in stitch.""" + source = voice_like(3.0) * 0.02 + treated = polish.polish(source, SR) + assert audio.speech_rms_db(treated, SR) < -20 + + def test_empty_audio_survives(self): + assert polish.polish(np.zeros(0, dtype=np.float32), SR).size == 0 + + +class TestWiring: + def test_a_stitched_chapter_is_polished_by_default(self): + segments = [(voice_like(2.0) + sine(2.0, 30.0, 0.4), 0.0)] + polished = audio.stitch(segments, SR, audio.MasteringSettings(), normalize=False) + raw = audio.stitch( + segments, SR, audio.MasteringSettings(polish=False), normalize=False + ) + assert not np.array_equal(polished, raw) + + def test_it_can_be_turned_off_from_the_mastering_settings(self): + settings = audio.MasteringSettings(polish=False, trim_silence=False) + source = voice_like(1.0) + result = audio.stitch([(source, 0.0)], SR, settings, normalize=False) + # The audio passed through untouched between the room tone and the + # de-click fades, which stitch applies whatever the polish setting. + lead = int(SR * settings.lead_sec) + middle = slice(lead + 1000, lead + source.size - 1000) + assert np.allclose(result[middle], source[1000:-1000], atol=1e-6) + + def test_the_flag_survives_a_saved_plan(self): + """plan.json holds flat JSON; a nested dataclass would not round-trip.""" + import dataclasses + + payload = dataclasses.asdict(audio.MasteringSettings()) + assert payload["polish"] is True + assert all(not isinstance(value, dict) for value in payload.values()) From 3bf14de5ba933d9ed580ead6368a00701f042d1d Mon Sep 17 00:00:00 2001 From: PaxHelios Date: Tue, 4 Aug 2026 15:29:15 +0200 Subject: [PATCH 35/98] feat(cloud): narrate from anywhere, and stop giving the model away MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Producing a book has meant sitting at one particular machine, and that machine is the slow one. Three routes out — a VPS for permanence, a rented GPU for speed, Kaggle for nothing at all — are now documented with numbers rather than impressions, and one script provisions all three. The script decides CPU or CUDA by looking for a GPU instead of asking, which also picks the right PyTorch wheel: the CPU wheels are a fraction of the size and the CUDA extras are dead weight on a box without one. It is idempotent — every step checks before acting — so it doubles as the update command. app.py grows --auth, and warns when it is missing. Bound to 0.0.0.0 without it, which is the default, the port hands the machine's whole synthesis capacity to whoever finds it; on a rented GPU that is someone else's work on your bill. The password is read from VOXCPM_AUTH as well, so it need not appear in shell history or in the process list, and the guide recommends an SSH tunnel over exposing the port at all. Measured on the target VPS — 2 cores, 8 GB — the guide says plainly what that costs: roughly three times slower than a laptop, and bfloat16 obligatory because the float32 weights want 8.7 GB resident and die at load without them. Disk is never the constraint; the whole install is 6.2 GB. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Fity58qgKttpD1nLheWrzy --- app.py | 39 +++++++++ docs/CLOUD.md | 177 +++++++++++++++++++++++++++++++++++++++++ docs/GUIDE_FR.md | 3 + scripts/cloud_setup.sh | 127 +++++++++++++++++++++++++++++ 4 files changed, 346 insertions(+) create mode 100644 docs/CLOUD.md create mode 100644 scripts/cloud_setup.sh diff --git a/app.py b/app.py index 2fa2a58d..ff8fbd78 100644 --- a/app.py +++ b/app.py @@ -1970,6 +1970,19 @@ def _run_asr_if_needed(checked, audio_path): return interface +def parse_auth(value: Optional[str]) -> Optional[Tuple[str, str]]: + """``user:password`` into a pair Gradio can check, or None. + + A colon in the password is fine — only the first one separates. + """ + if not value: + return None + user, separator, password = value.partition(":") + if not separator or not user or not password: + raise ValueError("--auth expects user:password") + return user, password + + def run_demo( server_name: str = "0.0.0.0", server_port: int = 8808, @@ -1977,9 +1990,19 @@ def run_demo( model_id: str = "openbmb/VoxCPM2", device: str = "auto", load_denoiser: bool = True, + auth: Optional[Tuple[str, str]] = None, ): demo = VoxCPMDemo(model_id=model_id, device=device, load_denoiser=load_denoiser) interface = create_demo_interface(demo) + # Bound to every interface and unauthenticated, this hands a stranger the + # machine's whole synthesis capacity — and, on a rented GPU, the bill. + if server_name not in ("127.0.0.1", "localhost") and auth is None: + logger.warning( + "Listening on %s without --auth: anyone who can reach this port can " + "use the model. Bind to 127.0.0.1 and reach it through an SSH tunnel, " + "or set --auth user:password.", + server_name, + ) interface.queue(max_size=10, default_concurrency_limit=1).launch( server_name=server_name, server_port=server_port, @@ -1987,6 +2010,7 @@ def run_demo( i18n=I18N, theme=_APP_THEME, css=_CUSTOM_CSS, + auth=auth, ) @@ -2021,11 +2045,26 @@ def run_demo( "clean reference audio for cloning; disabling it speeds up startup and " "avoids a slow/blocking download — recommended for narration on CPU.", ) + parser.add_argument( + "--auth", + type=str, + default=os.environ.get("VOXCPM_AUTH", ""), + metavar="USER:PASSWORD", + help="Require a login. Essential whenever the port is reachable from " + "outside the machine — a rented GPU left open is someone else's " + "synthesis on your bill. Also read from VOXCPM_AUTH, so the " + "password need not appear in the command line or in shell history.", + ) args = parser.parse_args() + try: + auth = parse_auth(args.auth) + except ValueError as error: + raise SystemExit(str(error)) run_demo( model_id=args.model_id, server_name=args.host, server_port=args.port, device=args.device, load_denoiser=not args.no_denoiser, + auth=auth, ) diff --git a/docs/CLOUD.md b/docs/CLOUD.md new file mode 100644 index 00000000..b9324c9e --- /dev/null +++ b/docs/CLOUD.md @@ -0,0 +1,177 @@ +# Narrer ailleurs que sur son poste + +Ce guide sert à une chose : pouvoir produire un livre audio **depuis n'importe où**, +sans dépendre de la machine qu'on a sous la main. Trois routes, selon ce qu'on +cherche — la permanence, la vitesse, ou la gratuité. + +Une seule commande les prépare toutes les trois, parce que la seule chose qui les +distingue est la présence d'un GPU, et le script la détecte au lieu de la demander : + +```bash +curl -fsSL https://raw.githubusercontent.com/Eddyosas008/VoxCPM/claude/repo-analysis-improvement-dg0ies/scripts/cloud_setup.sh | bash +``` + +Il installe les paquets système, clone le dépôt, choisit la roue PyTorch adaptée +(CUDA ou CPU — les roues CPU sont bien plus légères), installe le projet et +télécharge le modèle. Relancé, il ne refait rien : chaque étape vérifie avant +d'agir. + +## Ce que pèse l'installation + +| | | +|---|---| +| Modèle VoxCPM2 (cache Hugging Face) | ~4,6 Go | +| Environnement Python avec PyTorch | ~1,6 Go | +| **Total** | **~6,2 Go** | + +Autrement dit : l'espace disque n'est jamais le facteur limitant. **La RAM l'est.** +Le modèle demande environ **8,7 Go résidents en float32**, et c'est au chargement +des poids qu'il meurt quand ils manquent. En dessous de 12 Go de RAM, il faut +`VOXCPM_CPU_DTYPE=bfloat16` : empreinte divisée par deux (~4,4 Go), un peu plus +lent parce que le bfloat16 est émulé sur processeur. + +## Route 1 — un VPS, pour la permanence + +**Ce que ça apporte** : la machine tourne en continu. On lance une narration, on +ferme son portable, on récupère les chapitres deux jours plus tard. La chaîne +étant reprenable au segment près, une coupure ne coûte que le segment en cours. + +**Ce que ça n'apporte pas** : de la vitesse. Un VPS d'entrée de gamme a 2 cœurs, +c'est-à-dire moins qu'un portable courant. + +Exemple mesuré sur un Hostinger **KVM 2** — 2 cœurs, 8 Go de RAM, 100 Go de +disque, Ubuntu 24.04 : le disque est confortable, la RAM impose le bfloat16, et +les 2 cœurs rendent la narration environ **2 à 3 fois plus lente** qu'un portable +à 4 cœurs. Un livre de 3 heures y demande de l'ordre de **5 jours** — acceptable +seulement parce que personne n'attend devant. + +Monter en gamme change la donne : 8 cœurs et 32 Go permettent le float32 et +divisent le temps par quatre. À comparer honnêtement au coût d'un GPU loué à +l'heure, qui fait le même livre en moins d'une heure. + +## Route 2 — un GPU loué à l'heure, pour la vitesse + +C'est la seule option qui change l'ordre de grandeur : **RTF ~0,3 contre ~18 à 40 +sur processeur**, soit un livre de 3 heures en moins d'une heure de calcul. + +Chez [RunPod](https://www.runpod.io/pricing), une RTX 4090 est à environ +**0,34 $/h** en Community Cloud, facturée à la seconde. Un livre entier coûte donc +moins qu'un café. Aucun engagement : on crée l'instance, on lance le script +ci-dessus, on narre, on rapatrie, on détruit. + +```bash +# sur la machine louée +bash scripts/cloud_setup.sh +nohup ./.venv/bin/python scripts/narrate_book.py livre.epub \ + --voice "Narrateur profond & calme" --device cuda \ + --assemble m4b --export-acx > narration.log 2>&1 & + +# depuis chez soi, quand c'est fini +rsync -avz root@:~/voxcpm/output/book_/ ./book_/ +``` + +**Détruire l'instance en partant.** Elle est facturée tant qu'elle existe, même +inactive. + +## Route 3 — Kaggle, pour ne rien payer + +[Kaggle](https://www.kaggle.com/product-feedback/173129) donne **une trentaine +d'heures de GPU par semaine** (P100, ou deux T4), en sessions de **12 heures +maximum**. C'est gratuit, c'est un vrai GPU, et la limite de session n'est pas +bloquante ici : le cache par segment fait qu'une session reprend là où la +précédente s'est arrêtée. Un livre de 3 heures tient en une ou deux sessions. + +La contrainte est le disque éphémère : il faut écrire les chapitres dans les +*outputs* du notebook, ou les pousser ailleurs avant la fin de session. + +## Atteindre l'interface à distance, sans l'offrir à tout le monde + +`app.py` écoute par défaut sur `0.0.0.0`, c'est-à-dire sur toutes les interfaces. +Sur une machine distante, **cela met le modèle à la disposition de quiconque +trouve le port** — et sur un GPU loué, c'est votre facture qui synthétise pour un +inconnu. L'application le signale désormais au démarrage. + +Deux façons correctes : + +**Le tunnel SSH** — rien n'est exposé, c'est la plus sûre : + +```bash +# sur le serveur +./.venv/bin/python app.py --host 127.0.0.1 --port 8808 --device cuda --no-denoiser + +# sur votre poste +ssh -N -L 8808:127.0.0.1:8808 root@ +# puis http://127.0.0.1:8808 +``` + +**Un mot de passe**, si l'accès direct est nécessaire : + +```bash +VOXCPM_AUTH='edwin:motdepasse' ./.venv/bin/python app.py \ + --host 0.0.0.0 --port 8808 --device cuda --no-denoiser +``` + +Le mot de passe passe par la variable d'environnement plutôt que par +`--auth` en ligne de commande, pour qu'il n'atterrisse ni dans l'historique du +shell ni dans la liste des processus. + +## Laisser tourner sans surveillance + +Sur un VPS, une narration dure des jours : elle doit survivre à la fermeture de la +session SSH. + +```bash +nohup ./.venv/bin/python scripts/narrate_book.py livre.epub \ + --voice "..." --outdir output/book_mon_livre > narration.log 2>&1 & +tail -f narration.log +``` + +Pour l'interface, qui elle doit repartir après un redémarrage du serveur, un +service systemd : + +```ini +# /etc/systemd/system/voxcpm.service +[Unit] +Description=VoxCPM narration +After=network.target + +[Service] +User=root +WorkingDirectory=/root/voxcpm +Environment=VOXCPM_AUTH=edwin:motdepasse +Environment=VOXCPM_CPU_DTYPE=bfloat16 +ExecStart=/root/voxcpm/.venv/bin/python app.py --host 127.0.0.1 --port 8808 --device cpu --no-denoiser +Restart=on-failure + +[Install] +WantedBy=multi-user.target +``` + +```bash +systemctl enable --now voxcpm +journalctl -u voxcpm -f +``` + +## Couper la chaîne en deux + +Le paquet `narration/` ne dépend ni de `torch` ni de `gradio` — c'est délibéré. +Tout ce qui n'est pas la synthèse tourne donc sur n'importe quelle petite machine, +en quelques secondes : + +- contrôle qualité et listage des défauts (`repair_segment.py --list`) +- réparation d'un segment (celle-ci a besoin du modèle) +- assemblage M4B avec marqueurs et couverture +- contrôle de conformité et export de dépôt (`export_acx.py`) + +L'architecture qui en découle : **le GPU loué ne fait que synthétiser**, quelques +dizaines de minutes, et tout le reste vit sur le VPS ou sur le poste local. C'est +ce qui rend la location à l'heure économique. + +## Récapitulatif + +| | Vitesse | Coût | Pour quoi | +|---|---|---|---| +| **VPS 2 cœurs** | ~3× plus lent qu'un portable | déjà payé | Permanence, stockage, tout le hors-synthèse | +| **VPS 8 cœurs** | ~4× un portable | abonnement mensuel | Narration sans surveillance, sans louer | +| **GPU à l'heure** | **~60× un portable** | ~0,34 $/h | Un livre entier en moins d'une heure | +| **Kaggle** | GPU, sessions de 12 h | gratuit | Essais, et livres entiers avec un peu de patience | diff --git a/docs/GUIDE_FR.md b/docs/GUIDE_FR.md index e774c74b..25940b4c 100644 --- a/docs/GUIDE_FR.md +++ b/docs/GUIDE_FR.md @@ -116,6 +116,9 @@ python scripts/assemble_audiobook.py output/book_mon_livre --title "Mon Livre" - python scripts/export_acx.py output/book_mon_livre ``` +**→ Pour narrer ailleurs que sur son poste** — VPS, GPU loué à l'heure, Kaggle, +et comment atteindre l'interface à distance sans l'exposer : [docs/CLOUD.md](CLOUD.md). + **→ Le guide détaillé est dans [docs/NARRATION.md](NARRATION.md)** : vitesse selon le matériel, import EPUB, réglages par usage (fiction, documentaire, méditation, podcast), lexique de prononciation personnalisé, et normes de sonie. diff --git a/scripts/cloud_setup.sh b/scripts/cloud_setup.sh new file mode 100644 index 00000000..db5f1f29 --- /dev/null +++ b/scripts/cloud_setup.sh @@ -0,0 +1,127 @@ +#!/usr/bin/env bash +# Provision any Ubuntu machine to narrate — a VPS, a rented GPU box, anything. +# +# The same script serves both because the only thing that differs between them +# is which PyTorch wheel to fetch, and that is decided here by looking for a +# GPU rather than by asking. Run it twice and it changes nothing the second +# time: every step checks before it acts. +# +# curl -fsSL https://raw.githubusercontent.com/Eddyosas008/VoxCPM/claude/repo-analysis-improvement-dg0ies/scripts/cloud_setup.sh | bash +# +# or, once the repository is already there: +# +# bash scripts/cloud_setup.sh +# +# Environment: +# VOXCPM_DIR where to install (default: ~/voxcpm) +# VOXCPM_BRANCH branch to check out (default: claude/repo-analysis-improvement-dg0ies) +# VOXCPM_REPO repository to clone (default: this fork) +# SKIP_MODEL=1 do not pre-download the model +set -euo pipefail + +DIR="${VOXCPM_DIR:-$HOME/voxcpm}" +BRANCH="${VOXCPM_BRANCH:-claude/repo-analysis-improvement-dg0ies}" +REPO="${VOXCPM_REPO:-https://github.com/Eddyosas008/VoxCPM.git}" + +say() { printf '\n\033[1;35m==> %s\033[0m\n' "$*"; } + +# --- What are we on? ------------------------------------------------------- +CORES="$(nproc)" +RAM_MB="$(awk '/MemTotal/ {print int($2/1024)}' /proc/meminfo)" +if command -v nvidia-smi >/dev/null 2>&1 && nvidia-smi -L >/dev/null 2>&1; then + GPU="$(nvidia-smi --query-gpu=name --format=csv,noheader | head -1)" + TORCH_INDEX="https://download.pytorch.org/whl/cu124" + DEVICE="cuda" +else + GPU="" + # The CPU wheels are a fraction of the size of the CUDA ones, and on a box + # without a GPU the CUDA extras are several gigabytes of dead weight. + TORCH_INDEX="https://download.pytorch.org/whl/cpu" + DEVICE="cpu" +fi + +say "Machine : ${CORES} cœur(s), ${RAM_MB} Mo de RAM, ${GPU:-pas de GPU} → device=${DEVICE}" + +if [ "$DEVICE" = "cpu" ] && [ "$RAM_MB" -lt 12000 ]; then + # float32 weights need about 8.7 GB resident, and the load is where it dies. + echo " RAM limitée : lancez la narration avec VOXCPM_CPU_DTYPE=bfloat16" + echo " (empreinte divisée par deux, un peu plus lent — mais il faut que ça tienne)" +fi + +# --- System packages ------------------------------------------------------- +say "Paquets système" +SUDO="" +[ "$(id -u)" -ne 0 ] && SUDO="sudo" +export DEBIAN_FRONTEND=noninteractive +$SUDO apt-get update -qq +$SUDO apt-get install -y -qq git curl python3 python3-venv python3-pip ffmpeg libsndfile1 + +# --- The repository -------------------------------------------------------- +if [ -d "$DIR/.git" ]; then + say "Mise à jour de $DIR" + git -C "$DIR" fetch --quiet origin "$BRANCH" + git -C "$DIR" checkout --quiet "$BRANCH" + git -C "$DIR" pull --quiet --ff-only origin "$BRANCH" +else + say "Clonage dans $DIR" + git clone --quiet --branch "$BRANCH" "$REPO" "$DIR" +fi +cd "$DIR" + +# --- Python ---------------------------------------------------------------- +say "Environnement Python" +[ -d .venv ] || python3 -m venv .venv +# shellcheck disable=SC1091 +source .venv/bin/activate +pip install --quiet --upgrade pip wheel + +say "PyTorch (${DEVICE})" +python - <<'PY' || pip install --quiet torch torchaudio --index-url "$TORCH_INDEX" +import sys +try: + import torch # noqa: F401 +except ImportError: + sys.exit(1) +PY + +say "Dépendances du projet" +pip install --quiet -e . + +# --- The model ------------------------------------------------------------ +if [ "${SKIP_MODEL:-0}" != "1" ]; then + say "Téléchargement du modèle (≈4,6 Go, une seule fois)" + python - <<'PY' +from huggingface_hub import snapshot_download + +path = snapshot_download("openbmb/VoxCPM2") +print(f" modèle dans {path}") +PY +fi + +# --- Ready ---------------------------------------------------------------- +say "Prêt" +cat < narration.log 2>&1 & + + # Suivre : + tail -f narration.log + + # L'interface, accessible uniquement par tunnel SSH (recommandé) : + ./.venv/bin/python app.py --host 127.0.0.1 --port 8808 --device $DEVICE --no-denoiser + # puis depuis votre poste : ssh -N -L 8808:127.0.0.1:8808 root@ + # et ouvrez http://127.0.0.1:8808 + + # Ou exposée, avec mot de passe obligatoire : + VOXCPM_AUTH='edwin:motdepasse' ./.venv/bin/python app.py \\ + --host 0.0.0.0 --port 8808 --device $DEVICE --no-denoiser + + # Rapatrier les chapitres finis, depuis votre poste : + rsync -avz root@:$DIR/output/book_/ ./book_/ + +EOF From e8333099c310c0f1c315a57fdb0b49d8dbfa2397 Mon Sep 17 00:00:00 2001 From: PaxHelios Date: Tue, 4 Aug 2026 15:33:30 +0200 Subject: [PATCH 36/98] feat(text_fr): a lexicon entry that knows where it applies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pronunciation lexicon replaced whole words unconditionally, which is enough for SNCF and useless for the thing that actually trips a French narration: a homograph. "Il est tard" and "à l'est" are the same three letters and two different words, so a rule aimed at the word alone must either break one of them or do nothing at all. A value may now be an object carrying the context it applies in — après and avant, both regexes — and only then is the word replaced. The preceding context is captured and put back rather than looked behind, because Python's lookbehind must be fixed width and "à l'|dans l'" is exactly the alternation that is not. String values keep working untouched, and a malformed entry is dropped rather than raised on: an optional override file with a typo must never take a nine-hour narration down with it. conf/pronunciation_fr.json ships templates for the classic French traps — est, fils, couvent, portions, violent, content, négligent, plus — deliberately disabled behind the comment prefix. I cannot hear what this engine already gets right, and a correction applied to a word that was fine can only make it worse. The user enables what their ear says is broken. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Fity58qgKttpD1nLheWrzy --- conf/pronunciation_fr.json | 50 +++++++++++++++++++++++- docs/NARRATION.md | 26 +++++++++++++ narration/text_fr.py | 68 ++++++++++++++++++++++++++++++--- tests/test_narration_text_fr.py | 40 +++++++++++++++++++ 4 files changed, 177 insertions(+), 7 deletions(-) diff --git a/conf/pronunciation_fr.json b/conf/pronunciation_fr.json index bf7b51fe..86225a87 100644 --- a/conf/pronunciation_fr.json +++ b/conf/pronunciation_fr.json @@ -1,13 +1,61 @@ { "_comment": "Lexique de prononciation. Clé = ce qui est écrit dans le texte, valeur = ce qui doit être prononcé. Le remplacement est insensible à la casse et ne s'applique qu'à des mots entiers. Les clés commençant par _ sont ignorées (commentaires). Utile surtout pour les noms propres, les sigles et les mots étrangers d'un livre donné.", + "_exemple_sigles": "--- sigles lus lettre par lettre ---", "SNCF": "S N C F", "RATP": "R A T P", "ONU": "O N U", "URSS": "U R S S", + "_exemple_etrangers": "--- mots étrangers ---", "Wi-Fi": "wifi", "email": "i-mail", + "_exemple_noms": "--- noms propres à adapter à votre livre ---", - "Nietzsche": "Nitche" + "Nietzsche": "Nitche", + + "_comment_contexte": "Une valeur peut aussi être un objet, pour ne remplacer QUE dans un contexte donné : {\"prononcer\": \"...\", \"après\": \"regex\", \"avant\": \"regex\"}. C'est le seul moyen de traiter un homographe : « il est » et « à l'est » s'écrivent pareil et ne se disent pas pareil, donc une règle qui vise le mot seul casse forcément l'un des deux.", + + "_comment_homographes": "Les entrées ci-dessous sont des MODÈLES, volontairement désactivées (préfixe _ = ignoré). Écoutez d'abord : si la voix lit déjà correctement « l'est » ou « le couvent », n'y touchez pas — une correction inutile ne peut que dégrader. Quand vous en repérez une fausse, recopiez la ligne sans le préfixe et ajustez l'orthographe phonétique à l'oreille.", + + "_est": { + "prononcer": "èsste", + "après": "à l'|dans l'|vers l'|de l'|l'", + "_pourquoi": "le point cardinal, contre le verbe être" + }, + "_fils": { + "prononcer": "fisse", + "après": "mon|son|ton|le|un|leur", + "_pourquoi": "l'enfant, contre les fils électriques" + }, + "_couvent": { + "prononcer": "couvan", + "après": "le|du|au|ce|un", + "_pourquoi": "le monastère, contre le verbe couver (ils couvent)" + }, + "_portions": { + "prononcer": "porcions", + "après": "les|des|deux|trois|quelques", + "_pourquoi": "les parts, contre le verbe porter (nous portions)" + }, + "_violent": { + "prononcer": "violan", + "après": "un|le|ce|très|si|plus", + "_pourquoi": "l'adjectif, contre le verbe violer (ils violent)" + }, + "_content": { + "prononcer": "contan", + "après": "je suis|il est|elle est|très|si|pas", + "_pourquoi": "l'adjectif, contre le verbe conter (ils content)" + }, + "_négligent": { + "prononcer": "négligean", + "après": "un|le|ce|très|si", + "_pourquoi": "l'adjectif, contre le verbe négliger (ils négligent)" + }, + "_plus": { + "prononcer": "pluss", + "avant": "de|que|d'", + "_pourquoi": "le s se prononce dans « plus de dix », pas dans « plus tard »" + } } diff --git a/docs/NARRATION.md b/docs/NARRATION.md index d58a6211..53175e81 100644 --- a/docs/NARRATION.md +++ b/docs/NARRATION.md @@ -514,6 +514,32 @@ C'est l'outil pour les noms propres d'un roman, les sigles et les mots étranger Le remplacement est insensible à la casse et ne s'applique qu'à des mots entiers. Les clés commençant par `_` sont des commentaires. +### Les homographes : quand le même mot se dit de deux façons + +`« il est »` et `« à l'est »` s'écrivent pareil et ne se prononcent pas pareil. +Une entrée de lexique qui vise le mot seul casse forcément l'un des deux, donc +une valeur peut être un **objet à contexte** : + +```json +"est": { "prononcer": "èsste", "après": "à l'|dans l'|vers l'|l'" }, +"plus": { "prononcer": "pluss", "avant": "de|que|d'" } +``` + +`après` et `avant` sont des expressions régulières ; seul ce qui suit le contexte +est remplacé, le contexte lui-même est conservé. Résultat : + +``` +La SNCF est à l'est. Il est tard. +→ La S N C F est à l'èsste. Il est tard. +``` + +`conf/pronunciation_fr.json` contient une **série de modèles désactivés** pour les +pièges classiques du français — *est, fils, couvent, portions, violent, content, +négligent, plus*. Ils sont désactivés à dessein : **écoutez d'abord**. Si la voix +lit déjà correctement « le couvent », corriger ne peut que dégrader. Quand vous en +repérez un faux, retirez le préfixe `_` de la ligne et ajustez l'orthographe +phonétique à l'oreille. + ## Ce que la préparation du texte corrige (et ses limites) Sont gérés : nombres cardinaux et ordinaux (`1er`, `2e`, `1re`), décimales, sommes en diff --git a/narration/text_fr.py b/narration/text_fr.py index 708e3562..c01545ac 100644 --- a/narration/text_fr.py +++ b/narration/text_fr.py @@ -26,6 +26,7 @@ import json import re import unicodedata +from dataclasses import dataclass from pathlib import Path from typing import Dict, Iterable, Mapping, Optional @@ -34,6 +35,7 @@ "cardinal", "ordinal", "roman_to_int", + "Pronunciation", "load_lexicon", "DEFAULT_ROMAN_TRIGGERS", ] @@ -283,13 +285,64 @@ def _strip_markdown(text: str) -> str: return text -def _apply_lexicon(text: str, lexicon: Mapping[str, str]) -> str: +@dataclass(frozen=True) +class Pronunciation: + """How a written form should be said, and when that applies. + + Without ``after`` and ``before`` this is the plain substitution the lexicon + has always done. With them it becomes the only thing that can handle a + French homograph: *il est* and *à l'est* are the same three letters and two + different words, so a rule that fires on the word alone must either break + one of them or do nothing. + """ + + spoken: str + #: Regex that must match immediately before the word — "à l'|dans l'". + after: str = "" + #: Regex that must match immediately after it. + before: str = "" + + @classmethod + def parse(cls, value) -> Optional["Pronunciation"]: + """Read either form from the lexicon file, or None if it makes no sense. + + A malformed entry is dropped rather than raised on: an optional override + file with a typo in it must not take a nine-hour narration down. + """ + if isinstance(value, str): + return cls(value) if value else None + if isinstance(value, Mapping): + spoken = str(value.get("prononcer", "")).strip() + if not spoken: + return None + return cls( + spoken=spoken, + after=str(value.get("après", value.get("apres", ""))), + before=str(value.get("avant", "")), + ) + return None + + +def _apply_lexicon(text: str, lexicon: Mapping[str, object]) -> str: """Apply user pronunciation overrides, longest key first so that multi-word entries win over their own prefixes.""" for source in sorted(lexicon, key=len, reverse=True): - replacement = lexicon[source] - pattern = re.compile(rf"(?{entry.after})(?P\s*){word}", re.IGNORECASE) + replacement = "\\g\\g" + entry.spoken.replace("\\", "\\\\") + else: + pattern = re.compile(word + (rf"(?=\s*(?:{entry.before}))" if entry.before else ""), + re.IGNORECASE) + replacement = entry.spoken.replace("\\", "\\\\") + text = pattern.sub(replacement, text) return text @@ -463,8 +516,11 @@ def load_lexicon(path: str | Path) -> Dict[str, str]: if not isinstance(data, dict): return {} # Keys starting with "_" are comments — JSON has no other way to carry one. + # Values are kept as they were written — a string for a plain substitution, + # an object for one that only applies in context — and interpreted later by + # Pronunciation.parse, which drops whatever it cannot make sense of. return { - str(k): str(v) + str(k): v for k, v in data.items() - if str(k).strip() and not str(k).startswith("_") + if str(k).strip() and not str(k).startswith("_") and Pronunciation.parse(v) } diff --git a/tests/test_narration_text_fr.py b/tests/test_narration_text_fr.py index 9f348963..e490292d 100644 --- a/tests/test_narration_text_fr.py +++ b/tests/test_narration_text_fr.py @@ -249,6 +249,46 @@ def test_roman_expansion_can_be_disabled(self): assert "XIV" in normalize_french("chapitre XIV", expand_roman=False) +class TestContextualLexicon: + """A homograph cannot be fixed by a rule that fires on the word alone.""" + + EAST = {"est": {"prononcer": "èsste", "après": "à l'|dans l'|vers l'|l'"}} + + def test_it_fires_in_context(self): + assert "èsste" in normalize_french("Le vent vient de l'est.", lexicon=self.EAST) + + def test_it_leaves_the_other_word_alone(self): + """`il est` must survive a rule aimed at `à l'est`.""" + out = normalize_french("Il est tard et elle est partie.", lexicon=self.EAST) + assert "èsste" not in out + assert "est tard" in out + + def test_the_context_itself_is_kept(self): + out = normalize_french("Il regarde vers l'est.", lexicon=self.EAST) + assert "vers l'" in out + + def test_a_following_context_works_too(self): + lexicon = {"plus": {"prononcer": "pluss", "avant": "de|que"}} + out = normalize_french("Il y en a plus de dix, plus tard.", lexicon=lexicon) + assert "pluss de dix" in out + assert "plus tard" in out + + def test_a_plain_string_entry_still_works(self): + assert "S N C F" in normalize_french("La SNCF.", lexicon={"SNCF": "S N C F"}) + + def test_case_does_not_matter(self): + assert "èsste" in normalize_french("À L'EST, la mer.", lexicon=self.EAST) + + def test_a_malformed_entry_is_ignored_not_fatal(self): + lexicon = {"est": {"pas_la_bonne_clef": "x"}, "SNCF": "S N C F"} + out = normalize_french("La SNCF est là.", lexicon=lexicon) + assert "S N C F" in out and "est là" in out + + def test_a_replacement_containing_a_backslash_is_literal(self): + out = normalize_french("Voir SNCF.", lexicon={"SNCF": r"S\N"}) + assert r"S\N" in out + + class TestLoadLexicon: def test_missing_file_yields_empty(self, tmp_path): assert load_lexicon(tmp_path / "nope.json") == {} From 799e294855519eedf7137af4109552a8f6369a5d Mon Sep 17 00:00:00 2001 From: PaxHelios Date: Tue, 4 Aug 2026 16:33:51 +0200 Subject: [PATCH 37/98] feat(text_en): narrate in English MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pipeline was French from the text preparation to the closing credits. --language en (or the tab's Language menu) now switches both, and the rest — segmentation, quality, mastering, studio chain, delivery — was never language-bound to begin with. narration/text_en.py is not a translation of the French module; English is irregular in different places: A year is said, not counted. 1789 is "seventeen eighty-nine", 1905 is "nineteen oh five", 2005 is "two thousand five". What tells a year from a quantity is the thousands separator — "1,789 men" is counted, "in 1789" is said — which is a rule the text carries and nothing else could recover. An ordinal suffix depends on the last two digits, so 11th is eleventh and not eleven-first, and 12th is twelfth rather than twelve-th. A title's period is not the end of a sentence. Mr. Dupont becomes Mister Dupont with the period consumed; left in place it invents a full stop mid sentence and the segmentation cuts the sentence there. Where a period really can end the sentence — etc. — it stays, which is the same distinction the French module already drew. The credits move from hard-coded French sentences to a per-language table, so a third language is an entry rather than a set of branches. The chapter markers follow: Opening credits / Closing credits. What stays French, and is documented as such: the preset voices are described in French and will carry an accent. English narration wants an English voice description, or a cloned one. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Fity58qgKttpD1nLheWrzy --- app.py | 45 +++-- docs/NARRATION.md | 38 +++- narration/__init__.py | 2 + narration/credits.py | 89 ++++++++-- narration/text_en.py | 304 ++++++++++++++++++++++++++++++++ scripts/narrate_book.py | 14 +- tests/test_narration_text_en.py | 148 ++++++++++++++++ 7 files changed, 610 insertions(+), 30 deletions(-) create mode 100644 narration/text_en.py create mode 100644 tests/test_narration_text_en.py diff --git a/app.py b/app.py index ff8fbd78..c28830f3 100644 --- a/app.py +++ b/app.py @@ -28,7 +28,7 @@ from narration import audio as audio_tools from narration import cache as cache_tools from narration import epub as epub_reader -from narration import chunking, credits, delivery, quality, repair, text_fr +from narration import chunking, credits, delivery, quality, repair, text_en, text_fr logging.basicConfig( level=logging.INFO, @@ -215,6 +215,8 @@ "book_text_label": "Book text — separate chapters with a line containing only ---", "book_title_label": "Book title", "book_author_label": "Author / narrator", + "book_language_label": "Language of the book", + "book_language_info": "Chooses the text preparation (numbers, abbreviations) and the wording of the credits.", "book_narrator_label": "Narrator named in the credits", "book_narrator_info": "Left empty, the credits state that the reading is a synthetic voice — which is what distributors require.", "book_credits_label": "Opening and closing credits", @@ -297,6 +299,8 @@ "book_text_label": "Texte du livre — séparez les chapitres par une ligne contenant seulement ---", "book_title_label": "Titre du livre", "book_author_label": "Auteur / narrateur", + "book_language_label": "Langue du livre", + "book_language_info": "Détermine la préparation du texte (nombres, abréviations) et la formulation du générique.", "book_narrator_label": "Narrateur cité au générique", "book_narrator_info": "Laissé vide, le générique indique que la lecture est une voix de synthèse — ce que les plateformes exigent.", "book_credits_label": "Générique de début et de fin", @@ -1023,7 +1027,7 @@ def _book_dir(title: str) -> Path: """Where a book's chapters and its resume cache live.""" return _BOOKS_DIR / f"book_{_sanitize_filename(title or 'livre')}" - def _book_credits(title, author, narrator, enabled: bool): + def _book_credits(title, author, narrator, enabled: bool, language: str = "fr"): """The credits a distributor requires, or None when switched off.""" if not enabled: return None @@ -1031,10 +1035,11 @@ def _book_credits(title, author, narrator, enabled: bool): title=(title or "").strip(), author=(author or "").strip(), narrator=(narrator or "").strip(), + language=language, ) def _book_prepared_chapters( - book_text: str, prepare: bool, book_credits=None + book_text: str, prepare: bool, book_credits=None, language: str = "fr" ) -> List[str]: """Chapters as they will be narrated, credits included. @@ -1047,14 +1052,20 @@ def _book_prepared_chapters( if not prepare: return chapters lexicon = text_fr.load_lexicon(_LEXICON_PATH) - return [text_fr.normalize_french(chapter, lexicon=lexicon) for chapter in chapters] + normalize = ( + text_en.normalize_english if language == "en" else text_fr.normalize_french + ) + return [normalize(chapter, lexicon=lexicon) for chapter in chapters] - def _book_chapter_title(chapter: str, index: int, count: int, has_credits: bool) -> str: + def _book_chapter_title( + chapter: str, index: int, count: int, has_credits: bool, language: str = "fr" + ) -> str: """Marker title — the credits are named rather than quoted.""" + opening_title, closing_title = credits.titles_for(language) if has_credits and index == 1: - return credits.OPENING_TITLE + return opening_title if has_credits and index == count: - return credits.CLOSING_TITLE + return closing_title return _chapter_title(chapter, index) def _book_profile(pause_sentence: float, pause_paragraph: float) -> chunking.PauseProfile: @@ -1075,10 +1086,11 @@ def _book_plan( author="", narrator="", with_credits=True, + language="fr", ): """Show what would be generated, without loading the model.""" - book_credits = _book_credits(title, author, narrator, with_credits) - chapters = _book_prepared_chapters(book_text, prepare, book_credits) + book_credits = _book_credits(title, author, narrator, with_credits, language) + chapters = _book_prepared_chapters(book_text, prepare, book_credits, language) if not chapters: return "*Aucun texte à analyser.*" @@ -1126,6 +1138,7 @@ def _book_narrate( narrator="", with_credits=True, polish_on=True, + language="fr", progress=gr.Progress(), ): """Narrate every chapter, writing each one to disk as soon as it is done. @@ -1133,8 +1146,8 @@ def _book_narrate( Yields after each chapter so the UI shows progress on a job that runs for hours, and so a finished chapter is listenable before the book is. """ - book_credits = _book_credits(title, author, narrator, with_credits) - chapters = _book_prepared_chapters(book_text, prepare, book_credits) + book_credits = _book_credits(title, author, narrator, with_credits, language) + chapters = _book_prepared_chapters(book_text, prepare, book_credits, language) if not chapters: raise gr.Error("Aucun texte à narrer. Chargez un fichier .txt ou collez le texte.") @@ -1171,7 +1184,7 @@ def _book_narrate( repair.PlannedChapter( index=index, title=_book_chapter_title( - chapter, index, len(chapters), book_credits is not None + chapter, index, len(chapters), book_credits is not None, language ), segments=tuple( repair.PlannedSegment(segment.text, segment.pause_after) @@ -1656,6 +1669,12 @@ def _run_asr_if_needed(checked, audio_path): book_title = gr.Textbox(value="", label=I18N("book_title_label")) book_author = gr.Textbox(value="", label=I18N("book_author_label")) with gr.Row(): + book_language = gr.Dropdown( + choices=[("Français", "fr"), ("English", "en")], + value="fr", + label=I18N("book_language_label"), + info=I18N("book_language_info"), + ) book_narrator = gr.Textbox( value="", label=I18N("book_narrator_label"), @@ -1877,6 +1896,7 @@ def _run_asr_if_needed(checked, audio_path): book_author, book_narrator, book_with_credits, + book_language, ], outputs=[book_status], show_progress=False, @@ -1910,6 +1930,7 @@ def _run_asr_if_needed(checked, audio_path): book_narrator, book_with_credits, book_polish, + book_language, ], outputs=[book_status, book_audio], show_progress=True, diff --git a/docs/NARRATION.md b/docs/NARRATION.md index 53175e81..61407cc6 100644 --- a/docs/NARRATION.md +++ b/docs/NARRATION.md @@ -25,7 +25,7 @@ chacune dans un module de `narration/` — testable et utilisable indépendammen |---|---|---| | **0. Lecture** | `narration/epub.py` | Lit un `.epub` dans l'ordre du *spine* et en tire des chapitres titrés — un `.txt` se découpe lui sur les lignes `---` | | **0 bis. Générique** | `narration/credits.py` | Ajoute au livre le générique de début et de fin qu'exigent les distributeurs, comme deux chapitres à part entière | -| **1. Préparation** | `narration/text_fr.py` | Réécrit le texte tel qu'un narrateur le dirait : `1789` → « mille sept cent quatre-vingt-neuf », `M. Dupont` → « Monsieur Dupont », `XIVe siècle` → « quatorzième siècle », `14h30`, `1 250 €`, `3,5 %`… | +| **1. Préparation** | `narration/text_fr.py` ou `text_en.py` | Réécrit le texte tel qu'un narrateur le dirait : `1789` → « mille sept cent quatre-vingt-neuf », `M. Dupont` → « Monsieur Dupont », `XIVe siècle` → « quatorzième siècle », `14h30`, `1 250 €`, `3,5 %`… | | **2. Découpage** | `narration/chunking.py` | Coupe en segments sous la limite du moteur, **sans jamais couper une phrase**, et décide la durée du silence après chaque segment selon la ponctuation | | **3. Synthèse** | moteur VoxCPM2 | Même seed partout → voix identique du début à la fin | | **4. Mastering** | `narration/audio.py` | Rogne les silences parasites, supprime les clics aux jointures, insère les pauses, normalise la sonie **une fois par chapitre** | @@ -125,6 +125,42 @@ Deux options utiles dans les **Réglages avancés** : - **Préparation du texte français** — applique l'étape 1 de la chaîne. - **Mastering livre audio** — applique l'étape 4 (activé par défaut). +## Narrer en anglais + +`--language en` (ou le menu **Langue du livre** dans l'onglet) bascule deux choses : +la préparation du texte et la formulation du générique. + +``` +.\.venv\Scripts\python.exe scripts\narrate_book.py book.epub --language en ^ + --voice "..." --title "Around the Moon" --author "Jules Verne" +``` + +L'anglais a ses propres irrégularités, et `narration/text_en.py` les traite : + +- **Une année se dit, elle ne se compte pas.** `1789` devient *seventeen + eighty-nine*, `1905` devient *nineteen oh five*, `2005` devient *two thousand + five*. Ce qui distingue une année d'une quantité est le séparateur de milliers : + `1,789 men` se compte, `in 1789` se dit. `--no-text-prep` ou `read_years=False` + désactive. +- **Les suffixes ordinaux** dépendent des deux derniers chiffres : `21st` → + *twenty-first*, mais `11th` → *eleventh* et non *eleven-first*. +- **Le point d'un titre n'est pas une fin de phrase.** `Mr. Dupont` devient + *Mister Dupont* — laisser le point inventerait un point final au milieu de la + phrase, et le découpage la couperait là. +- Monnaies avec leurs centimes (*and fifty cents*), pourcentages, heures, chiffres + romains après un mot déclencheur (`chapter XIV`). + +Le générique suit : + +> « Around the Moon », by Jules Verne. Narrated by a synthetic voice. +> +> You have been listening to « Around the Moon », by Jules Verne… Recorded in +> twenty twenty-six. This text is in the public domain. + +**Ce qui reste français** : les voix préréglées sont décrites en français et +sonneront avec un accent. Pour de l'anglais natif, décris une voix anglaise dans +l'onglet Studio, ou clone une voix anglophone. + ## Partir d'un EPUB Un `.epub` se charge directement, dans l'onglet **📚 Livre audio** comme en ligne de diff --git a/narration/__init__.py b/narration/__init__.py index 73f7d96a..eac6c5de 100644 --- a/narration/__init__.py +++ b/narration/__init__.py @@ -11,6 +11,7 @@ epub read an .epub into the plain chapters everything else expects credits the opening and closing credits distributors require text_fr prepare raw French prose for a TTS engine + text_en the same for English — years, ordinals, titles chunking cut prepared text into engine-sized segments + pause plan cache content-addressed store so an interrupted run resumes per chunk quality flag the segments the engine got wrong, and re-roll those only @@ -32,5 +33,6 @@ "polish", "quality", "repair", + "text_en", "text_fr", ] diff --git a/narration/credits.py b/narration/credits.py index 2ad1c95e..fbe20875 100644 --- a/narration/credits.py +++ b/narration/credits.py @@ -34,6 +34,7 @@ "CLOSING_TITLE", "OPENING_TITLE", "BookCredits", + "titles_for", "SYNTHETIC_DISCLOSURE", ] @@ -46,6 +47,45 @@ #: distributors require synthetic narration to be identified as such. SYNTHETIC_DISCLOSURE = "une voix de synthèse" +#: Everything the credits say, per language. Kept as data rather than as +#: branches in the methods, so adding a language is adding an entry. +_WORDS = { + "fr": { + "opening_title": OPENING_TITLE, + "closing_title": CLOSING_TITLE, + "synthetic": SYNTHETIC_DISCLOSURE, + "by": "de", + "read_by": "Lu par {narrator}", + "you_heard": "Vous venez d'écouter {work}", + "read_by_inline": ", lu par {narrator}", + "produced_by_year": "Enregistrement produit par {publisher}, {year}", + "produced_by": "Enregistrement produit par {publisher}", + "recorded_in": "Enregistrement réalisé en {year}", + "public_domain": "Texte du domaine public", + "untitled": "Ce livre", + "missing_title": "le titre", + "missing_author": "l'auteur", + "missing_narrator": "le narrateur (ou la mention de voix de synthèse)", + }, + "en": { + "opening_title": "Opening credits", + "closing_title": "Closing credits", + "synthetic": "a synthetic voice", + "by": "by", + "read_by": "Narrated by {narrator}", + "you_heard": "You have been listening to {work}", + "read_by_inline": ", narrated by {narrator}", + "produced_by_year": "Produced by {publisher}, {year}", + "produced_by": "Produced by {publisher}", + "recorded_in": "Recorded in {year}", + "public_domain": "This text is in the public domain", + "untitled": "This book", + "missing_title": "the title", + "missing_author": "the author", + "missing_narrator": "the narrator (or the synthetic voice disclosure)", + }, +} + # A title already carrying its author ("Autour de la Lune, par Jules Verne") # would otherwise be announced as "…, par Jules Verne, de Jules Verne". _AUTHOR_PREPOSITIONS = frozenset({"par", "de", "by"}) @@ -80,6 +120,13 @@ class BookCredits: public_domain: bool = False #: Turning this off is a deliberate act — see the module docstring. disclose_synthetic: bool = True + #: "fr" or "en". Anything else falls back to French, which is what this + #: fork narrates by default. + language: str = "fr" + + @property + def _words(self) -> dict: + return _WORDS.get(self.language, _WORDS["fr"]) @property def narrator_credit(self) -> str: @@ -87,17 +134,21 @@ def narrator_credit(self) -> str: narrator = _clean(self.narrator) if narrator: return narrator - return SYNTHETIC_DISCLOSURE if self.disclose_synthetic else "" + return self._words["synthetic"] if self.disclose_synthetic else "" def _work(self) -> str: - """« Title », de Author — the phrase both credits are built around.""" - title = _clean(self.title) or "Ce livre" + """« Title », by Author — the phrase both credits are built around.""" + words = self._words + title = _clean(self.title) or words["untitled"] author = _clean(self.author) + # French quotes in both languages: they are heard as a pause rather than + # read as characters, and they keep a title made of ordinary words from + # dissolving into the sentence around it. piece = f"« {title} »" if self.subtitle: piece += f", {_clean(self.subtitle)}" if author and not _names_the_author(title, author): - piece += f", de {author}" + piece += f", {words['by']} {author}" return piece def opening(self) -> str: @@ -105,28 +156,31 @@ def opening(self) -> str: lines: List[str] = [_sentence(self._work())] narrator = self.narrator_credit if narrator: - lines.append(_sentence(f"Lu par {narrator}")) + lines.append(_sentence(self._words["read_by"].format(narrator=narrator))) return "\n\n".join(line for line in lines if line) def closing(self) -> str: """The last thing heard: the work named again, then the production.""" + words = self._words narrator = self.narrator_credit - first = f"Vous venez d'écouter {self._work()}" + first = words["you_heard"].format(work=self._work()) if narrator: - first += f", lu par {narrator}" + first += words["read_by_inline"].format(narrator=narrator) lines: List[str] = [_sentence(first)] publisher = _clean(self.publisher) year = _clean(self.year) if publisher and year: - lines.append(_sentence(f"Enregistrement produit par {publisher}, {year}")) + lines.append( + _sentence(words["produced_by_year"].format(publisher=publisher, year=year)) + ) elif publisher: - lines.append(_sentence(f"Enregistrement produit par {publisher}")) + lines.append(_sentence(words["produced_by"].format(publisher=publisher))) elif year: - lines.append(_sentence(f"Enregistrement réalisé en {year}")) + lines.append(_sentence(words["recorded_in"].format(year=year))) if self.public_domain: - lines.append(_sentence("Texte du domaine public")) + lines.append(_sentence(words["public_domain"])) return "\n\n".join(line for line in lines if line) def missing_for_distribution(self) -> List[str]: @@ -135,16 +189,23 @@ def missing_for_distribution(self) -> List[str]: Reported rather than raised: a draft narration is a perfectly reasonable thing to produce, and the gaps only matter on the day it is uploaded. """ + words = self._words missing: List[str] = [] if not _clean(self.title): - missing.append("le titre") + missing.append(words["missing_title"]) if not _clean(self.author): - missing.append("l'auteur") + missing.append(words["missing_author"]) if not self.narrator_credit: - missing.append("le narrateur (ou la mention de voix de synthèse)") + missing.append(words["missing_narrator"]) return missing +def titles_for(language: str) -> tuple: + """The two chapter titles, in the language the credits are spoken in.""" + words = _WORDS.get(language, _WORDS["fr"]) + return words["opening_title"], words["closing_title"] + + def _names_the_author(title: str, author: str) -> bool: """Whether the title already ends by naming the author. diff --git a/narration/text_en.py b/narration/text_en.py new file mode 100644 index 00000000..795aa5f6 --- /dev/null +++ b/narration/text_en.py @@ -0,0 +1,304 @@ +"""Prepare raw English prose for a TTS engine. + +The English twin of :mod:`narration.text_fr`, and it exists for the same reason: +a model reads what it is given, and ``1789``, ``Mr. Dupont``, ``chapter XIV`` or +``$1,250`` are not words. One number read wrong in the middle of a chapter is +enough to break the spell. + +What differs from the French module is not the shape but the language's own +awkwardness: + +* **Years are said, not counted.** 1789 is "seventeen eighty-nine", not "one + thousand seven hundred and eighty-nine", and 2005 is "two thousand five". A + four-digit number in prose is far more often a year than a quantity, so that + is the default — and a number carrying a thousands separator (``1,789``) is + never one, which is what tells them apart. +* **The scale words are large and regular** — thousand, million, billion — and + invariable, so none of the agreement rules that make French numbers hard. +* **Ordinal suffixes are written**: ``1st``, ``2nd``, ``21st``. Their spelling + is decided by the last two digits, which is why eleventh is not "eleven-first". +* **Titles keep their period.** ``Mr.`` and ``St.`` end in one that is not a + sentence end, and consuming it would silently glue two sentences together and + destroy a pause. + +Pure text-in / text-out with no dependencies, so it is cheap to test +exhaustively. +""" +from __future__ import annotations + +import re +from typing import Iterable, Mapping, Optional + +from .text_fr import Pronunciation, _apply_lexicon, _clean_typography, _strip_markdown + +__all__ = [ + "DEFAULT_ROMAN_TRIGGERS_EN", + "cardinal_en", + "normalize_english", + "ordinal_en", + "year_en", +] + +_UNITS = [ + "zero", "one", "two", "three", "four", "five", "six", "seven", "eight", "nine", + "ten", "eleven", "twelve", "thirteen", "fourteen", "fifteen", "sixteen", + "seventeen", "eighteen", "nineteen", +] +_TENS = { + 2: "twenty", 3: "thirty", 4: "forty", 5: "fifty", + 6: "sixty", 7: "seventy", 8: "eighty", 9: "ninety", +} +_SCALES = ((10**9, "billion"), (10**6, "million"), (10**3, "thousand")) + +#: Ordinals whose written form is irregular; everything else takes ``th``. +_ORDINAL_WORDS = { + "one": "first", "two": "second", "three": "third", "five": "fifth", + "eight": "eighth", "nine": "ninth", "twelve": "twelfth", +} + +#: Words after which a Roman numeral is unambiguous. Without a trigger, ``I`` +#: and ``C`` are an initial and a letter far more often than they are numbers. +DEFAULT_ROMAN_TRIGGERS_EN = ( + "chapter", "chapters", "part", "parts", "book", "books", "volume", "volumes", + "act", "acts", "scene", "scenes", "section", "sections", "appendix", "annex", + "episode", "episodes", "title", "article", "articles", "figure", "plate", + "lesson", "canto", "world war", +) + +_ABBREVIATIONS: tuple[tuple[str, str], ...] = ( + # The period is matched by lookahead and left in place: it may also be the + # end of the sentence, and eating it would merge two sentences into one. + # A title is always followed by a name, so its period is never a sentence + # end and is consumed with it. Left behind, it invents a full stop in the + # middle of "Mister. Dupont" and the segmentation splits the sentence there. + (r"\bMrs\.(?=\s+[A-Z])", "Missus"), + (r"\bMr\.(?=\s+[A-Z])", "Mister"), + (r"\bMs\.(?=\s+[A-Z])", "Miz"), + (r"\bDr\.(?=\s+[A-Z])", "Doctor"), + (r"\bProf\.(?=\s+[A-Z])", "Professor"), + (r"\bSt\.(?=\s+[A-Z])", "Saint"), + (r"\bMt\.(?=\s+[A-Z])", "Mount"), + # This one genuinely can end a sentence, so its period stays. + (r"\betc(?=\.)", "et cetera"), + (r"\be\.\s*g\.", "for example"), + (r"\bi\.\s*e\.", "that is"), + (r"\bvs\.?(?!\w)", "versus"), + (r"\bNo\.(?=\s*\d)", "number"), + (r"\bpp?\.(?=\s*\d)", "page"), + (r"\bA\.?M\.(?!\w)", "A M"), + (r"\bP\.?M\.(?!\w)", "P M"), +) + +_ROMAN_VALUES = {"I": 1, "V": 5, "X": 10, "L": 50, "C": 100, "D": 500, "M": 1000} +_ROMAN_STRICT = re.compile(r"^M{0,3}(CM|CD|D?C{0,3})(XC|XL|L?X{0,3})(IX|IV|V?I{0,3})$") + +#: symbol -> (singular, plural, name of the hundredth part) +_CURRENCIES = { + "$": ("dollar", "dollars", "cents"), + "£": ("pound", "pounds", "pence"), + "€": ("euro", "euros", "cents"), +} + + +def _below_100(n: int) -> str: + if n < 20: + return _UNITS[n] + tens, unit = divmod(n, 10) + return _TENS[tens] if unit == 0 else f"{_TENS[tens]}-{_UNITS[unit]}" + + +def _below_1000(n: int) -> str: + hundreds, rest = divmod(n, 100) + if hundreds == 0: + return _below_100(rest) + head = f"{_UNITS[hundreds]} hundred" + # "and" after the hundreds is the British reading and the one a narrator + # uses; American English drops it, but never wrongly. + return head if rest == 0 else f"{head} and {_below_100(rest)}" + + +def cardinal_en(n: int) -> str: + """Spell out an integer in English.""" + if n < 0: + return f"minus {cardinal_en(-n)}" + if n < 1000: + return _below_1000(n) + + parts: list[str] = [] + remainder = n + for value, name in _SCALES: + count, remainder = divmod(remainder, value) + if count: + parts.append(f"{_below_1000(count)} {name}") + if remainder: + parts.append(_below_1000(remainder)) + return " ".join(parts) + + +def ordinal_en(n: int) -> str: + """Spell out an ordinal: 1 -> first, 21 -> twenty-first, 1000 -> thousandth.""" + words = cardinal_en(n) + head, separator, last = words.rpartition("-") + if not separator: + head, separator, last = words.rpartition(" ") + if last in _ORDINAL_WORDS: + last = _ORDINAL_WORDS[last] + elif last.endswith("y"): + last = f"{last[:-1]}ieth" + else: + last = f"{last}th" + return f"{head}{separator}{last}" + + +def year_en(n: int) -> str: + """Read a year the way it is said rather than counted. + + 1789 is "seventeen eighty-nine". The exceptions are the ones a reader makes + without thinking: whole centuries ("nineteen hundred"), the years either + side of a millennium ("two thousand five"), and anything with a zero in the + tens where the pairing would produce "nineteen oh five" — which is right, + and is what this returns. + """ + if not 1000 <= n <= 2999: + return cardinal_en(n) + high, low = divmod(n, 100) + if 2000 <= n < 2010: + return f"two thousand {_UNITS[low]}" if low else "two thousand" + if low == 0: + return f"{_below_100(high)} hundred" + if low < 10: + return f"{_below_100(high)} oh {_UNITS[low]}" + return f"{_below_100(high)} {_below_100(low)}" + + +def roman_to_int(s: str) -> Optional[int]: + """Value of a well-formed Roman numeral, or None. Strict, so initials survive.""" + s = (s or "").strip().upper() + if not s or not _ROMAN_STRICT.match(s): + return None + total, previous = 0, 0 + for char in reversed(s): + value = _ROMAN_VALUES[char] + total += value if value >= previous else -value + previous = max(previous, value) + return total or None + + +def _expand_abbreviations(text: str) -> str: + for pattern, replacement in _ABBREVIATIONS: + text = re.sub(pattern, replacement, text) + return text + + +def _expand_roman(text: str, triggers: Iterable[str]) -> str: + words = "|".join(re.escape(t) for t in triggers) + if not words: + return text + + def replace(match: re.Match) -> str: + value = roman_to_int(match.group("roman")) + return match.group(0) if value is None else f"{match.group('trigger')} {cardinal_en(value)}" + + return re.sub( + rf"(?P\b(?:{words}))\s+(?P[IVXLCDM]+)\b", + replace, + text, + flags=re.IGNORECASE, + ) + + +def _expand_times(text: str) -> str: + def replace(match: re.Match) -> str: + hour, minute = int(match.group(1)), int(match.group(2)) + if minute == 0: + return f"{cardinal_en(hour)} o'clock" + if minute < 10: + return f"{cardinal_en(hour)} oh {_UNITS[minute]}" + return f"{cardinal_en(hour)} {cardinal_en(minute)}" + + return re.sub(r"\b(\d{1,2}):(\d{2})\b", replace, text) + + +def _expand_currency(text: str) -> str: + symbols = "".join(re.escape(s) for s in _CURRENCIES) + + def replace(match: re.Match) -> str: + singular, plural, subunit = _CURRENCIES[match.group("symbol")] + whole = int(match.group("whole").replace(",", "")) + cents = match.group("cents") + words = f"{cardinal_en(whole)} {singular if whole == 1 else plural}" + if cents and int(cents): + # Named, or "one thousand two hundred and fifty dollars fifty" + # leaves the listener wondering what the fifty was. + words += f" and {cardinal_en(int(cents))} {subunit}" + return words + + return re.sub( + rf"(?P[{symbols}])\s?(?P\d[\d,]*)(?:\.(?P\d{{2}}))?", + replace, + text, + ) + + +def _expand_percent(text: str) -> str: + return re.sub( + r"\b(\d[\d,]*)\s?%", + lambda m: f"{cardinal_en(int(m.group(1).replace(',', '')))} percent", + text, + ) + + +def _expand_ordinal_marks(text: str) -> str: + return re.sub( + r"\b(\d+)(?:st|nd|rd|th)\b", + lambda m: ordinal_en(int(m.group(1))), + text, + flags=re.IGNORECASE, + ) + + +def _expand_numbers(text: str, read_years: bool = True) -> str: + def replace(match: re.Match) -> str: + raw = match.group(0) + digits = raw.replace(",", "") + value = int(digits) + # A separator marks a quantity, never a year: "1,789 men" is counted. + if read_years and "," not in raw and len(digits) == 4 and 1000 <= value <= 2999: + return year_en(value) + return cardinal_en(value) + + return re.sub(r"\b\d[\d,]*\b", replace, text) + + +def normalize_english( + text: str, + *, + lexicon: Optional[Mapping[str, object]] = None, + expand_roman: bool = True, + roman_triggers: Iterable[str] = DEFAULT_ROMAN_TRIGGERS_EN, + strip_markdown: bool = True, + read_years: bool = True, +) -> str: + """Rewrite English prose into the words a narrator would speak. + + The passes run in a fixed order because several compete for the same digits: + times, currency, percentages and ordinal marks each claim their pattern + before the generic number rule can reach it. + """ + if not text or not text.strip(): + return "" + + text = _clean_typography(text) + if strip_markdown: + text = _strip_markdown(text) + if lexicon: + text = _apply_lexicon(text, lexicon) + text = _expand_abbreviations(text) + if expand_roman: + text = _expand_roman(text, roman_triggers) + text = _expand_times(text) + text = _expand_currency(text) + text = _expand_percent(text) + text = _expand_ordinal_marks(text) + text = _expand_numbers(text, read_years=read_years) + return re.sub(r"[^\S\n]{2,}", " ", text).strip() diff --git a/scripts/narrate_book.py b/scripts/narrate_book.py index 75ebfa93..6beca607 100644 --- a/scripts/narrate_book.py +++ b/scripts/narrate_book.py @@ -66,7 +66,7 @@ from narration import assemble as assembly # noqa: E402 from narration import audio as audio_tools # noqa: E402 from narration import cache as cache_tools # noqa: E402 -from narration import chunking, credits, epub, quality, repair, text_fr # noqa: E402 +from narration import chunking, credits, epub, quality, repair, text_en, text_fr # noqa: E402 #: Rough characters-per-second of finished narration, used only to estimate how #: long a book will run before committing hours of CPU to it. @@ -107,6 +107,9 @@ def build_parser() -> argparse.ArgumentParser: voice.add_argument("--steps", type=int, default=10, help="Diffusion steps (default: 10)") text = parser.add_argument_group("texte") + text.add_argument("--language", choices=["fr", "en"], default="fr", + help="Language of the book: picks the text preparation and the " + "wording of the credits (default: fr)") text.add_argument("--no-text-prep", action="store_true", help="Skip French normalization (numbers, abbreviations, Roman numerals)") text.add_argument("--lexicon", default="conf/pronunciation_fr.json", @@ -236,15 +239,20 @@ def main() -> int: publisher=args.publisher, year=args.year, public_domain=args.public_domain, + language=args.language, ) if not args.no_credits: + opening_title, closing_title = credits.titles_for(args.language) raw_chapters = [book_credits.opening()] + raw_chapters + [book_credits.closing()] - titles = [credits.OPENING_TITLE] + titles + [credits.CLOSING_TITLE] + titles = [opening_title] + titles + [closing_title] lexicon = {} if not args.no_text_prep: lexicon = text_fr.load_lexicon(args.lexicon) - chapters = [text_fr.normalize_french(chapter, lexicon=lexicon) for chapter in raw_chapters] + prepare = ( + text_en.normalize_english if args.language == "en" else text_fr.normalize_french + ) + chapters = [prepare(chapter, lexicon=lexicon) for chapter in raw_chapters] else: chapters = raw_chapters diff --git a/tests/test_narration_text_en.py b/tests/test_narration_text_en.py new file mode 100644 index 00000000..d10bcf98 --- /dev/null +++ b/tests/test_narration_text_en.py @@ -0,0 +1,148 @@ +"""Tests for the English text preparation. + +The interesting cases are the ones where English is quietly irregular: a year is +said rather than counted, an ordinal suffix depends on the last two digits, and +a title's period is not the end of a sentence. +""" +import pytest + +from narration.text_en import cardinal_en, normalize_english, ordinal_en, year_en + + +class TestCardinals: + @pytest.mark.parametrize( + "value,expected", + [ + (0, "zero"), + (7, "seven"), + (13, "thirteen"), + (21, "twenty-one"), + (40, "forty"), + (100, "one hundred"), + (101, "one hundred and one"), + (999, "nine hundred and ninety-nine"), + (1000, "one thousand"), + (1_000_000, "one million"), + (-5, "minus five"), + ], + ) + def test_it_spells_them_out(self, value, expected): + assert cardinal_en(value) == expected + + def test_a_large_number_reads_in_scale_order(self): + assert cardinal_en(1_234_567).startswith("one million two hundred and thirty-four thousand") + + +class TestOrdinals: + @pytest.mark.parametrize( + "value,expected", + [ + (1, "first"), (2, "second"), (3, "third"), (5, "fifth"), + (8, "eighth"), (9, "ninth"), (11, "eleventh"), (12, "twelfth"), + (20, "twentieth"), (21, "twenty-first"), (22, "twenty-second"), + (100, "one hundredth"), (1000, "one thousandth"), + ], + ) + def test_the_irregular_ones_are_right(self, value, expected): + assert ordinal_en(value) == expected + + def test_written_suffixes_are_expanded(self): + assert "twenty-first of May" in normalize_english("21st of May") + assert "second" in normalize_english("2nd") + + +class TestYears: + @pytest.mark.parametrize( + "value,expected", + [ + (1789, "seventeen eighty-nine"), + (1066, "ten sixty-six"), + (1900, "nineteen hundred"), + (1905, "nineteen oh five"), + (2000, "two thousand"), + (2005, "two thousand five"), + (2026, "twenty twenty-six"), + ], + ) + def test_a_year_is_said_not_counted(self, value, expected): + assert year_en(value) == expected + + def test_prose_reads_four_digits_as_a_year(self): + assert "seventeen eighty-nine" in normalize_english("It began in 1789.") + + def test_a_thousands_separator_means_a_quantity(self): + """`1,789 men` is counted; only a bare 1789 is a year.""" + out = normalize_english("There were 1,789 men.") + assert "one thousand seven hundred and eighty-nine" in out + + def test_it_can_be_switched_off(self): + out = normalize_english("In 1789.", read_years=False) + assert "one thousand seven hundred and eighty-nine" in out + + +class TestAbbreviations: + def test_a_title_does_not_end_the_sentence(self): + """Leaving the period would split "Mister. Dupont" in two.""" + out = normalize_english("Mr. Dupont met Dr. Smith at St. Paul.") + assert "Mister Dupont" in out + assert "Doctor Smith" in out + assert "Saint Paul" in out + assert "Mister." not in out + + def test_etc_keeps_its_period_because_it_may_end_one(self): + assert normalize_english("And so on, etc. The end.").count(".") == 2 + + def test_latin_shorthand_is_spoken(self): + assert "for example" in normalize_english("Fruit, e.g. apples.") + assert "that is" in normalize_english("One, i.e. the first.") + + +class TestQuantities: + def test_money_names_its_parts(self): + out = normalize_english("It cost $1,250.50.") + assert "one thousand two hundred and fifty dollars and fifty cents" in out + + def test_pounds_have_pence(self): + assert "five pence" in normalize_english("£3.05") + + def test_one_of_something_is_singular(self): + assert "one dollar" in normalize_english("$1") + + def test_percentages(self): + assert "three percent" in normalize_english("3%") + + def test_times(self): + assert "fourteen thirty" in normalize_english("at 14:30") + assert "nine o'clock" in normalize_english("at 9:00") + assert "nine oh five" in normalize_english("at 9:05") + + +class TestRoman: + def test_a_trigger_word_is_required(self): + assert "chapter fourteen" in normalize_english("chapter XIV") + + def test_an_initial_is_not_a_number(self): + """Without a trigger, `I` and `C` are letters far more often.""" + out = normalize_english("I met C. D. Lewis.") + assert out.startswith("I met") + + def test_it_can_be_switched_off(self): + assert "XIV" in normalize_english("chapter XIV", expand_roman=False) + + +class TestShape: + def test_empty_input(self): + assert normalize_english("") == "" + assert normalize_english(" ") == "" + + def test_markdown_is_stripped(self): + assert "**" not in normalize_english("A **bold** claim.") + + def test_the_lexicon_applies(self): + assert "N A S A" in normalize_english("The NASA report.", lexicon={"NASA": "N A S A"}) + + def test_a_contextual_lexicon_entry_works_here_too(self): + lexicon = {"read": {"prononcer": "red", "après": "have|has|had"}} + out = normalize_english("I have read it. I read daily.", lexicon=lexicon) + assert "have red it" in out + assert "I read daily" in out From a47fde7f4e7767d3d88961b53f97f9e860011358 Mon Sep 17 00:00:00 2001 From: PaxHelios Date: Tue, 4 Aug 2026 17:21:53 +0200 Subject: [PATCH 38/98] feat(narrate_book): narrate a whole book in a cloned voice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The engine has always been able to clone from a reference recording, and only the Studio tab could reach it — so a clone could speak a sentence and never a book. --reference-audio and --reference-text open it to the long form, and a recording is now a complete answer to "which voice?", replacing --voice and --description rather than joining them. The delicate part is the cache, not the plumbing. Its key is derived from everything that shapes the audio, so the reference had to join it — otherwise a sentence spoken by a cloned voice and the same sentence spoken from a description share an address, and the second run serves the wrong voice. What goes into the key is a hash of the recording's *contents*, not its path: the same take moved must keep its entries, and a new take saved over the old file must not inherit them. The denoiser is explicitly off on this path. It is not loaded during narration at all, so asking for it would be asking for something absent — which also means the reference is copied as recorded, breath and room and all, and the guide says so where someone about to record will read it. Also recorded in QualityThresholds: English was measured rather than assumed. The same four voices reading a sentence of the same length come back at 14.7 to 17.7 characters per second against 17.4 to 20.9 in French, so the existing bounds cover both with more than a factor of two to spare. No language knob was added, because the difference it would configure does not exist — and the numbers are written down so the next person does not have to re-measure to find that out. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Fity58qgKttpD1nLheWrzy --- docs/NARRATION.md | 30 +++++++ narration/cache.py | 29 +++++++ narration/quality.py | 8 ++ scripts/narrate_book.py | 30 ++++++- tests/test_narrate_book_cloning.py | 127 +++++++++++++++++++++++++++++ 5 files changed, 221 insertions(+), 3 deletions(-) create mode 100644 tests/test_narrate_book_cloning.py diff --git a/docs/NARRATION.md b/docs/NARRATION.md index 61407cc6..2a8111ff 100644 --- a/docs/NARRATION.md +++ b/docs/NARRATION.md @@ -125,6 +125,36 @@ Deux options utiles dans les **Réglages avancés** : - **Préparation du texte français** — applique l'étape 1 de la chaîne. - **Mastering livre audio** — applique l'étape 4 (activé par défaut). +## Narrer dans une voix clonée + +Le moteur sait cloner une voix depuis un enregistrement, mais seul l'onglet +Studio y avait accès — donc pour un extrait, jamais pour un livre. C'est branché +dans la narration longue : + +``` +scripts\narrate_book.py livre.epub --reference-audio ma_voix.wav ^ + --reference-text "le texte exact prononcé dans l'enregistrement" +``` + +`--reference-audio` remplace `--voice` et `--description` : un enregistrement est +une réponse complète à la question « quelle voix ? ». + +**Ce qu'il faut enregistrer** : un extrait **court et très propre** vaut mieux +qu'un long avec du souffle. Le débruiteur n'est pas chargé pendant la narration +(il bloque au téléchargement depuis cette machine), donc **ce qui est dans le +fichier est ce qui sera copié** — respiration, écho de la pièce, ventilateur +compris. Et c'est cette voix qui portera le livre pendant des heures : lis un +passage au rythme et sur le ton que tu veux entendre, pas une phrase neutre. + +`--reference-text` est facultatif et vaut le coup : le moteur met les mots en +face de l'audio et clone plus fidèlement. + +**Le cache suit la voix.** L'empreinte qui adresse un segment inclut un **hachage +du contenu** de l'enregistrement, pas son chemin. Deux conséquences voulues : +réenregistrer dans le même fichier ne ressert pas l'ancienne voix, et déplacer le +fichier ne jette pas le cache. Un même passage cloné et décrit ne peuvent pas se +confondre en cache. + ## Narrer en anglais `--language en` (ou le menu **Langue du livre** dans l'onglet) bascule deux choses : diff --git a/narration/cache.py b/narration/cache.py index d1a9740f..13b4494f 100644 --- a/narration/cache.py +++ b/narration/cache.py @@ -48,6 +48,35 @@ class VoiceSpec: steps: int = 10 normalize: bool = True model_id: str = "" + #: Identifies the reference recording a cloned voice was built from — a + #: hash of its *contents*, not its path, because the same path can hold a + #: different take tomorrow and the same take can be moved. Empty for a voice + #: described in words. It belongs here for the same reason the seed does: + #: without it, a chapter narrated in a cloned voice would collide in the + #: cache with the same sentence narrated from a description. + reference: str = "" + #: The transcript given alongside that recording, which also changes the + #: result. + reference_text: str = "" + + @staticmethod + def hash_reference(path) -> str: + """Content hash of a reference recording, or "" when there is none. + + Hashing the bytes rather than the name is what makes the cache honest: + re-recording into the same filename must not silently reuse the old + voice, and moving the file must not throw the cache away. + """ + if not path: + return "" + file_path = Path(path) + if not file_path.is_file(): + return "" + digest = hashlib.sha256() + with file_path.open("rb") as handle: + for block in iter(lambda: handle.read(1 << 20), b""): + digest.update(block) + return digest.hexdigest()[:16] def fingerprint(self) -> str: payload = {"version": CACHE_VERSION, **asdict(self)} diff --git a/narration/quality.py b/narration/quality.py index baa1a2c6..af843016 100644 --- a/narration/quality.py +++ b/narration/quality.py @@ -79,6 +79,14 @@ class QualityThresholds: The range held exactly when the voice set grew from seven to fourteen, which is the reason to trust it: doubling the sample moved neither end. + + **English was measured too, and needs no bounds of its own.** The same four + voices reading a sentence of the same length come back at 14.7 to 17.7 + characters per second against 17.4 to 20.9 in French — around a tenth + slower, and the nearest limit is still more than twice away. Adding a + language knob here would be configuration for a difference that does not + exist, so there is none; if a language ever does fall outside, these numbers + are what to compare its measurement against. """ #: Median measured across the preset voices. Explains a report, and breaks diff --git a/scripts/narrate_book.py b/scripts/narrate_book.py index 6beca607..c2cc3244 100644 --- a/scripts/narrate_book.py +++ b/scripts/narrate_book.py @@ -105,6 +105,14 @@ def build_parser() -> argparse.ArgumentParser: voice.add_argument("--seed", type=int, help="Seed for the custom voice (fixes the voice identity)") voice.add_argument("--cfg", type=float, default=2.0, help="CFG guidance scale (default: 2.0)") voice.add_argument("--steps", type=int, default=10, help="Diffusion steps (default: 10)") + voice.add_argument("--reference-audio", metavar="WAV", + help="Clone a voice from this recording instead of describing one. " + "A short, clean take beats a long noisy one — the denoiser is " + "off during narration, so what is in the file is what is copied") + voice.add_argument("--reference-text", + help="Exact transcript of --reference-audio. Optional, and worth " + "giving: the engine matches the words to the audio and clones " + "more faithfully with it") text = parser.add_argument_group("texte") text.add_argument("--language", choices=["fr", "en"], default="fr", @@ -197,8 +205,13 @@ def build_parser() -> argparse.ArgumentParser: def main() -> int: args = build_parser().parse_args() - if not args.voice and not args.description: - raise SystemExit("Provide either --voice or --description [--seed N].") + if not args.voice and not args.description and not args.reference_audio: + raise SystemExit( + "Provide either --voice , --description [--seed N], " + "or --reference-audio to clone a voice." + ) + if args.reference_audio and not Path(args.reference_audio).is_file(): + raise SystemExit(f"Reference audio not found: {args.reference_audio}") in_path = Path(args.input) if not in_path.is_file(): @@ -271,7 +284,11 @@ def main() -> int: total_chars = sum(chunking.total_characters(segments) for _, segments in plan) print(f"Entrée : {in_path}") - print(f"Voix : {args.voice or '(personnalisée)'} | seed={seed}") + if args.reference_audio: + print(f"Voix : clonée de {Path(args.reference_audio).name}" + + (" (avec transcription)" if args.reference_text else " (sans transcription)")) + else: + print(f"Voix : {args.voice or '(personnalisée)'} | seed={seed}") print(f"Préparation : {'désactivée' if args.no_text_prep else f'française ({len(lexicon)} entrée(s) de lexique)'}") print(f"Chapitres : {len(chapters)} | segments : {total_segments} | caractères : {total_chars}") print(f"Durée estimée : ~{total_chars / _CHARS_PER_SECOND / 60:.0f} min de narration") @@ -303,6 +320,10 @@ def main() -> int: steps=args.steps, normalize=not args.no_normalize, model_id=args.model_id, + # Hashed by content: the cache must not serve a segment spoken by a + # different recording that happened to live at the same path. + reference=cache_tools.VoiceSpec.hash_reference(args.reference_audio), + reference_text=(args.reference_text or "").strip(), ) cache = cache_tools.ChunkCache(outdir / ".cache", enabled=not args.no_cache) mastering = audio_tools.MasteringSettings( @@ -377,9 +398,12 @@ def render(current_seed, _segment=segment): sr, wav_out, _ = demo.generate_tts_audio( text_input=_segment.text, control_instruction=description, + reference_wav_path_input=args.reference_audio, + prompt_text=(args.reference_text or ""), cfg_value_input=args.cfg, do_normalize=not args.no_normalize, inference_timesteps=args.steps, + denoise=False, seed=current_seed, ) return sr, wav_out diff --git a/tests/test_narrate_book_cloning.py b/tests/test_narrate_book_cloning.py new file mode 100644 index 00000000..9616d90c --- /dev/null +++ b/tests/test_narrate_book_cloning.py @@ -0,0 +1,127 @@ +"""Tests for narrating a whole book in a cloned voice. + +The engine already cloned from a reference recording in the Studio tab; what it +could not do was narrate a book that way, because the script had no way to pass +one. The delicate part is not the plumbing but the cache: a segment spoken by a +cloned voice and the same segment spoken from a description must never share an +address. +""" +from __future__ import annotations + +import sys +from pathlib import Path + +import numpy as np +import pytest +import soundfile as sf + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +from narration.cache import VoiceSpec # noqa: E402 + +from test_narrate_book_qc import CALLS, SR, _noise, narrate_book # noqa: E402,F401 +from test_narrate_book_qc import book, reset_stub # noqa: E402,F401 (fixtures) + + +@pytest.fixture +def reference(tmp_path): + """A short recording standing in for the user's own voice.""" + path = tmp_path / "ma_voix.wav" + sf.write(str(path), _noise(3.0), SR, subtype="PCM_16") + return path + + +def run(monkeypatch, book, outdir, *extra) -> int: + monkeypatch.setattr( + sys, "argv", + ["narrate_book.py", str(book), "--outdir", str(outdir), "--no-credits", *extra], + ) + return narrate_book.main() + + +class TestHashingTheReference: + """The cache key has to follow the audio, not the file name.""" + + def test_the_same_take_moved_keeps_its_hash(self, tmp_path, reference): + moved = tmp_path / "ailleurs.wav" + moved.write_bytes(reference.read_bytes()) + assert VoiceSpec.hash_reference(reference) == VoiceSpec.hash_reference(moved) + + def test_a_new_take_at_the_same_path_changes_it(self, tmp_path, reference): + before = VoiceSpec.hash_reference(reference) + sf.write(str(reference), _noise(2.0, level=0.3), SR, subtype="PCM_16") + assert VoiceSpec.hash_reference(reference) != before + + def test_no_reference_hashes_to_nothing(self, tmp_path): + assert VoiceSpec.hash_reference(None) == "" + assert VoiceSpec.hash_reference("") == "" + assert VoiceSpec.hash_reference(tmp_path / "absent.wav") == "" + + def test_a_cloned_voice_never_collides_with_a_described_one(self, reference): + described = VoiceSpec(description="voix grave", seed=42) + cloned = VoiceSpec( + description="voix grave", seed=42, reference=VoiceSpec.hash_reference(reference) + ) + assert described.fingerprint() != cloned.fingerprint() + + def test_the_transcript_counts_too(self, reference): + digest = VoiceSpec.hash_reference(reference) + without = VoiceSpec(reference=digest) + with_text = VoiceSpec(reference=digest, reference_text="Le vent se lève.") + assert without.fingerprint() != with_text.fingerprint() + + +class TestNarratingWithIt: + def test_the_reference_reaches_the_engine(self, monkeypatch, book, tmp_path, reference): + seen = {} + + class CloningDemo: + def __init__(self, **_kwargs): + pass + + def generate_tts_audio(self, *, text_input, seed=None, **kwargs): + seen["reference"] = kwargs.get("reference_wav_path_input") + seen["prompt"] = kwargs.get("prompt_text") + seen["denoise"] = kwargs.get("denoise") + CALLS.append((text_input, seed)) + return SR, _noise(max(0.5, len(text_input) / 17.0)), None + + monkeypatch.setattr(narrate_book.app, "VoxCPMDemo", CloningDemo) + assert run( + monkeypatch, book, tmp_path / "out", + "--reference-audio", str(reference), + "--reference-text", "Le vent se lève.", + ) == 0 + + assert seen["reference"] == str(reference) + assert seen["prompt"] == "Le vent se lève." + # The denoiser is not loaded during narration, so asking for it would + # be asking for something that is not there. + assert seen["denoise"] is False + + def test_it_needs_no_voice_and_no_description(self, monkeypatch, book, tmp_path, reference): + """A recording is a complete answer to "which voice?".""" + assert run(monkeypatch, book, tmp_path / "out", "--reference-audio", str(reference)) == 0 + + def test_a_missing_recording_is_refused_before_anything_runs( + self, monkeypatch, book, tmp_path + ): + with pytest.raises(SystemExit) as raised: + run(monkeypatch, book, tmp_path / "out", "--reference-audio", str(tmp_path / "nope.wav")) + assert "not found" in str(raised.value) + + def test_naming_no_voice_at_all_is_still_refused(self, monkeypatch, book, tmp_path): + with pytest.raises(SystemExit) as raised: + monkeypatch.setattr( + sys, "argv", + ["narrate_book.py", str(book), "--outdir", str(tmp_path / "out")], + ) + narrate_book.main() + assert "reference-audio" in str(raised.value) + + def test_the_plan_says_the_voice_was_cloned(self, monkeypatch, book, tmp_path, reference, capsys): + run(monkeypatch, book, tmp_path / "out", "--reference-audio", str(reference)) + out = capsys.readouterr().out + assert "clonée" in out + assert reference.name in out From 0f35a84a45351b8ce8db520dc6778cd720f45c5c Mon Sep 17 00:00:00 2001 From: PaxHelios Date: Tue, 4 Aug 2026 18:54:06 +0200 Subject: [PATCH 39/98] feat(voices): a cloned voice can be a preset like any other MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cloning worked from a path on the command line, which meant remembering a path and a transcript every time. A voice belongs in the list next to the others, and the dropdown should not know which kind it is showing. An entry may now carry a reference recording and its transcript instead of a description — the recording is the description — and it works everywhere a named voice works: the tab's menu, and --voice "Edwin Dérivé (V1)" on the command line, which looks the reference up on its own. The catalogue moved out of app.py into narration/voices.py, because it is data and not interface. It loads without torch or gradio, which is why its tests now run in a third of a second instead of twenty-eight — and why they stopped depending on collection order: another test module replaces sys.modules["app"] with a stub, so anything importing app to reach the voice list was passing alone and failing in the suite. Two things that had to be right rather than convenient: The cache key includes a hash of the recording's contents, so a chapter cloned from one take never collides with the same words from another, or from a description. Recordings are gitignored. This repository is public and a voice sample is the one asset that lets anyone impersonate its owner: the entry pointing at a recording is committed, the audio is not. A preset whose recording is absent says so at load and degrades to its description, rather than failing minutes into a chapter. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Fity58qgKttpD1nLheWrzy --- .gitignore | 4 + app.py | 70 ++++++++++------- conf/preset_voices.json | 11 +++ docs/NARRATION.md | 24 ++++++ narration/__init__.py | 2 + narration/voices.py | 113 +++++++++++++++++++++++++++ scripts/narrate_book.py | 14 +++- tests/test_cloned_presets.py | 143 +++++++++++++++++++++++++++++++++++ 8 files changed, 352 insertions(+), 29 deletions(-) create mode 100644 narration/voices.py create mode 100644 tests/test_cloned_presets.py diff --git a/.gitignore b/.gitignore index b3c3954a..8d6aeb69 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,10 @@ app_local.py # Generated voice-preview cache (regenerate with scripts/pregenerate_previews.py) assets/voice_previews/ +# Recordings a cloned voice is built from. Never committed: this repository is +# public, and a voice sample is the one asset that lets anyone impersonate its +# owner. The preset entry that points at one is committed; the audio is not. +assets/voices/ # Archived generations output/ diff --git a/app.py b/app.py index c28830f3..b100794f 100644 --- a/app.py +++ b/app.py @@ -29,6 +29,7 @@ from narration import cache as cache_tools from narration import epub as epub_reader from narration import chunking, credits, delivery, quality, repair, text_en, text_fr +from narration import voices as voices_catalogue logging.basicConfig( level=logging.INFO, @@ -456,35 +457,16 @@ # Optional external override: conf/preset_voices.json (a JSON list of objects with # the same keys). Lets non-developers curate the voice list without editing code. -_PRESET_VOICES_JSON = Path(__file__).parent / "conf" / "preset_voices.json" +_REPO_ROOT = Path(__file__).parent +_PRESET_VOICES_JSON = _REPO_ROOT / "conf" / "preset_voices.json" def _load_preset_voices() -> List[dict]: - """Return voices from conf/preset_voices.json if valid, else the built-in list.""" - if not _PRESET_VOICES_JSON.is_file(): - return _BUILTIN_PRESET_VOICES - try: - with open(_PRESET_VOICES_JSON, "r", encoding="utf-8") as f: - data = json.load(f) - voices = [ - { - "name": str(item["name"]), - "description": str(item["description"]), - "seed": int(item["seed"]), - "cfg": float(item.get("cfg", 2.0)), - "diffusion_steps": int(item.get("diffusion_steps", 10)), - "normalize": bool(item.get("normalize", True)), - "lang": str(item.get("lang", "fr")), - } - for item in data - ] - if not voices: - raise ValueError("no voices found in JSON") - logger.info(f"Loaded {len(voices)} preset voices from {_PRESET_VOICES_JSON}") - return voices - except Exception as e: - logger.warning(f"Could not load {_PRESET_VOICES_JSON} ({e}); using built-in presets.") - return _BUILTIN_PRESET_VOICES + """The voice catalogue. Reading it lives in narration.voices, which loads + without torch or gradio — a voice list is data, not interface.""" + return voices_catalogue.load_presets( + _PRESET_VOICES_JSON, _REPO_ROOT, _BUILTIN_PRESET_VOICES + ) PRESET_VOICES = _load_preset_voices() @@ -804,6 +786,20 @@ def _resolve_voice(preset_name, description, seed_value, cfg, steps, normalize): preset.get("normalize", normalize), ) + def _preset_reference(preset_name) -> Tuple[Optional[str], str]: + """The recording a preset clones, and its transcript. ("", "") if none. + + Kept apart from _resolve_voice rather than widening its tuple: that + function is called from half a dozen places, and a cloned voice only + concerns the two that actually synthesize. + """ + preset = ( + _PRESET_BY_NAME.get(preset_name) + if preset_name and preset_name != PRESET_CUSTOM_LABEL + else None + ) + return voices_catalogue.reference_of(preset) + def _prepare_seed(use_random_seed: bool, seed_value): if use_random_seed: return random.randint(0, 2**32 - 1) @@ -917,6 +913,14 @@ def _generate( actual_prompt_text = prompt_text_value.strip() if use_prompt_text else "" actual_control = "" if use_prompt_text else control_instruction seed = _coerce_seed(seed_value) + + # A cloned preset brings its own recording. A file dropped in the form + # wins: the user picked it deliberately and just now. + preset_reference, preset_reference_text = _preset_reference(preset_name) + if preset_reference and not ref_wav: + ref_wav = preset_reference + actual_control = "" + actual_prompt_text = actual_prompt_text or preset_reference_text voice_name = preset_name if preset_name and preset_name != PRESET_CUSTOM_LABEL else "custom" if prepare_text: @@ -1154,7 +1158,10 @@ def _book_narrate( description, seed, cfg_value, dit_steps, do_normalize = _resolve_voice( preset_name, control_instruction, seed_value, cfg_value, dit_steps, do_normalize ) - if not description.strip(): + # A cloned voice answers "which voice?" with a recording; it needs no + # description, so the demand for one only applies to a designed voice. + reference, reference_text = _preset_reference(preset_name) + if not reference and not description.strip(): raise gr.Error( "Choisissez une voix dans la liste ci-dessus, ou décrivez-en une " "dans l'onglet Studio." @@ -1173,6 +1180,10 @@ def _book_narrate( steps=int(dit_steps), normalize=bool(do_normalize), model_id=demo._model_id, + # Hashed by content, so a chapter cloned from one recording never + # collides in the cache with the same words from another. + reference=cache_tools.VoiceSpec.hash_reference(reference), + reference_text=reference_text, ) cache = cache_tools.ChunkCache(outdir / ".cache") @@ -1242,10 +1253,13 @@ def _book_narrate( def render(current_seed, _segment=segment): sample_rate, wav_out, _ = demo.generate_tts_audio( text_input=_segment.text, - control_instruction=description, + control_instruction="" if reference else description, + reference_wav_path_input=reference, + prompt_text=reference_text, cfg_value_input=cfg_value, do_normalize=do_normalize, inference_timesteps=int(dit_steps), + denoise=False, seed=current_seed, ) return sample_rate, wav_out diff --git a/conf/preset_voices.json b/conf/preset_voices.json index 93f2daed..cd40fcc3 100644 --- a/conf/preset_voices.json +++ b/conf/preset_voices.json @@ -124,5 +124,16 @@ "diffusion_steps": 10, "normalize": true, "lang": "fr" + }, + { + "name": "Edwin Dérivé (V1)", + "description": "", + "seed": 1234, + "cfg": 2.0, + "diffusion_steps": 10, + "normalize": true, + "lang": "fr", + "reference": "assets/voices/edwin_derive_v1.wav", + "reference_text": "Bienvenue dans cet espace de méditation conçu spécialement pour vous. Pendant les vingt prochaines minutes, je vous invite à mettre en pause vos responsabilités, vos e-mails et toutes formes de distractions, pour vous offrir un moment précieux de calme et de recentrage." } ] diff --git a/docs/NARRATION.md b/docs/NARRATION.md index 2a8111ff..d8cdcc49 100644 --- a/docs/NARRATION.md +++ b/docs/NARRATION.md @@ -139,6 +139,30 @@ scripts\narrate_book.py livre.epub --reference-audio ma_voix.wav ^ `--reference-audio` remplace `--voice` et `--description` : un enregistrement est une réponse complète à la question « quelle voix ? ». +**Une voix clonée peut devenir une voix préréglée**, listée dans le menu comme les +autres. Ajoute une entrée à `conf/preset_voices.json` avec un chemin **relatif** au +dépôt : + +```json +{ + "name": "Edwin Dérivé (V1)", + "reference": "assets/voices/edwin_derive_v1.wav", + "reference_text": "le texte exact prononcé dans l'enregistrement", + "seed": 1234, "cfg": 2.0, "diffusion_steps": 10, "lang": "fr" +} +``` + +Pas besoin de `description` : l'enregistrement *est* la description. Elle +s'utilise ensuite partout — menu de l'onglet, et `--voice "Edwin Dérivé (V1)"` en +ligne de commande, qui va chercher la référence et sa transcription tout seul. + +**Les enregistrements ne sont jamais versionnés** (`assets/voices/` est dans le +`.gitignore`). Ce dépôt est public, et un échantillon de voix est précisément ce +qui permet à n'importe qui d'usurper celle de son propriétaire. L'entrée qui +pointe vers le fichier est versionnée ; le fichier, non. Un préréglage dont +l'enregistrement est absent le signale au démarrage et retombe sur sa +description, plutôt que d'échouer en pleine génération. + **Ce qu'il faut enregistrer** : un extrait **court et très propre** vaut mieux qu'un long avec du souffle. Le débruiteur n'est pas chargé pendant la narration (il bloque au téléchargement depuis cette machine), donc **ce qui est dans le diff --git a/narration/__init__.py b/narration/__init__.py index eac6c5de..bb60a7f5 100644 --- a/narration/__init__.py +++ b/narration/__init__.py @@ -10,6 +10,7 @@ epub read an .epub into the plain chapters everything else expects credits the opening and closing credits distributors require + voices the catalogue of narration voices, designed or cloned text_fr prepare raw French prose for a TTS engine text_en the same for English — years, ordinals, titles chunking cut prepared text into engine-sized segments + pause plan @@ -35,4 +36,5 @@ "repair", "text_en", "text_fr", + "voices", ] diff --git a/narration/voices.py b/narration/voices.py new file mode 100644 index 00000000..e42d1faa --- /dev/null +++ b/narration/voices.py @@ -0,0 +1,113 @@ +"""The catalogue of narration voices, read from configuration. + +A voice is answered for in one of two ways, and the rest of the pipeline should +not have to care which: + +* **Designed** — a description in words and a seed. The pair is reproducible: + the same two values always yield the same voice. +* **Cloned** — a recording, and ideally the exact words spoken in it. The + recording *is* the description, so a cloned entry needs none. + +This lives in :mod:`narration` rather than in the interface because it is data, +not user interface: it loads without ``torch`` or ``gradio``, which is what +makes it testable in milliseconds and usable from a script that never opens a +window. + +**Recordings are never committed.** A voice sample is the one asset that lets +anyone impersonate its owner, and this fork's repository is public. The entry +pointing at a recording is configuration and belongs in git; the audio it +points at does not, and ``assets/voices/`` is ignored. +""" +from __future__ import annotations + +import json +import logging +from pathlib import Path +from typing import Dict, List, Optional, Sequence + +__all__ = ["CUSTOM_LABEL", "load_presets", "reference_of"] + +logger = logging.getLogger(__name__) + +#: What the dropdown shows for "no preset — I will describe it myself". +CUSTOM_LABEL = "Personnalisé / manuel" + + +def _entry(item: dict, root: Path) -> Optional[dict]: + """One catalogue entry, or None when it is not usable. + + A malformed entry is dropped rather than raised on: a typo in an optional + configuration file must not cost the whole voice list. + """ + try: + name = str(item["name"]).strip() + except (KeyError, TypeError): + return None + if not name: + return None + + voice = { + "name": name, + # A cloned voice needs no description: the recording is the description. + "description": str(item.get("description", "")), + "seed": int(item.get("seed", 0)), + "cfg": float(item.get("cfg", 2.0)), + "diffusion_steps": int(item.get("diffusion_steps", 10)), + "normalize": bool(item.get("normalize", True)), + "lang": str(item.get("lang", "fr")), + "reference": "", + "reference_text": str(item.get("reference_text", "")), + } + + reference = str(item.get("reference", "")).strip() + if reference: + path = Path(reference) + resolved = path if path.is_absolute() else root / path + if resolved.is_file(): + voice["reference"] = str(resolved) + else: + # Said at load, not at generation: a preset pointing at a missing + # recording would otherwise fail minutes into a chapter, and the + # reason would be nowhere near the symptom. + logger.warning( + "Voice %r references a recording that is not there (%s); it will be " + "used as a described voice instead.", name, resolved, + ) + return voice + + +def load_presets( + path: str | Path, + root: str | Path, + fallback: Sequence[dict] = (), +) -> List[dict]: + """Read the voice catalogue, falling back to ``fallback`` on any problem. + + ``root`` is what a relative ``reference`` is resolved against — the + repository, so a catalogue committed on one machine works on another. + """ + config = Path(path) + root = Path(root) + if not config.is_file(): + return [dict(voice) for voice in fallback] + + try: + data = json.loads(config.read_text(encoding="utf-8")) + if not isinstance(data, list): + raise ValueError("the voice catalogue must be a list") + voices = [voice for voice in (_entry(item, root) for item in data) if voice] + if not voices: + raise ValueError("no usable voice in the catalogue") + except (OSError, ValueError, TypeError, json.JSONDecodeError) as error: + logger.warning("Could not load %s (%s); using the built-in voices.", config, error) + return [dict(voice) for voice in fallback] + + logger.info("Loaded %d preset voices from %s", len(voices), config) + return voices + + +def reference_of(voice: Optional[Dict]) -> tuple: + """``(recording, transcript)`` for a cloned voice, ``(None, "")`` otherwise.""" + if not voice or not voice.get("reference"): + return None, "" + return voice["reference"], voice.get("reference_text", "") diff --git a/scripts/narrate_book.py b/scripts/narrate_book.py index c2cc3244..0a7090e2 100644 --- a/scripts/narrate_book.py +++ b/scripts/narrate_book.py @@ -74,12 +74,20 @@ def resolve_voice(args) -> tuple[str, int | None]: - """Return (description, seed) from a preset name or explicit --description/--seed.""" + """Return (description, seed) from a preset name or explicit --description/--seed. + + A cloned preset also fills in --reference-audio and --reference-text, unless + the command line gave its own: what is typed now beats what was configured + once. + """ if args.voice: preset = app._PRESET_BY_NAME.get(args.voice) if preset is None: names = ", ".join(repr(v["name"]) for v in app.PRESET_VOICES) raise SystemExit(f"Unknown voice {args.voice!r}. Available presets: {names}") + if preset.get("reference") and not args.reference_audio: + args.reference_audio = preset["reference"] + args.reference_text = args.reference_text or preset.get("reference_text", "") return preset["description"], preset["seed"] return (args.description or ""), args.seed @@ -213,6 +221,7 @@ def main() -> int: if args.reference_audio and not Path(args.reference_audio).is_file(): raise SystemExit(f"Reference audio not found: {args.reference_audio}") + in_path = Path(args.input) if not in_path.is_file(): raise SystemExit(f"Input file not found: {in_path}") @@ -235,6 +244,9 @@ def main() -> int: raise SystemExit(f"Input file is empty: {in_path}") description, seed = resolve_voice(args) + # A preset may have just supplied one, so the file is checked again here. + if args.reference_audio and not Path(args.reference_audio).is_file(): + raise SystemExit(f"Reference audio not found: {args.reference_audio}") outdir = Path(args.outdir) if args.outdir else app._OUTPUT_DIR / f"book_{app._sanitize_filename(in_path.stem)}" # ---- prepare ------------------------------------------------------- diff --git a/tests/test_cloned_presets.py b/tests/test_cloned_presets.py new file mode 100644 index 00000000..4cbacc95 --- /dev/null +++ b/tests/test_cloned_presets.py @@ -0,0 +1,143 @@ +"""Tests for cloned voices offered as presets. + +A preset used to be a description and a seed. A cloned voice is a recording +instead, and the two must coexist in the same list: the dropdown does not know +which kind it is showing, and neither should the rest of the pipeline. +""" +from __future__ import annotations + +import json +import sys +from pathlib import Path + +import numpy as np +import pytest +import soundfile as sf + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +from narration import voices as catalogue # noqa: E402 +from narration.cache import VoiceSpec # noqa: E402 + + +@pytest.fixture +def voices_file(tmp_path): + """Build a catalogue this test owns, next to a recording that exists.""" + recording = tmp_path / "voix.wav" + sf.write(str(recording), np.zeros(24000, dtype=np.float32), 24000) + + def write(entries): + path = tmp_path / "preset_voices.json" + path.write_text(json.dumps(entries, ensure_ascii=False), encoding="utf-8") + return catalogue.load_presets(path, tmp_path) + + write.recording = recording + return write + + +class TestLoading: + def test_a_cloned_voice_needs_no_description(self, voices_file): + voices = voices_file([ + {"name": "Edwin", "reference": "voix.wav", "reference_text": "Bonjour."} + ]) + assert voices[0]["description"] == "" + assert voices[0]["reference"].endswith("voix.wav") + assert voices[0]["reference_text"] == "Bonjour." + + def test_a_relative_path_resolves_against_the_repository(self, voices_file): + voices = voices_file([{"name": "Edwin", "reference": "voix.wav"}]) + assert Path(voices[0]["reference"]).is_absolute() + assert Path(voices[0]["reference"]).is_file() + + def test_a_described_voice_is_untouched(self, voices_file): + voices = voices_file([ + {"name": "Narrateur", "description": "voix grave", "seed": 7} + ]) + assert voices[0]["description"] == "voix grave" + assert voices[0]["reference"] == "" + + def test_the_two_kinds_live_side_by_side(self, voices_file): + voices = voices_file([ + {"name": "Narrateur", "description": "voix grave", "seed": 7}, + {"name": "Edwin", "reference": "voix.wav"}, + ]) + assert [v["name"] for v in voices] == ["Narrateur", "Edwin"] + + def test_a_missing_recording_degrades_instead_of_breaking(self, voices_file, caplog): + """Silence here would fail minutes into a generation instead.""" + voices = voices_file([ + {"name": "Edwin", "description": "de secours", "reference": "absent.wav"} + ]) + assert voices[0]["reference"] == "" + assert voices[0]["description"] == "de secours" + + +class TestFallingBack: + """An optional configuration file must never cost the whole voice list.""" + + FALLBACK = [{"name": "de secours", "description": "voix grave", "seed": 1}] + + def test_a_missing_catalogue_falls_back(self, tmp_path): + voices = catalogue.load_presets(tmp_path / "absent.json", tmp_path, self.FALLBACK) + assert [v["name"] for v in voices] == ["de secours"] + + def test_malformed_json_falls_back(self, tmp_path): + path = tmp_path / "voices.json" + path.write_text("{ pas du json", encoding="utf-8") + assert catalogue.load_presets(path, tmp_path, self.FALLBACK)[0]["name"] == "de secours" + + def test_something_that_is_not_a_list_falls_back(self, tmp_path): + path = tmp_path / "voices.json" + path.write_text('{"name": "seul"}', encoding="utf-8") + assert catalogue.load_presets(path, tmp_path, self.FALLBACK)[0]["name"] == "de secours" + + def test_an_entry_without_a_name_is_dropped_not_fatal(self, tmp_path): + path = tmp_path / "voices.json" + path.write_text( + json.dumps([{"description": "sans nom"}, {"name": "Bonne", "seed": 2}]), + encoding="utf-8", + ) + voices = catalogue.load_presets(path, tmp_path, self.FALLBACK) + assert [v["name"] for v in voices] == ["Bonne"] + + def test_the_fallback_is_copied_not_shared(self, tmp_path): + """A caller mutating what it got back must not corrupt the built-ins.""" + voices = catalogue.load_presets(tmp_path / "absent.json", tmp_path, self.FALLBACK) + voices[0]["name"] = "modifié" + assert self.FALLBACK[0]["name"] == "de secours" + + +class TestTheCacheFollows: + def test_two_recordings_do_not_share_an_address(self, tmp_path): + first, second = tmp_path / "a.wav", tmp_path / "b.wav" + sf.write(str(first), np.zeros(2400, dtype=np.float32), 24000) + sf.write(str(second), np.ones(2400, dtype=np.float32) * 0.1, 24000) + + a = VoiceSpec(seed=1, reference=VoiceSpec.hash_reference(first)) + b = VoiceSpec(seed=1, reference=VoiceSpec.hash_reference(second)) + assert a.fingerprint() != b.fingerprint() + + def test_a_cloned_voice_never_collides_with_a_described_one(self, tmp_path): + recording = tmp_path / "a.wav" + sf.write(str(recording), np.zeros(2400, dtype=np.float32), 24000) + described = VoiceSpec(description="voix grave", seed=1) + cloned = VoiceSpec( + description="voix grave", seed=1, reference=VoiceSpec.hash_reference(recording) + ) + assert described.fingerprint() != cloned.fingerprint() + + +class TestTheRepositoryStaysClean: + def test_recordings_are_never_committed(self): + """A public repository plus a voice sample is impersonation waiting.""" + ignored = (ROOT / ".gitignore").read_text(encoding="utf-8") + assert "assets/voices/" in ignored + + def test_the_shipped_preset_points_at_a_relative_path(self): + """An absolute path would only work on the machine that wrote it.""" + entries = json.loads((ROOT / "conf" / "preset_voices.json").read_text(encoding="utf-8")) + for entry in entries: + reference = entry.get("reference", "") + if reference: + assert not Path(reference).is_absolute(), entry["name"] From 9d160e32702f9e1de4d9bfb5d7bebcd4de8c484b Mon Sep 17 00:00:00 2001 From: PaxHelios Date: Wed, 5 Aug 2026 12:14:22 +0200 Subject: [PATCH 40/98] test: keep a bare `pytest` run from aborting on a standalone self-check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `scripts/test_pick_runtime_dtype.py` runs its assertions at import time and exits with their verdict. pytest collects anything named `test_*.py`, so it imported the script, caught the `SystemExit`, and abandoned the entire run — `pytest` alone collected nothing at all, and only `pytest tests` worked. Point pytest at `tests/`, where the suite actually lives, and fix the one expectation the script had outgrown: it still wanted `cpu/fp16` to pass through untouched, from before CPU was made to force float32 because bfloat16 is emulated there and measurably slower. It now checks that rule and its `VOXCPM_CPU_DTYPE` escape hatch instead: 25/25. Co-Authored-By: Claude Opus 5 (1M context) --- pyproject.toml | 6 ++++++ scripts/test_pick_runtime_dtype.py | 13 +++++++++++-- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 4c0d38b1..f26bcf09 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -87,6 +87,12 @@ include = ["voxcpm*"] [tool.setuptools_scm] version_scheme = "post-release" +[tool.pytest.ini_options] +# The suite lives in tests/. scripts/test_*.py are standalone self-checks that +# run their assertions at import time and sys.exit() — collecting them aborts +# the whole run, so keep pytest out of scripts/. +testpaths = ["tests"] + [tool.black] line-length = 120 target-version = ['py310'] diff --git a/scripts/test_pick_runtime_dtype.py b/scripts/test_pick_runtime_dtype.py index 160aba33..fb7113e8 100644 --- a/scripts/test_pick_runtime_dtype.py +++ b/scripts/test_pick_runtime_dtype.py @@ -57,11 +57,20 @@ def expect_raises(fn, exc_type, label): print(f"[FAIL] get_dtype({dt!r}) raised: {e}") results.append(False) -print("\n=== pick_runtime_dtype: non-mps is a no-op ===") +print("\n=== pick_runtime_dtype: cuda keeps the checkpoint dtype ===") results.append(expect(pick_runtime_dtype("cuda", "bfloat16"), "bfloat16", "cuda/bf16 untouched")) -results.append(expect(pick_runtime_dtype("cpu", "float16"), "float16", "cpu/fp16 untouched")) results.append(expect(pick_runtime_dtype("cuda", "float32"), "float32", "cuda/fp32 untouched")) +print("\n=== pick_runtime_dtype: cpu forces fp32 (bf16 is emulated, slower) ===") +os.environ.pop("VOXCPM_CPU_DTYPE", None) +results.append(expect(pick_runtime_dtype("cpu", "bfloat16"), "float32", "cpu/bf16 -> fp32")) +results.append(expect(pick_runtime_dtype("cpu", "float16"), "float32", "cpu/fp16 -> fp32")) +results.append(expect(pick_runtime_dtype("cpu", "float32"), "float32", "cpu/fp32 stays")) + +os.environ["VOXCPM_CPU_DTYPE"] = "bfloat16" +results.append(expect(pick_runtime_dtype("cpu", "bfloat16"), "bfloat16", "VOXCPM_CPU_DTYPE override honored")) +os.environ.pop("VOXCPM_CPU_DTYPE", None) + print("\n=== pick_runtime_dtype: mps forces fp32 for low-precision ===") os.environ.pop("VOXCPM_MPS_DTYPE", None) results.append(expect(pick_runtime_dtype("mps", "bfloat16"), "float32", "mps/bf16 -> fp32")) From 5a98b43ab6a7ae673378db054f8e7270cc9bfd23 Mon Sep 17 00:00:00 2001 From: PaxHelios Date: Wed, 5 Aug 2026 12:14:40 +0200 Subject: [PATCH 41/98] feat(quality): catch a cloning recording its transcript does not cover MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The most expensive defect in cloning is also the quietest one: a reference clip that contains speech its transcript does not mention. The recording sounds perfectly fine on its own, so nothing looks wrong — but the engine reads the pair and concludes that the text runs out before the audio does, then ends every narrated segment early. A whole book truncated, from a mismatch visible in a millisecond, with the symptom hours of CPU away from its cause. Measured on the recording that caused it: cut at 8.8s with a one-sentence transcript, the clip trails 2.5s of the *next* sentence and narration comes back at 3.2s where 5.9s were due, twice, on two different seeds. Recut at 5.8s where that sentence ends, same voice, same transcript: sane, and equivalent to the full 19s reference (7.55s against 7.89s). A short reference costs nothing; a misaligned one costs everything. `inspect_reference` measures the one against the other, before the model is loaded — so during `--dry-run` too, and in both tabs. It compares characters to *speech* seconds, which is the new `audio.speech_seconds`: not the file's length and not the span between its first and last word either, because a speaker who pauses must not measure as a speaker who is under-transcribed. It warns and never refuses. The bounds sit between the takes measured here — 10.4 char/s for the under-transcribed one against 15.9 and 19.0 for the two that are exact — which is one voice and one session, too thin a basis to make a verdict binding. From the interface there would be no way past a wrong one anyway, and a deliberately slow take would simply become unusable. Co-Authored-By: Claude Opus 5 (1M context) --- app.py | 25 +++++ docs/NARRATION.md | 27 +++++ narration/audio.py | 37 +++++++ narration/quality.py | 161 +++++++++++++++++++++++++++++ scripts/narrate_book.py | 21 ++++ tests/test_narrate_book_cloning.py | 49 +++++++++ tests/test_narration_audio.py | 34 ++++++ tests/test_narration_quality.py | 110 ++++++++++++++++++++ 8 files changed, 464 insertions(+) diff --git a/app.py b/app.py index b100794f..c13f43a0 100644 --- a/app.py +++ b/app.py @@ -800,6 +800,27 @@ def _preset_reference(preset_name) -> Tuple[Optional[str], str]: ) return voices_catalogue.reference_of(preset) + def _warn_about_reference(reference: Optional[str], reference_text: str) -> None: + """Warn, before generating, when a recording and its transcript disagree. + + Warns rather than refuses. The bounds behind this are heuristics on a + thin sample, and from the interface there would be no way past a wrong + verdict — a deliberate take that measures oddly would simply become + unusable. Being told is the whole value; being stopped is not. + """ + if not reference: + return + try: + wav, sample_rate = sf.read(reference, dtype="float32", always_2d=False) + except Exception as error: # noqa: BLE001 - synthesis will fail on it too + logger.warning("Could not inspect reference %s: %s", reference, error) + return + + report = quality.inspect_reference(wav, sample_rate, reference_text or "") + for issue in report.issues: + logger.warning("Reference %s: %s", Path(reference).name, issue) + gr.Warning(f"Audio de référence : {issue.detail}") + def _prepare_seed(use_random_seed: bool, seed_value): if use_random_seed: return random.randint(0, 2**32 - 1) @@ -921,6 +942,7 @@ def _generate( ref_wav = preset_reference actual_control = "" actual_prompt_text = actual_prompt_text or preset_reference_text + _warn_about_reference(ref_wav, actual_prompt_text) voice_name = preset_name if preset_name and preset_name != PRESET_CUSTOM_LABEL else "custom" if prepare_text: @@ -1166,6 +1188,9 @@ def _book_narrate( "Choisissez une voix dans la liste ci-dessus, ou décrivez-en une " "dans l'onglet Studio." ) + # Said now rather than never: a mismatched recording truncates every + # segment, and a book is hours of CPU before the first one is heard. + _warn_about_reference(reference, reference_text) outdir = _book_dir(title) outdir.mkdir(parents=True, exist_ok=True) diff --git a/docs/NARRATION.md b/docs/NARRATION.md index d8cdcc49..a7164752 100644 --- a/docs/NARRATION.md +++ b/docs/NARRATION.md @@ -173,6 +173,33 @@ passage au rythme et sur le ton que tu veux entendre, pas une phrase neutre. `--reference-text` est facultatif et vaut le coup : le moteur met les mots en face de l'audio et clone plus fidèlement. +**La transcription doit couvrir tout l'enregistrement, et rien de plus.** C'est le +piège le plus coûteux du clonage, parce qu'il est silencieux : l'enregistrement +sonne parfaitement bien tout seul. Si le fichier contient de la parole que la +transcription ne mentionne pas — typiquement un clip coupé après la phrase +transcrite, qui mord sur la suivante — le moteur en déduit que le texte s'épuise +avant l'audio, et **termine trop tôt chaque segment du livre**. Mesuré ici : une +même voix, coupée à 8,8 s avec une transcription d'une phrase, sort tronquée ; +recoupée à 5,8 s là où finit cette phrase, elle sort saine et équivalente à la +référence complète de 19 s. Une référence courte ne coûte rien ; une référence +mal alignée coûte tout. + +Le pré-vol le vérifie tout seul, avant même de charger le modèle — donc aussi en +`--dry-run`, et dans les deux onglets : + +``` +Voix : clonée de edwin_derive_v1_phrase.wav (avec transcription) + référence saine (4.3s de parole, 16 car/s) +``` + +Il compare le temps de **parole réelle** (silences de début, de fin et pauses +exclues) au nombre de caractères de la transcription, et signale les deux +décalages : `undertranscribed` (plus de parole que de texte) et `overtranscribed` +(des mots qui ne sont pas dans l'enregistrement), plus une référence sans +transcription, trop courte, trop longue ou saturée. **Il avertit, il ne refuse +pas** : les bornes sont des heuristiques calées sur peu d'enregistrements, et une +prise volontairement lente ne doit pas devenir inutilisable pour autant. + **Le cache suit la voix.** L'empreinte qui adresse un segment inclut un **hachage du contenu** de l'enregistrement, pas son chemin. Deux conséquences voulues : réenregistrer dans le même fichier ne ressert pas l'ancienne voix, et déplacer le diff --git a/narration/audio.py b/narration/audio.py index 01dbb599..ffa2ac32 100644 --- a/narration/audio.py +++ b/narration/audio.py @@ -48,6 +48,7 @@ "silence", "speech_bounds", "speech_rms_db", + "speech_seconds", "stitch", "trim_silence", ] @@ -348,6 +349,42 @@ def speech_bounds( return int(loud[0]) * hop, min(wav.size, int(loud[-1]) * hop + frame) +def speech_seconds( + wav: np.ndarray, + sr: int, + *, + relative_db: float = 25.0, + frame_ms: float = 20.0, +) -> float: + """Seconds of the signal that actually carry speech. + + Not the file's length, and not the span between its first and last word + either: the silence *inside* that span is excluded too. That is what makes + the number comparable with a transcript — a pause carries no characters, so + counting it would make a speaker who breathes measure as a slower speaker. + + Same relative threshold as :func:`speech_bounds`, for the same reason: a + recording arrives at whatever level it was made at. + """ + wav = as_float_mono(wav) + if wav.size == 0 or not sr: + return 0.0 + + hop_ms = frame_ms / 2.0 + power = _frame_power(wav, sr, frame_ms, hop_ms) + if power.size == 0: + return 0.0 + + level = speech_rms_db(wav, sr) + if not np.isfinite(level): + return 0.0 + + threshold = 10.0 ** ((level - relative_db) / 10.0) + loud = int(np.count_nonzero(power > threshold)) + # Frames overlap, so each one stands for a hop's worth of signal. + return min(float(wav.size) / sr, loud * hop_ms / 1000.0) + + def fade_edges(wav: np.ndarray, sr: int, fade_ms: float = 8.0) -> np.ndarray: """Ramp the first and last few milliseconds so joins do not click. diff --git a/narration/quality.py b/narration/quality.py index af843016..bba85da4 100644 --- a/narration/quality.py +++ b/narration/quality.py @@ -26,6 +26,13 @@ ``looped`` the level envelope repeats, as it does when a phrase is spoken twice. +The same pairing catches a defect one step earlier, before anything has been +generated at all. A *cloned* voice is a recording plus the words spoken in it, +and :func:`inspect_reference` measures the one against the other. A recording +that says more than its transcript admits teaches the engine that the text runs +out before the audio does, and it then ends every narrated segment early — the +whole book truncated, from a mismatch visible in a millisecond. + Deliberately torch-free, like the rest of the package: the checks run on a finished waveform, so they are unit-testable against synthetic signals without loading a model. @@ -45,10 +52,13 @@ "SUSPECT", "Issue", "QualityThresholds", + "ReferenceReport", + "ReferenceThresholds", "RenderResult", "SegmentReport", "ends_abruptly", "envelope_repetition", + "inspect_reference", "inspect_segment", "longest_internal_silence_sec", "render_checked", @@ -405,6 +415,157 @@ def inspect_segment( ) +# -------------------------------------------------------------------------- +# The recording a cloned voice is built from +# -------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class ReferenceThresholds: + """Where a cloning recording stops being usable. + + The rate bounds are deliberately not :class:`QualityThresholds`': those + judge *generated* speech against the fourteen preset voices, while this + judges a human take, measured over the speech alone rather than the whole + file. Measured on the three recordings available here — one voice, one + session — the under-transcribed one comes back at 10.4 characters per second + of speech against 15.9 and 19.0 for the two whose transcripts are exact. + The lower bound sits between them, nearer the bad case, because the cost is + asymmetric: this only ever prints a warning, so missing a mild mismatch is + cheaper than crying wolf at a deliberate speaker. + + Three recordings of one speaker is a thin basis, and the honest reading of + these numbers is "far enough outside plausible narration to be worth a + look", not "measured to two significant figures". Widen them rather than + argue with them if a real take is ever flagged. + """ + + #: Below this, there is more speech in the recording than the transcript accounts for. + min_chars_per_second: float = 12.0 + #: Above this, the transcript claims words the recording does not contain. + max_chars_per_second: float = 30.0 + #: Typical rate, used only to phrase the report in seconds. + expected_chars_per_second: float = 17.0 + #: Below this there is too little voice to clone from. + min_speech_sec: float = 3.0 + #: Past this the recording is only costing tokens; it clones no better. + max_speech_sec: float = 30.0 + silence_peak_db: float = -50.0 + clipping_sample_ratio: float = 0.0005 + clipping_threshold: float = 0.999 + + +@dataclass(frozen=True) +class ReferenceReport: + """What a cloning recording measures, and what is wrong with it.""" + + duration_sec: float + speech_sec: float + characters: int + chars_per_second: float + peak_db: float + issues: Tuple[Issue, ...] = () + + @property + def ok(self) -> bool: + return not self.issues + + @property + def fatal(self) -> bool: + return any(issue.severity == FATAL for issue in self.issues) + + @property + def severity(self) -> Optional[str]: + if not self.issues: + return None + return max((issue.severity for issue in self.issues), key=lambda s: _SEVERITY_RANK.get(s, 0)) + + @property + def codes(self) -> Tuple[str, ...]: + return tuple(issue.code for issue in self.issues) + + def describe(self) -> str: + if self.ok: + return f"ok ({self.speech_sec:.1f}s de parole, {self.chars_per_second:.0f} car/s)" + return ", ".join(f"{i.code} — {i.detail}" for i in self.issues) + + +def inspect_reference( + wav: np.ndarray, + sr: int, + text: str, + thresholds: ReferenceThresholds = ReferenceThresholds(), +) -> ReferenceReport: + """Measure a cloning recording against the transcript that goes with it. + + Runs before a single segment is generated, which is the entire point: a + mismatch here is silent — the recording sounds perfectly fine on its own — + and only shows up as truncated narration minutes of CPU later, where the + cause is nowhere near the symptom. + + The comparison is against *speech* seconds, not the file's length, so + trailing silence and a speaker's pauses do not read as a mismatch. Nothing + is refused: these bounds are heuristics on a thin sample, and the caller is + better placed than they are to decide that an unusual take is deliberate. + """ + wav = audio_tools.as_float_mono(wav) + duration = float(wav.size) / sr if sr > 0 else 0.0 + speech = audio_tools.speech_seconds(wav, sr) if wav.size and sr > 0 else 0.0 + characters = len((text or "").strip()) + rate = characters / speech if speech > 0 else 0.0 + peak = audio_tools.peak_db(wav) + + issues: List[Issue] = [] + + if wav.size == 0 or speech <= 0.0 or peak < thresholds.silence_peak_db: + issues.append(Issue("silent", FATAL, f"aucune parole dans l'enregistrement ({duration:.1f}s)")) + return ReferenceReport(duration, speech, characters, rate, float(peak), tuple(issues)) + + if not characters: + # Supported by the engine, and markedly worse: without the words, the + # timbre is copied but the prosody is guessed. + issues.append( + Issue("no_transcript", SUSPECT, "enregistrement sans transcription, le clonage sera moins fidèle") + ) + elif rate < thresholds.min_chars_per_second: + accounted = characters / thresholds.expected_chars_per_second + issues.append( + Issue( + "undertranscribed", + FATAL, + f"{speech:.1f}s de parole pour {characters} caractères " + f"(~{accounted:.1f}s attendues) — la transcription ne couvre pas tout " + f"l'enregistrement, la narration sera tronquée", + ) + ) + elif rate > thresholds.max_chars_per_second: + accounted = characters / thresholds.expected_chars_per_second + issues.append( + Issue( + "overtranscribed", + FATAL, + f"{characters} caractères pour {speech:.1f}s de parole " + f"(~{accounted:.1f}s attendues) — la transcription contient des mots " + f"qui ne sont pas dans l'enregistrement", + ) + ) + + if speech < thresholds.min_speech_sec: + issues.append( + Issue("too_short", SUSPECT, f"{speech:.1f}s de parole, peu pour caractériser une voix") + ) + elif speech > thresholds.max_speech_sec: + issues.append( + Issue("too_long", SUSPECT, f"{speech:.1f}s de parole, sans bénéfice pour le clonage") + ) + + clipped = _clipped_ratio(wav, thresholds.clipping_threshold) + if clipped > thresholds.clipping_sample_ratio: + issues.append(Issue("clipped", SUSPECT, f"{clipped * 100:.2f}% des échantillons saturés")) + + return ReferenceReport(duration, speech, characters, rate, float(peak), tuple(issues)) + + # -------------------------------------------------------------------------- # Repair # -------------------------------------------------------------------------- diff --git a/scripts/narrate_book.py b/scripts/narrate_book.py index 0a7090e2..e72b46af 100644 --- a/scripts/narrate_book.py +++ b/scripts/narrate_book.py @@ -92,6 +92,26 @@ def resolve_voice(args) -> tuple[str, int | None]: return (args.description or ""), args.seed +def describe_reference(path: str, text: str) -> str: + """One line on the state of a cloning recording, for the pre-flight summary. + + Printed before the model is even loaded — and so during ``--dry-run`` too, + which is where it earns its keep: a recording whose transcript does not + cover it truncates every segment of the book, and the symptom appears + minutes of CPU away from the cause. + """ + try: + wav, sr = sf.read(path, dtype="float32", always_2d=False) + except Exception as error: # noqa: BLE001 - the engine will fail on it too, more obscurely + return f"référence illisible ({error})" + + report = quality.inspect_reference(wav, sr, text) + if report.ok: + return f"référence saine ({report.speech_sec:.1f}s de parole, {report.chars_per_second:.0f} car/s)" + marks = {quality.FATAL: "/!\\", quality.SUSPECT: "(!)"} + return " ; ".join(f"{marks.get(i.severity, '')} {i.detail}" for i in report.issues) + + def chapter_title(chapter: str, index: int) -> str: """First non-empty line of a chapter, used as its marker title.""" for line in chapter.splitlines(): @@ -299,6 +319,7 @@ def main() -> int: if args.reference_audio: print(f"Voix : clonée de {Path(args.reference_audio).name}" + (" (avec transcription)" if args.reference_text else " (sans transcription)")) + print(f" {describe_reference(args.reference_audio, args.reference_text or '')}") else: print(f"Voix : {args.voice or '(personnalisée)'} | seed={seed}") print(f"Préparation : {'désactivée' if args.no_text_prep else f'française ({len(lexicon)} entrée(s) de lexique)'}") diff --git a/tests/test_narrate_book_cloning.py b/tests/test_narrate_book_cloning.py index 9616d90c..89e136a7 100644 --- a/tests/test_narrate_book_cloning.py +++ b/tests/test_narrate_book_cloning.py @@ -125,3 +125,52 @@ def test_the_plan_says_the_voice_was_cloned(self, monkeypatch, book, tmp_path, r out = capsys.readouterr().out assert "clonée" in out assert reference.name in out + + +class TestCheckingTheReferenceBeforeAnythingRuns: + """The recording is measured against its transcript in the pre-flight block. + + It runs during ``--dry-run``, which is the point: the defect it catches + otherwise shows up as truncated narration, minutes of CPU from its cause. + """ + + def dry_run(self, monkeypatch, book, tmp_path, capsys, *extra) -> str: + run(monkeypatch, book, tmp_path / "out", "--dry-run", *extra) + return capsys.readouterr().out + + def test_a_matching_recording_is_reported_sane( + self, monkeypatch, book, tmp_path, reference, capsys + ): + # 3s of voice for 51 characters — 17 char/s. + out = self.dry_run( + monkeypatch, book, tmp_path, capsys, + "--reference-audio", str(reference), + "--reference-text", "a" * 51, + ) + assert "référence saine" in out + + def test_a_transcript_that_stops_short_is_called_out( + self, monkeypatch, book, tmp_path, reference, capsys + ): + out = self.dry_run( + monkeypatch, book, tmp_path, capsys, + "--reference-audio", str(reference), + "--reference-text", "a" * 10, + ) + assert "tronquée" in out + + def test_a_recording_with_no_transcript_is_called_out( + self, monkeypatch, book, tmp_path, reference, capsys + ): + out = self.dry_run(monkeypatch, book, tmp_path, capsys, "--reference-audio", str(reference)) + assert "sans transcription" in out + + def test_an_unreadable_recording_does_not_stop_the_pre_flight( + self, monkeypatch, book, tmp_path, capsys + ): + """It is the engine's job to fail on it; the summary still prints.""" + broken = tmp_path / "cassé.wav" + broken.write_bytes(b"not a wav at all") + out = self.dry_run(monkeypatch, book, tmp_path, capsys, "--reference-audio", str(broken)) + assert "illisible" in out + assert "Chapitres" in out diff --git a/tests/test_narration_audio.py b/tests/test_narration_audio.py index a2c4fd19..1ab6bd4c 100644 --- a/tests/test_narration_audio.py +++ b/tests/test_narration_audio.py @@ -261,3 +261,37 @@ def test_mastering_removes_the_offset(self): def test_empty(self): assert audio.remove_dc(np.zeros(0, dtype=np.float32)).size == 0 + + +class TestSpeechSeconds: + """How much of a file is voice — the measure a transcript is compared with.""" + + def test_counts_only_the_speech(self): + signal = np.concatenate([audio.silence(SR, 1.0), sine(3.0), audio.silence(SR, 2.0)]) + assert audio.speech_seconds(signal, SR) == pytest.approx(3.0, abs=0.15) + + def test_pauses_inside_the_speech_are_excluded_too(self): + """What separates this from the span between the first and last word.""" + signal = np.concatenate( + [sine(2.0), audio.silence(SR, 1.5), sine(2.0), audio.silence(SR, 1.5), sine(2.0)] + ) + assert audio.speech_seconds(signal, SR) == pytest.approx(6.0, abs=0.3) + + def test_never_exceeds_the_file(self): + signal = sine(2.0) + assert audio.speech_seconds(signal, SR) <= 2.0 + + def test_silence_measures_nothing(self): + assert audio.speech_seconds(audio.silence(SR, 3.0), SR) == 0.0 + + def test_empty_input_is_not_a_crash(self): + assert audio.speech_seconds(np.zeros(0, dtype=np.float32), SR) == 0.0 + assert audio.speech_seconds(sine(1.0), 0) == 0.0 + + def test_level_does_not_change_the_answer(self): + """The threshold is relative, so a quiet take measures like a loud one.""" + loud = np.concatenate([sine(2.0, amplitude=0.5), audio.silence(SR, 2.0)]) + quiet = np.concatenate([sine(2.0, amplitude=0.005), audio.silence(SR, 2.0)]) + assert audio.speech_seconds(quiet, SR) == pytest.approx( + audio.speech_seconds(loud, SR), abs=0.1 + ) diff --git a/tests/test_narration_quality.py b/tests/test_narration_quality.py index 0a6d5e11..f7caf2eb 100644 --- a/tests/test_narration_quality.py +++ b/tests/test_narration_quality.py @@ -364,3 +364,113 @@ def test_render_checked_never_calls_render_more_than_once_when_ok(): sentence(140), lambda seed: (calls.append(seed), _good(seed))[1], base_seed=None, max_attempts=5 ) assert len(calls) == 1 + + +# -------------------------------------------------------------------------- +# The recording a cloned voice is built from +# -------------------------------------------------------------------------- + + +def recording(speech_seconds: float, pauses: int = 0, sr: int = SR) -> np.ndarray: + """A take: speech broken by pauses, with the silence a real file carries. + + The pauses matter to these tests specifically — the check must measure the + speech and not the file, so a take with pauses and a take without must be + judged the same when their transcripts are the same. + """ + if pauses <= 0: + return with_edges(speech(speech_seconds, sr=sr), sr=sr) + piece = speech_seconds / (pauses + 1) + parts = [] + for index in range(pauses + 1): + parts.append(speech(piece, sr=sr, seed=index)) + if index < pauses: + parts.append(np.zeros(int(sr * 0.5), dtype=np.float32)) + return with_edges(np.concatenate(parts), sr=sr) + + +def test_matching_recording_and_transcript_pass(): + # 5s of speech for 85 characters — 17 char/s, the middle of the range. + report = quality.inspect_reference(recording(5.0), SR, sentence(85)) + assert report.ok, report.describe() + assert report.speech_sec == pytest.approx(5.0, abs=0.4) + assert report.chars_per_second == pytest.approx(17.0, rel=0.15) + + +def test_recording_saying_more_than_its_transcript_is_fatal(): + """The defect that cost a whole run: a clip cut past its transcribed sentence. + + The engine then learns that the text runs out before the audio does, and + ends every narrated segment early. + """ + report = quality.inspect_reference(recording(7.0), SR, sentence(60)) + assert report.fatal + assert "undertranscribed" in report.codes + assert "tronquée" in report.describe() + + +def test_transcript_claiming_words_the_recording_lacks_is_fatal(): + report = quality.inspect_reference(recording(4.0), SR, sentence(200)) + assert report.fatal + assert "overtranscribed" in report.codes + + +def test_pauses_do_not_count_against_the_transcript(): + """A speaker who breathes must not measure as an under-transcribed take.""" + spoken = sentence(85) + without = quality.inspect_reference(recording(5.0, pauses=0), SR, spoken) + withal = quality.inspect_reference(recording(5.0, pauses=3), SR, spoken) + assert without.ok and withal.ok, withal.describe() + # Two extra seconds of silence, and the rate barely moves. + assert withal.chars_per_second == pytest.approx(without.chars_per_second, rel=0.15) + + +def test_recording_without_a_transcript_is_flagged_but_not_fatal(): + report = quality.inspect_reference(recording(5.0), SR, "") + assert not report.fatal + assert report.codes == ("no_transcript",) + + +def test_silent_recording_is_fatal_and_says_so_first(): + report = quality.inspect_reference(np.zeros(SR * 3, dtype=np.float32), SR, sentence(50)) + assert report.fatal + assert report.codes == ("silent",) + + +def test_empty_recording_is_fatal(): + report = quality.inspect_reference(np.zeros(0, dtype=np.float32), SR, sentence(50)) + assert report.fatal + assert "silent" in report.codes + + +def test_a_very_short_take_is_worth_mentioning_but_not_fatal(): + # 2s of speech, transcript matching, so only the length is at issue. + report = quality.inspect_reference(recording(2.0), SR, sentence(34)) + assert not report.fatal + assert "too_short" in report.codes + + +def test_a_needlessly_long_take_is_worth_mentioning_but_not_fatal(): + report = quality.inspect_reference(recording(35.0), SR, sentence(595)) + assert not report.fatal + assert "too_long" in report.codes + + +def test_clipping_in_the_recording_is_reported(): + wav = recording(5.0) + wav[: int(SR * 0.2)] = 1.0 + report = quality.inspect_reference(wav, SR, sentence(85)) + assert "clipped" in report.codes + + +def test_reference_thresholds_separate_the_measured_recordings(): + """The bounds are set from real takes; keep them on the right side of those. + + Measured on the three recordings this check was built from: 10.4 char/s for + the under-transcribed one, 15.9 and 19.0 for the two whose transcripts are + exact. + """ + bounds = quality.ReferenceThresholds() + assert bounds.min_chars_per_second > 10.4 + assert bounds.min_chars_per_second < 15.9 + assert bounds.max_chars_per_second > 19.0 From 435e827bf883b5739c19d35198e30149e45f9892 Mon Sep 17 00:00:00 2001 From: PaxHelios Date: Wed, 5 Aug 2026 12:14:52 +0200 Subject: [PATCH 42/98] fix(voices): cut the Edwin reference where its transcript ends MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The preset pointed at an 8.8s clip whose transcript stopped after the first sentence, so every segment narrated in that voice came back truncated. Point it at a 5.8s cut taken in the pause that follows that sentence — the transcript now covers the recording exactly, and nothing else changes. The check added alongside this would have said so in a dry run. Co-Authored-By: Claude Opus 5 (1M context) --- conf/preset_voices.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/conf/preset_voices.json b/conf/preset_voices.json index cd40fcc3..48176a0d 100644 --- a/conf/preset_voices.json +++ b/conf/preset_voices.json @@ -133,7 +133,7 @@ "diffusion_steps": 10, "normalize": true, "lang": "fr", - "reference": "assets/voices/edwin_derive_v1.wav", - "reference_text": "Bienvenue dans cet espace de méditation conçu spécialement pour vous. Pendant les vingt prochaines minutes, je vous invite à mettre en pause vos responsabilités, vos e-mails et toutes formes de distractions, pour vous offrir un moment précieux de calme et de recentrage." + "reference": "assets/voices/edwin_derive_v1_phrase.wav", + "reference_text": "Bienvenue dans cet espace de méditation conçu spécialement pour vous." } ] From 4d49b364feb37527b34cf756a40c51ca9104840a Mon Sep 17 00:00:00 2001 From: PaxHelios Date: Wed, 5 Aug 2026 16:10:46 +0200 Subject: [PATCH 43/98] fix(text_fr): a whole hour swallowed the space that followed it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `9h du matin` came back as `neuf heuresdu matin`, and so did every bare hour followed by a word: `20h il rentre`, `de 9h à 17h`. The separator before the minutes sat outside the optional group, so when there were no minutes to read the pattern consumed the space anyway. Punctuation hid it — `8h,` and `9h.` were always right, which is why it survived this long. Moving the separator inside the group needs a guard the `\b` used to provide: without one, `35ha` would read as `trente-cinq heures a`. `(?!\w)` covers both that and `9h305`. Found while normalising a transcript into a cloning reference, where the missing space would have been taught to the voice. Co-Authored-By: Claude Opus 5 (1M context) --- narration/text_fr.py | 6 +++++- tests/test_narration_text_fr.py | 24 ++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/narration/text_fr.py b/narration/text_fr.py index c01545ac..d80741ab 100644 --- a/narration/text_fr.py +++ b/narration/text_fr.py @@ -231,7 +231,11 @@ def roman_to_int(s: str) -> Optional[int]: # otherwise turn the extremely common "Le", "Ce", "De" and "Me" into ordinals — # "Le manuscrit" read aloud as "cinquantième manuscrit". _RE_ROMAN_ORDINAL = re.compile(r"\b([IVX]|[IVXLCDM]{2,15})(?:e|è?me|ᵉ)\b") -_RE_TIME = re.compile(r"\b(\d{1,2})\s*[hH]\s*(\d{2})?\b(?!\d)") +# The minutes carry their own separator: with the space outside the optional +# group, "9h du matin" matched "9h " and came back as "neuf heuresdu matin". +# The trailing guard is what keeps "35ha" and "9h305" out — a bare `\b` would +# let the first of them through as "trente-cinq heures a". +_RE_TIME = re.compile(r"\b(\d{1,2})\s*[hH](?:\s*(\d{2}))?(?!\w)") _RE_CURRENCY = re.compile(rf"({_NUM})(?:,(\d{{1,2}}))?\s*([€$£])") _RE_CURRENCY_PREFIX = re.compile(rf"([€$£])\s*({_NUM})(?:,(\d{{1,2}}))?") _RE_PERCENT = re.compile(rf"({_NUM}(?:,\d+)?)\s*%") diff --git a/tests/test_narration_text_fr.py b/tests/test_narration_text_fr.py index e490292d..9e40b010 100644 --- a/tests/test_narration_text_fr.py +++ b/tests/test_narration_text_fr.py @@ -201,6 +201,30 @@ def test_times(self, source, expected): def test_an_hour_like_number_is_not_a_time(self): assert normalize_french("il y a 2 hommes") == "il y a deux hommes" + @pytest.mark.parametrize( + "source,expected", + [ + # The space after a whole hour used to be eaten with the minutes + # that were not there: "neuf heuresdu matin". + ("Il est 9h du matin.", "Il est neuf heures du matin."), + ("Vers 20h il rentre.", "Vers vingt heures il rentre."), + ("de 9h à 17h", "de neuf heures à dix-sept heures"), + # Punctuation hid the defect, and must keep working. + ("À 8h, il partit.", "À huit heures, il partit."), + ("Il est 9h.", "Il est neuf heures."), + # Minutes still read, spaced or not. + ("à 14 h 30", "à quatorze heures trente"), + ("10h00 pile", "dix heures pile"), + ], + ) + def test_a_whole_hour_keeps_the_space_after_it(self, source, expected): + assert normalize_french(source) == expected + + @pytest.mark.parametrize("source", ["un champ de 35ha", "9h305 n'est pas une heure"]) + def test_what_only_looks_like_an_hour_is_left_alone(self, source): + """A letter or a digit right after the h means it was never a time.""" + assert normalize_french(source) == source + @pytest.mark.parametrize( "source,expected", [ From 0dfc2d0fe77ab63c5fa50e50e4ed20bc47465ae6 Mon Sep 17 00:00:00 2001 From: PaxHelios Date: Wed, 5 Aug 2026 16:10:47 +0200 Subject: [PATCH 44/98] feat(voices): Aurore, for books and for guided meditation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two presets from two recordings of the same voice, kept apart because the registers measure apart: 20.5 characters per second of speech reading a book against 16.2 meditating. Generating the same sentence in each reproduces the gap — 4.0s of speech against 6.6s — so this is two voices in use, not one entry duplicated. Both references are cut in a pause at the end of a sentence, and their transcripts stop exactly there: 13.3s and 16.4s of speech, 21 and 16 char/s, both sane by the pre-flight check. Transcribed with Whisper large-v3-turbo, whose per-segment timestamps are what made the cut a lookup rather than a guess, then run through the French normaliser so the transcript holds the words that are actually spoken — `neuf heures`, not `9h`. Kept short deliberately: the reference is prefixed to every segment generated, so its length is paid again on each one. 13s rather than the full 27s available halves that, and the two cuts measure the same speech rate as the full takes, which is what says the shorter one lost nothing. The recordings themselves stay out of git, like every other voice sample. Co-Authored-By: Claude Opus 5 (1M context) --- conf/preset_voices.json | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/conf/preset_voices.json b/conf/preset_voices.json index 48176a0d..f8297ca1 100644 --- a/conf/preset_voices.json +++ b/conf/preset_voices.json @@ -135,5 +135,27 @@ "lang": "fr", "reference": "assets/voices/edwin_derive_v1_phrase.wav", "reference_text": "Bienvenue dans cet espace de méditation conçu spécialement pour vous." + }, + { + "name": "Aurore — livre audio", + "description": "", + "seed": 1235, + "cfg": 2.0, + "diffusion_steps": 10, + "normalize": true, + "lang": "fr", + "reference": "assets/voices/aurore_livre.wav", + "reference_text": "Il est neuf heures du matin, dans le bureau parisien d'une grande entreprise de service. Sarah, trente-huit ans, manager depuis cinq ans, entre dans la pièce. Elle a mal dormi. Elle pose son sac, allume son ordinateur. L'écran affiche trois cent quarante-sept mails non lus." + }, + { + "name": "Aurore — méditation guidée", + "description": "", + "seed": 1236, + "cfg": 2.0, + "diffusion_steps": 10, + "normalize": true, + "lang": "fr", + "reference": "assets/voices/aurore_meditation.wav", + "reference_text": "Bienvenue dans cet espace de méditation conçu spécialement pour vous. Pendant les vingt prochaines minutes, je vous invite à mettre en pause vos responsabilités, vos emails et toute forme de distraction pour vous offrir un moment précieux de calme et de recentrage." } ] From 9274fd311183a8e13565f182db662c2dce05b3e3 Mon Sep 17 00:00:00 2001 From: PaxHelios Date: Wed, 5 Aug 2026 21:09:35 +0200 Subject: [PATCH 45/98] feat(voices): Alex Somerset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cut from 3.5 minutes of guided meditation, not from the top of the file: the recording carries a constant background at -52 dBFS — twenty dB above the two Aurore takes — so the passage was chosen for the quietest floor rather than for being first. It barely varied (-49 to -53 across the file), which is what says the noise is the room, not an incident. `polish.highpass` at 80 Hz then takes back most of it. The background is rumble, not hiss: the 20-120 Hz band sits 32 dB above the midrange, so the filter the repo already uses for mastering removes 4.7 dB of floor at a cost of 0.7 dB of voice. 36.3 dB of signal-to-noise becomes 40.3 — level with the Edwin reference, which clones acceptably. The transcript is exact by construction rather than by alignment: the cut was made on silences first, then transcribed, so the words *are* the audio. Whisper segments break every seven seconds, mid-sentence, and could not have supplied the boundary. 13.1s of speech at 19 char/s, sane by the pre-flight check, and the generated sample comes back clean. Co-Authored-By: Claude Opus 5 (1M context) --- conf/preset_voices.json | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/conf/preset_voices.json b/conf/preset_voices.json index f8297ca1..53a3d70d 100644 --- a/conf/preset_voices.json +++ b/conf/preset_voices.json @@ -157,5 +157,16 @@ "lang": "fr", "reference": "assets/voices/aurore_meditation.wav", "reference_text": "Bienvenue dans cet espace de méditation conçu spécialement pour vous. Pendant les vingt prochaines minutes, je vous invite à mettre en pause vos responsabilités, vos emails et toute forme de distraction pour vous offrir un moment précieux de calme et de recentrage." + }, + { + "name": "Alex Somerset", + "description": "", + "seed": 1237, + "cfg": 2.0, + "diffusion_steps": 10, + "normalize": true, + "lang": "fr", + "reference": "assets/voices/alex_somerset.wav", + "reference_text": "Si l'esprit commence à vagabonder, utilisez ce moment comme opportunité pour cultiver patience et bienveillance envers soi-même. Revenez simplement au souffle, sans jugement ni frustration. Nous allons maintenant entamer un cycle respiratoire conscient." } ] From b258b1014445ec4283dc5dc94121174db99e14ce Mon Sep 17 00:00:00 2001 From: PaxHelios Date: Fri, 7 Aug 2026 13:26:47 +0200 Subject: [PATCH 46/98] feat(cloud): renting a GPU per book, without paying twice for the same setup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renting by the hour only pays off if creating the machine is cheap, and three things made it expensive — each of them invisible until the meter was already running. The cloned voices never arrived. `assets/voices/` is gitignored on purpose: the references are recordings of real people and the repository is public. So a fresh clone had the fourteen synthetic voices and none of the four that matter, their `reference` paths pointing at nothing. Nothing said so until generation, on a GPU billed by the second. Setup now checks the paths the presets actually declare and names what is missing. The model was downloaded again for every book. 4.6 GB plus a Python environment, twenty minutes at GPU price, to reach a state identical to the one destroyed the day before. The install now goes to /workspace when a persistent volume is mounted there, with the Hugging Face cache beside it, and writes an env.sh that the narration session sources — the second book starts in two minutes. And a Blackwell card would have failed at the first matmul: the cu124 wheels were hardcoded, torch imports fine and sees the GPU, then finds no kernel image. The driver already reports the compute capability, so ask it. Bringing the chapters home was documented with rsync, which does not exist on Windows. scp does, and ships with OpenSSH; gpu_session.ps1 wraps the two transfers a session needs, plus the SSH tunnel — the rented port is never worth exposing. The .ps1 carries a BOM because Windows PowerShell 5.1 reads a BOM-less file as CP1252, where an em dash decodes to a trailing right double quote and silently terminates a string. .gitattributes keeps *.sh at LF for the same class of reason: a CRLF shebang answers "bad interpreter", and only ever on the machine being paid for. Verified here: bash -n, the PowerShell parser, the missing-reference detector against a simulated fresh clone, and the script's guard clauses. The remote half cannot be exercised from this machine — no Linux, no GPU. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FBNmTCfFPDth6cWqg69MGu --- .gitattributes | 10 +++ docs/CLOUD.md | 74 ++++++++++++++++++---- scripts/cloud_setup.sh | 87 +++++++++++++++++++++---- scripts/gpu_session.ps1 | 136 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 284 insertions(+), 23 deletions(-) create mode 100644 .gitattributes create mode 100644 scripts/gpu_session.ps1 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..66b8138d --- /dev/null +++ b/.gitattributes @@ -0,0 +1,10 @@ +# cloud_setup.sh est récupéré par `curl | bash` sur une machine Linux. En CRLF, +# le shebang emporte un \r et la machine louée répond « bad interpreter » — sur +# un poste Windows, où core.autocrlf convertit à la volée, l'erreur ne se voit +# jamais avant d'avoir payé l'instance. +*.sh text eol=lf + +# Symétriquement, Windows PowerShell 5.1 lit un .ps1 sans BOM en CP1252 : les +# accents se cassent et un tiret cadratin devient un guillemet fermant, donc un +# délimiteur de chaîne. Git ne doit pas toucher à ces fichiers. +*.ps1 -text diff --git a/docs/CLOUD.md b/docs/CLOUD.md index b9324c9e..afaf8094 100644 --- a/docs/CLOUD.md +++ b/docs/CLOUD.md @@ -56,22 +56,62 @@ sur processeur**, soit un livre de 3 heures en moins d'une heure de calcul. Chez [RunPod](https://www.runpod.io/pricing), une RTX 4090 est à environ **0,34 $/h** en Community Cloud, facturée à la seconde. Un livre entier coûte donc -moins qu'un café. Aucun engagement : on crée l'instance, on lance le script -ci-dessus, on narre, on rapatrie, on détruit. +moins qu'un café. Aucun engagement : on crée l'instance, on narre, on rapatrie, +on détruit. -```bash -# sur la machine louée -bash scripts/cloud_setup.sh -nohup ./.venv/bin/python scripts/narrate_book.py livre.epub \ - --voice "Narrateur profond & calme" --device cuda \ - --assemble m4b --export-acx > narration.log 2>&1 & +### Le volume persistant, qui rend la location à l'heure supportable + +Créer l'instance **avec un volume réseau** monté sur `/workspace`, une fois pour +toutes. Sans lui, chaque livre recommence par 4,6 Go de modèle à télécharger et +un environnement Python à construire — vingt minutes payées au tarif GPU, à +chaque fois, pour retrouver un état identique au précédent. + +`cloud_setup.sh` s'installe de lui-même sur `/workspace` quand il en trouve un, +et y place le cache Hugging Face à côté. Vingt gigaoctets suffisent (~1,4 $/mois) +et le deuxième livre démarre en deux minutes au lieu de vingt. + +**Quelle carte ?** Le modèle tient dans 5 Go de VRAM : n'importe quelle carte à +partir de 12 Go convient, et une RTX 4090 est déjà large. Le script choisit la +roue PyTorch d'après la *compute capability* rapportée par le pilote, donc une +carte Blackwell (RTX 5090) reçoit bien `cu128` et non `cu124` — avec lequel torch +se charge, voit la carte, puis échoue au premier calcul. + +### Les quatre étapes d'un livre + +```powershell +# 1. La machine, une fois créée (SSH selon l'IP et le port donnés par RunPod) +ssh root@ -p +curl -fsSL https://raw.githubusercontent.com/Eddyosas008/VoxCPM/claude/repo-analysis-improvement-dg0ies/scripts/cloud_setup.sh | bash + +# 2. Depuis votre poste : les voix clonées et le livre +# (assets/voices/ est hors du dépôt — voir plus bas) +./scripts/gpu_session.ps1 push -RemoteHost -Port -Book C:\livres\mon_livre.epub -# depuis chez soi, quand c'est fini -rsync -avz root@:~/voxcpm/output/book_/ ./book_/ +# 3. Sur la machine louée : narrer, sans surveillance +source /workspace/voxcpm/env.sh +nohup python scripts/narrate_book.py mon_livre.epub --device cuda \ + --voice 'Aurore — livre audio' --assemble m4b --export-acx \ + > narration.log 2>&1 & +tail -f narration.log + +# 4. Depuis votre poste, quand c'est fini +./scripts/gpu_session.ps1 pull -RemoteHost -Port -Name mon_livre ``` **Détruire l'instance en partant.** Elle est facturée tant qu'elle existe, même -inactive. +inactive — et vérifier le contenu rapatrié *avant* de détruire, pas après. + +### Les voix clonées ne voyagent pas avec le dépôt + +`assets/voices/` est dans le `.gitignore`, délibérément : ce sont des +enregistrements de personnes réelles et le dépôt est public. Un clone frais a +donc les quatorze voix de synthèse et **aucune des voix clonées** — leurs +références pointent vers des fichiers absents, et l'échec ne se verrait qu'à la +génération, sur un GPU facturé. `cloud_setup.sh` le signale à la fin de +l'installation, et `gpu_session.ps1 push` envoie les 4,6 Mo qui manquent. + +`rsync` n'existe pas sur Windows : `gpu_session.ps1` s'appuie sur `scp`, livré +avec OpenSSH. Sous Linux ou macOS, `rsync -avz` reste évidemment plus efficace. ## Route 3 — Kaggle, pour ne rien payer @@ -104,6 +144,12 @@ ssh -N -L 8808:127.0.0.1:8808 root@ # puis http://127.0.0.1:8808 ``` +Sous Windows, où le port de la machine louée n'est presque jamais 22 : + +```powershell +./scripts/gpu_session.ps1 tunnel -RemoteHost -Port +``` + **Un mot de passe**, si l'accès direct est nécessaire : ```bash @@ -173,5 +219,9 @@ ce qui rend la location à l'heure économique. |---|---|---|---| | **VPS 2 cœurs** | ~3× plus lent qu'un portable | déjà payé | Permanence, stockage, tout le hors-synthèse | | **VPS 8 cœurs** | ~4× un portable | abonnement mensuel | Narration sans surveillance, sans louer | -| **GPU à l'heure** | **~60× un portable** | ~0,34 $/h | Un livre entier en moins d'une heure | +| **GPU à l'heure** | **~60× un portable** | ~0,34 $/h, soit ~0,35 $ le livre | Un livre entier en moins d'une heure | | **Kaggle** | GPU, sessions de 12 h | gratuit | Essais, et livres entiers avec un peu de patience | + +Le même GPU laissé allumé en permanence coûterait ~248 $/mois. À l'usage — un +livre de temps en temps — la location à la demande revient donc environ deux +cents fois moins cher, et c'est le volume persistant qui la rend praticable. diff --git a/scripts/cloud_setup.sh b/scripts/cloud_setup.sh index db5f1f29..24d07e33 100644 --- a/scripts/cloud_setup.sh +++ b/scripts/cloud_setup.sh @@ -13,16 +13,31 @@ # bash scripts/cloud_setup.sh # # Environment: -# VOXCPM_DIR where to install (default: ~/voxcpm) +# VOXCPM_DIR where to install (default: ~/voxcpm, or /workspace/voxcpm) # VOXCPM_BRANCH branch to check out (default: claude/repo-analysis-improvement-dg0ies) # VOXCPM_REPO repository to clone (default: this fork) +# HF_HOME where the model is cached (default: beside the install) # SKIP_MODEL=1 do not pre-download the model set -euo pipefail -DIR="${VOXCPM_DIR:-$HOME/voxcpm}" +# A rented GPU is destroyed after every book, so the install goes on the +# persistent volume when there is one: /workspace survives the pod on RunPod +# and on most of its competitors. Nothing here is RunPod-specific beyond that +# path, and an explicit VOXCPM_DIR always wins. +if [ -n "${VOXCPM_DIR:-}" ]; then + DIR="$VOXCPM_DIR" +elif [ -d /workspace ] && [ -w /workspace ]; then + DIR=/workspace/voxcpm +else + DIR="$HOME/voxcpm" +fi BRANCH="${VOXCPM_BRANCH:-claude/repo-analysis-improvement-dg0ies}" REPO="${VOXCPM_REPO:-https://github.com/Eddyosas008/VoxCPM.git}" +# The model is 4,6 GB. Cached next to the install, it is downloaded once for +# all the pods that will ever mount this volume rather than once per book. +CACHE="${HF_HOME:-$(dirname "$DIR")/hf-cache}" + say() { printf '\n\033[1;35m==> %s\033[0m\n' "$*"; } # --- What are we on? ------------------------------------------------------- @@ -30,17 +45,29 @@ CORES="$(nproc)" RAM_MB="$(awk '/MemTotal/ {print int($2/1024)}' /proc/meminfo)" if command -v nvidia-smi >/dev/null 2>&1 && nvidia-smi -L >/dev/null 2>&1; then GPU="$(nvidia-smi --query-gpu=name --format=csv,noheader | head -1)" - TORCH_INDEX="https://download.pytorch.org/whl/cu124" DEVICE="cuda" + # Blackwell (compute capability 10.x and 12.x — RTX 5090, B200) has no + # kernels in the cu124 wheels: torch imports, sees the card, and fails at + # the first matmul with "no kernel image is available". Ask the driver + # rather than maintaining a list of card names. + CAP="$(nvidia-smi --query-gpu=compute_cap --format=csv,noheader 2>/dev/null | head -1 | tr -d ' ')" + if [ "${CAP%%.*}" -ge 10 ] 2>/dev/null; then + TORCH_INDEX="https://download.pytorch.org/whl/cu128" + else + TORCH_INDEX="https://download.pytorch.org/whl/cu124" + fi else GPU="" + CAP="" # The CPU wheels are a fraction of the size of the CUDA ones, and on a box # without a GPU the CUDA extras are several gigabytes of dead weight. TORCH_INDEX="https://download.pytorch.org/whl/cpu" DEVICE="cpu" fi -say "Machine : ${CORES} cœur(s), ${RAM_MB} Mo de RAM, ${GPU:-pas de GPU} → device=${DEVICE}" +say "Machine : ${CORES} cœur(s), ${RAM_MB} Mo de RAM, ${GPU:-pas de GPU}${CAP:+ (cc ${CAP})} → device=${DEVICE}" +echo " installation : $DIR" +echo " cache modèle : $CACHE" if [ "$DEVICE" = "cpu" ] && [ "$RAM_MB" -lt 12000 ]; then # float32 weights need about 8.7 GB resident, and the load is where it dies. @@ -88,8 +115,19 @@ say "Dépendances du projet" pip install --quiet -e . # --- The model ------------------------------------------------------------ +# Written down rather than merely exported, because the narration command runs +# in a later shell — often days later, on a pod that did not run this script. +mkdir -p "$CACHE" +cat > "$DIR/env.sh" < -Port " +fi + # --- Ready ---------------------------------------------------------------- say "Prêt" cat < narration.log 2>&1 & @@ -113,15 +178,15 @@ cat < # et ouvrez http://127.0.0.1:8808 # Ou exposée, avec mot de passe obligatoire : - VOXCPM_AUTH='edwin:motdepasse' ./.venv/bin/python app.py \\ + VOXCPM_AUTH='edwin:motdepasse' python app.py \\ --host 0.0.0.0 --port 8808 --device $DEVICE --no-denoiser - # Rapatrier les chapitres finis, depuis votre poste : - rsync -avz root@:$DIR/output/book_/ ./book_/ + # Rapatrier les chapitres finis, depuis votre poste (Windows) : + pwsh scripts/gpu_session.ps1 pull -RemoteHost -Port -Book EOF diff --git a/scripts/gpu_session.ps1 b/scripts/gpu_session.ps1 new file mode 100644 index 00000000..47036074 --- /dev/null +++ b/scripts/gpu_session.ps1 @@ -0,0 +1,136 @@ +<# +.SYNOPSIS + Les deux transferts d'une session de narration sur GPU loué. + +.DESCRIPTION + Un GPU loué à l'heure est créé pour un livre puis détruit. Il manque donc, + à chaque fois, exactement deux choses que `git clone` ne fournit pas : + + - les références des voix clonées, `assets/voices/`, volontairement hors + du dépôt parce que ce sont des enregistrements de personnes réelles et + que le dépôt est public ; + - le livre à narrer. + + Et à la fin, il faut rapatrier les chapitres avant de détruire la machine. + + `rsync`, que documentait CLOUD.md, n'existe pas sur Windows. `scp` si — + il est livré avec OpenSSH depuis Windows 10 — et suffit largement pour + 4,6 Mo de références et quelques centaines de Mo de chapitres. + +.PARAMETER Action + push envoie les voix clonées (et, avec -Book, le livre à narrer) + pull rapatrie les chapitres produits dans output/ + tunnel ouvre l'interface Gradio distante sur http://127.0.0.1:8808 + sans exposer le port de la machine louée + +.EXAMPLE + ./scripts/gpu_session.ps1 push -RemoteHost 194.26.196.4 -Port 22077 -Book 'C:\livres\autour_de_la_lune.epub' + ./scripts/gpu_session.ps1 tunnel -RemoteHost 194.26.196.4 -Port 22077 + ./scripts/gpu_session.ps1 pull -RemoteHost 194.26.196.4 -Port 22077 -Name autour_de_la_lune +#> +[CmdletBinding()] +param( + [Parameter(Mandatory, Position = 0)] + [ValidateSet('push', 'pull', 'tunnel')] + [string]$Action, + + [Parameter(Mandatory)] + [string]$RemoteHost, + + [int]$Port = 22, + [string]$User = 'root', + + # Là où cloud_setup.sh installe quand il trouve un volume persistant. + [string]$RemoteDir = '/workspace/voxcpm', + + # push : le livre à narrer. pull : le nom du dossier sous output/. + [string]$Book, + [string]$Name, + + [string]$IdentityFile, + [int]$LocalPort = 8808 +) + +$ErrorActionPreference = 'Stop' + +if (-not (Get-Command scp -ErrorAction SilentlyContinue)) { + throw "scp introuvable. Installez OpenSSH : Add-WindowsCapability -Online -Name OpenSSH.Client~~~~0.0.1.0" +} + +$repo = Split-Path $PSScriptRoot -Parent +$target = "$User@$RemoteHost" + +# Les options communes, construites une fois. -P pour scp, -p pour ssh : la +# différence de casse est une vraie source d'erreurs, elle est isolée ici. +$scpOpts = @('-P', $Port) +$sshOpts = @('-p', $Port) +if ($IdentityFile) { + $scpOpts += @('-i', $IdentityFile) + $sshOpts += @('-i', $IdentityFile) +} + +function Invoke-Checked { + param([string]$Exe, [string[]]$Arguments, [string]$What) + Write-Host " $Exe $($Arguments -join ' ')" -ForegroundColor DarkGray + & $Exe @Arguments + if ($LASTEXITCODE -ne 0) { throw "$What a échoué (code $LASTEXITCODE)." } +} + +switch ($Action) { + + 'push' { + $voices = Join-Path $repo 'assets\voices' + if (-not (Test-Path $voices)) { throw "Introuvable : $voices" } + + $wavs = Get-ChildItem $voices -Filter *.wav + $mo = [math]::Round((($wavs | Measure-Object Length -Sum).Sum) / 1MB, 1) + Write-Host "Voix clonées : $($wavs.Count) fichiers, $mo Mo" -ForegroundColor Cyan + + # Le dossier distant existe (il vient du dépôt), mais pas forcément si + # l'installation a été déplacée — et scp ne le crée pas. + Invoke-Checked ssh ($sshOpts + @($target, "mkdir -p '$RemoteDir/assets/voices'")) 'mkdir distant' + Invoke-Checked scp ($scpOpts + @('-r', "$voices\*", "${target}:$RemoteDir/assets/voices/")) 'Envoi des voix' + + # preset_voices.json est versionné, donc déjà à jour côté distant après + # un clone — mais pas si vous venez d'ajouter une voix sans committer. + $conf = Join-Path $repo 'conf\preset_voices.json' + Invoke-Checked scp ($scpOpts + @($conf, "${target}:$RemoteDir/conf/preset_voices.json")) 'Envoi de preset_voices.json' + + if ($Book) { + if (-not (Test-Path $Book)) { throw "Livre introuvable : $Book" } + $leaf = Split-Path $Book -Leaf + Invoke-Checked scp ($scpOpts + @($Book, "${target}:$RemoteDir/$leaf")) 'Envoi du livre' + Write-Host "" + Write-Host "Sur la machine louée :" -ForegroundColor Green + Write-Host @" + source $RemoteDir/env.sh + nohup python scripts/narrate_book.py '$leaf' --device cuda \ + --voice 'Aurore — livre audio' --assemble m4b --export-acx \ + > narration.log 2>&1 & +"@ + } + } + + 'pull' { + if (-not $Name) { throw "Précisez -Name (le dossier sous output/)." } + + $localOut = Join-Path $repo 'output' + if (-not (Test-Path $localOut)) { New-Item -ItemType Directory $localOut | Out-Null } + + $remote = "$RemoteDir/output/book_$Name" + Invoke-Checked ssh ($sshOpts + @($target, "test -d '$remote'")) "Le dossier distant $remote n'existe pas ; vérifiez -Name" + Invoke-Checked scp ($scpOpts + @('-r', "${target}:$remote", $localOut)) 'Rapatriement' + + $got = Join-Path $localOut "book_$Name" + $n = (Get-ChildItem $got -Recurse -File | Measure-Object).Count + Write-Host "" + Write-Host "$n fichiers dans $got" -ForegroundColor Green + Write-Host "Vérifiez avant de détruire la machine — elle est facturée tant qu'elle existe." -ForegroundColor Yellow + } + + 'tunnel' { + Write-Host "Tunnel ouvert : http://127.0.0.1:$LocalPort" -ForegroundColor Green + Write-Host "Rien n'est exposé sur la machine louée. Ctrl+C pour fermer." -ForegroundColor DarkGray + & ssh @sshOpts -N -L "${LocalPort}:127.0.0.1:8808" $target + } +} From 7634a661d6b371c8ebd740bc4c41a4832c7e59ba Mon Sep 17 00:00:00 2001 From: PaxHelios Date: Fri, 7 Aug 2026 23:05:20 +0200 Subject: [PATCH 47/98] feat(queue): narrate a catalogue unattended, from Markdown to delivery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Twenty books is sixty-five hours of GPU — two and a half days during which nobody is watching. Two pieces were missing for that to be safe. A manuscript is written to be seen. Its first pages carry an ISBN, a copyright notice and a table of contents, none of which a narrator reads and all of which an engine reads aloud. Its body carries asterisks that are silent on a page and absurd in an ear. Worse, it uses `---` as a horizontal rule, sprinkled through the front matter — and that is exactly the chapter separator narrate_book.py keys on, so the naive conversion cuts the book at the copyright page. prepare_manuscript.py therefore removes every rule and re-emits `---` only at boundaries it decides itself, then asserts the count matches. Front matter goes except the blocks worth keeping, which are named rather than guessed, and nothing leaves silently: every dropped block is reported. Measured on DORMIR: 18 chapters, 221k characters, no markdown residue, contents page gone, dedication and medical disclaimer kept. narrate_queue.py runs the list. A book that fails does not stop the others — its cause is recorded and the queue moves on, because nothing costs more than twenty books stalled at hour three by the second one. A finished book is skipped and an interrupted one resumes at its segment, so relaunching after an outage costs only what was missing. Repair is part of the render rather than an afterthought: fatally defective segments are regenerated one at a time with a derived seed and their chapter restitched from cache. The fatal-segment detector matches "FATAL " case-sensitively. Case-insensitive would also match repair_segment.py's own "Aucun segment fatalement défectueux", so every healthy book would report a defect and load the model to fix nothing. Console output is forced to UTF-8: chapter titles carry whatever the book does, and a Windows console defaults to cp1252, where an arrow raises rather than prints. A summary must never kill a conversion that worked. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FBNmTCfFPDth6cWqg69MGu --- scripts/narrate_queue.py | 153 +++++++++++++++++++++++ scripts/prepare_manuscript.py | 224 ++++++++++++++++++++++++++++++++++ 2 files changed, 377 insertions(+) create mode 100644 scripts/narrate_queue.py create mode 100644 scripts/prepare_manuscript.py diff --git a/scripts/narrate_queue.py b/scripts/narrate_queue.py new file mode 100644 index 00000000..3249122f --- /dev/null +++ b/scripts/narrate_queue.py @@ -0,0 +1,153 @@ +"""Narrer une file de livres, sans surveillance, et se relever tout seul. + +Un livre demande trois heures de GPU. Vingt en demandent soixante-cinq, soit +deux jours et demi pendant lesquels personne ne regarde. Ce script existe pour +que cette absence ne coûte rien. + +Trois principes, tous appris à la dure : + +**Un livre qui échoue n'arrête pas les autres.** Sa cause est notée, il est +marqué en échec, on passe au suivant. Rien n'est plus coûteux qu'une file de +vingt livres bloquée à la troisième heure par le second. + +**Rien n'est refait.** Un livre déjà terminé est sauté ; un livre interrompu +reprend au segment près, parce que le cache de ``narrate_book.py`` survit à +tout. Relancer ce script après une coupure ne coûte que ce qui manquait. + +**La réparation fait partie du rendu, pas de l'après.** Une fois le livre +narré, les segments jugés fatals — silencieux, tronqués, emballés — sont +re-générés un par un avec un seed dérivé, et leur chapitre est recousu depuis +le cache. Renarrer le chapitre entier pour une phrase serait absurde. + + python scripts/narrate_queue.py queue/queue.json --device cuda +""" +from __future__ import annotations + +import argparse +import json +import pathlib +import subprocess +import sys +import time + +REPO = pathlib.Path(__file__).resolve().parent.parent +PYTHON = sys.executable + + +def log(msg: str) -> None: + print(f"[{time.strftime('%H:%M:%S')}] {msg}", flush=True) + + +def run(cmd: list[str], logfile: pathlib.Path | None = None) -> tuple[int, str]: + """Lancer une étape. Sa sortie va dans un fichier, pas en mémoire.""" + if logfile: + with logfile.open("a", encoding="utf-8") as fh: + fh.write(f"\n$ {' '.join(cmd)}\n") + p = subprocess.run(cmd, stdout=fh, stderr=subprocess.STDOUT, cwd=REPO) + tail = logfile.read_text(encoding="utf-8", errors="replace")[-600:] + return p.returncode, tail + p = subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8", + errors="replace", cwd=REPO) + return p.returncode, (p.stdout or "") + (p.stderr or "") + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("queue", help="queue.json") + ap.add_argument("--device", default="cuda") + ap.add_argument("--outroot", default="output") + ap.add_argument("--qc-retries", default="2") + ap.add_argument("--only", type=int, help="ne traiter que les N premiers") + ap.add_argument("--skip-repair", action="store_true") + args = ap.parse_args() + + qpath = pathlib.Path(args.queue).resolve() + books = json.loads(qpath.read_text(encoding="utf-8")) + if args.only: + books = books[: args.only] + + state_path = qpath.parent / "state.json" + state = json.loads(state_path.read_text(encoding="utf-8")) if state_path.is_file() else {} + + def save() -> None: + state_path.write_text(json.dumps(state, ensure_ascii=False, indent=2), encoding="utf-8") + + logdir = qpath.parent / "logs" + logdir.mkdir(exist_ok=True) + + log(f"file de {len(books)} livre(s) — {sum(b['chars'] for b in books)} caractères") + + for i, b in enumerate(books, 1): + slug = b["slug"] + st = state.get(slug, {}) + if st.get("status") == "done": + log(f"[{i}/{len(books)}] {slug} : déjà terminé, sauté") + continue + + txt = qpath.parent / b["txt"] + outdir = pathlib.Path(args.outroot) / f"book_{slug.replace('-', '_')}" + blog = logdir / f"{slug}.log" + state[slug] = {"status": "running", "started": time.strftime("%Y-%m-%d %H:%M:%S"), + "voice": b["voice"], "chars": b["chars"]} + save() + + log(f"[{i}/{len(books)}] {slug} — {b['chars']} car., voix « {b['voice']} »") + + # Pré-vol : il ne charge pas le modèle, donc il coûte des secondes et + # attrape ce qui ferait échouer trois heures plus tard. + rc, out = run([PYTHON, "scripts/narrate_book.py", str(txt), "--voice", b["voice"], + "--device", args.device, "--outdir", str(outdir), "--dry-run"]) + if rc != 0: + log(f" pré-vol refusé — livre écarté") + state[slug].update(status="failed", stage="dry-run", detail=out[-400:]) + save() + continue + + t0 = time.time() + rc, tail = run([PYTHON, "scripts/narrate_book.py", str(txt), "--voice", b["voice"], + "--device", args.device, "--outdir", str(outdir), + "--qc-retries", args.qc_retries, + "--assemble", "m4b", "--export-acx"], blog) + mins = (time.time() - t0) / 60 + if rc != 0: + log(f" narration échouée après {mins:.0f} min — voir {blog.name}") + state[slug].update(status="failed", stage="narration", minutes=round(mins, 1), + detail=tail[-400:]) + save() + continue + log(f" narré en {mins:.0f} min") + + # Réparation ciblée : un segment fatal ne justifie pas de refaire son + # chapitre, encore moins le livre. + repaired = 0 + if not args.skip_repair: + rc, out = run([PYTHON, "scripts/repair_segment.py", str(outdir), "--list"]) + # Le marqueur est « FATAL » en capitales (repair_segment.py:68). + # Chercher « fatal » sans distinction de casse compterait aussi la + # phrase « Aucun segment fatalement défectueux », donc trouverait un + # défaut dans tout livre sain et chargerait le modèle pour rien. + fatal = sum(1 for line in out.splitlines() if "FATAL " in line) + if fatal: + log(f" {fatal} segment(s) fatal(s) — réparation") + rc, out = run([PYTHON, "scripts/repair_segment.py", str(outdir), + "--all-fatal", "--device", args.device], blog) + repaired = fatal + if rc != 0: + log(f" réparation incomplète (code {rc})") + + wavs = len(list(outdir.glob("*.wav"))) if outdir.is_dir() else 0 + state[slug].update(status="done", minutes=round(mins, 1), chapters_wav=wavs, + repaired=repaired, finished=time.strftime("%Y-%m-%d %H:%M:%S")) + save() + log(f" terminé — {wavs} chapitre(s), {repaired} segment(s) réparé(s)") + + done = sum(1 for v in state.values() if v.get("status") == "done") + failed = [k for k, v in state.items() if v.get("status") == "failed"] + log(f"file terminée — {done} livre(s) produits, {len(failed)} en échec") + for k in failed: + log(f" échec : {k} ({state[k].get('stage')})") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/prepare_manuscript.py b/scripts/prepare_manuscript.py new file mode 100644 index 00000000..6fba7f4f --- /dev/null +++ b/scripts/prepare_manuscript.py @@ -0,0 +1,224 @@ +"""Turn a Markdown manuscript into text a narrator can read aloud. + +A manuscript is written to be *seen*. Its first pages carry an ISBN, a +copyright notice, a table of contents and a web address — all of which a +narrator would never read out, and all of which a TTS engine reads out +happily. Its body carries asterisks for emphasis and hashes for headings, +which are silent on a page and absurd in an ear. + +Three traps, in order of how much damage they do: + +1. **``---`` means two different things.** In the manuscript it is a + horizontal rule, sprinkled through the front matter. To ``narrate_book.py`` + it is *the* chapter separator. Converting naively cuts the book at the + copyright page. So every rule is removed, and ``---`` is re-emitted only at + the boundaries this script decides. +2. **Front matter is not narration.** Everything before the first content + heading goes, except the blocks worth keeping (a disclaimer, a dedication), + which are named rather than guessed. +3. **Tables cannot be read.** A Markdown table spoken aloud is a stream of + pipes. They are dropped, and counted. + +Nothing is removed silently: ``--report`` prints every dropped block, because +text taken out of a book has to be reported back to whoever asked for it. + + python scripts/prepare_manuscript.py manuscrit_complet.md -o livre.txt --report +""" +from __future__ import annotations + +import argparse +import pathlib +import re +import sys +from dataclasses import dataclass, field +from typing import List + +# A heading that opens something a narrator actually reads. Anything before the +# first of these is front matter: title page, copyright, ISBN, contents. +CONTENT_HEADING = re.compile( + r"^\s*(introduction|chapitre|partie|prologue|pr[ée]face|avant[- ]propos" + r"|conclusion|[ée]pilogue|annexe|postface)\b", + re.IGNORECASE, +) + +# Front-matter blocks worth keeping anyway. A disclaimer carries legal weight +# and a dedication is read in most audiobooks; a copyright page is neither. +KEEP_IN_FRONT = re.compile(r"^\s*(avertissement|d[ée]dicace|note de l['’]auteur)\b", re.IGNORECASE) + +# Blocks to drop even when they sit in the body. +DROP_ALWAYS = re.compile(r"^\s*(table des mati[èe]res|sommaire|remerciements?|bibliographie|index)\b", re.IGNORECASE) + +# A part divider carries no prose of its own — it must not become a chapter of +# two words, it belongs to the chapter that follows. +PART_HEADING = re.compile(r"^\s*partie\b", re.IGNORECASE) + + +@dataclass +class Block: + """A heading and the prose under it, down to the next heading of any level.""" + + level: int + title: str + lines: List[str] = field(default_factory=list) + + @property + def text(self) -> str: + return "\n".join(self.lines).strip() + + +def strip_inline(text: str) -> str: + """Remove the marks that are silent on a page and spoken by an engine.""" + text = re.sub(r"!\[[^\]]*\]\([^)]*\)", "", text) # images: nothing to say + text = re.sub(r"\[([^\]]+)\]\([^)]*\)", r"\1", text) # links: keep the words + text = re.sub(r"`{1,3}([^`]*)`{1,3}", r"\1", text) # inline code + text = re.sub(r"\*\*\*([^*]+)\*\*\*", r"\1", text) + text = re.sub(r"\*\*([^*]+)\*\*", r"\1", text) + text = re.sub(r"(?\s?", "", text) # blockquote marker + text = re.sub(r"^\s{0,3}[-*+]\s+", "", text) # bullet + text = re.sub(r"^\s{0,3}\d+[.)]\s+", "", text) # numbered item + return text.strip() + + +def parse(md: str) -> tuple[List[Block], List[str]]: + """Split Markdown into heading-led blocks, dropping what cannot be spoken.""" + removed: List[str] = [] + blocks: List[Block] = [Block(0, "")] + in_fence = False + in_table = False + + for raw in md.splitlines(): + if raw.strip().startswith("```"): + in_fence = not in_fence + if in_fence: + removed.append("bloc de code") + continue + if in_fence: + continue + + # A table row, and the ---|--- rule under it, are unreadable aloud. + if re.match(r"^\s*\|", raw) or re.match(r"^\s*\|?[\s:-]*\|[\s:|-]*$", raw) and "|" in raw: + if not in_table: + removed.append("tableau") + in_table = True + continue + in_table = False + + heading = re.match(r"^(#{1,6})\s+(.*)$", raw) + if heading: + blocks.append(Block(len(heading.group(1)), strip_inline(heading.group(2)))) + continue + + # Horizontal rules only ever meant "new visual section"; the chapter + # boundaries this script emits are decided from the headings instead. + if re.match(r"^\s*([-*_])\1{2,}\s*$", raw): + continue + + blocks[-1].lines.append(strip_inline(raw)) + + return [b for b in blocks if b.title or b.text], removed + + +def to_chapters(blocks: List[Block]) -> tuple[List[str], List[str]]: + """Group blocks into chapters, and say what was left out.""" + removed: List[str] = [] + + start = next((i for i, b in enumerate(blocks) if CONTENT_HEADING.match(b.title)), None) + if start is None: + # No recognisable structure — narrate the whole thing as one chapter + # rather than refuse. Better a long chapter than no book. + body = "\n\n".join(b.text for b in blocks if b.text) + return ([body] if body else []), removed + + kept_front = [] + for b in blocks[:start]: + if KEEP_IN_FRONT.match(b.title): + kept_front.append(b) + elif b.title or b.text: + removed.append(f"liminaire : {b.title or b.text[:40]}…") + + chapters: List[str] = [] + current: List[str] = [] + + for b in kept_front: + chapters.append("\n\n".join(x for x in (b.title, b.text) if x)) + + for b in blocks[start:]: + if DROP_ALWAYS.match(b.title): + removed.append(f"section : {b.title}") + continue + # A new chapter opens on a level-1 heading — except a part divider, + # which introduces the chapter after it rather than standing alone. + if b.level == 1 and not PART_HEADING.match(b.title): + if current: + chapters.append("\n\n".join(current).strip()) + current = [] + piece = "\n\n".join(x for x in (b.title, b.text) if x) + if piece: + current.append(piece) + + if current: + chapters.append("\n\n".join(current).strip()) + + return [c for c in chapters if c.strip()], removed + + +def main() -> int: + # The report names chapters, so it carries whatever the book does — and a + # Windows console defaults to cp1252, where an em dash or an arrow raises + # rather than prints. Never let the summary kill a conversion that worked. + for stream in (sys.stdout, sys.stderr): + try: + stream.reconfigure(encoding="utf-8", errors="replace") + except (AttributeError, ValueError): + pass + + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("manuscript", help="fichier Markdown") + ap.add_argument("-o", "--output", help="fichier .txt de sortie (défaut : à côté du manuscrit)") + ap.add_argument("--report", action="store_true", help="détailler ce qui a été retiré") + args = ap.parse_args() + + src = pathlib.Path(args.manuscript) + if not src.is_file(): + print(f"introuvable : {src}", file=sys.stderr) + return 1 + + md = src.read_text(encoding="utf-8", errors="replace") + blocks, removed_parse = parse(md) + chapters, removed_struct = to_chapters(blocks) + + if not chapters: + print("aucun texte narrable trouvé", file=sys.stderr) + return 1 + + out = pathlib.Path(args.output) if args.output else src.with_suffix(".narration.txt") + body = "\n\n---\n\n".join(chapters) + # The separator must be unambiguous: it is the one thing narrate_book.py + # keys on, so no stray rule may survive anywhere else in the file. + assert body.count("\n---\n") == len(chapters) - 1, "séparateur ambigu" + out.write_text(body + "\n", encoding="utf-8") + + chars = sum(len(c) for c in chapters) + print(f"{len(chapters)} chapitre(s) · {chars} caractères · ~{chars/15/60:.0f} min → {out.name}") + for i, c in enumerate(chapters, 1): + print(f" {i:>3}. {c.splitlines()[0][:62]:<62} {len(c):>7} car.") + + counts: dict[str, int] = {} + for r in removed_parse: + counts[r] = counts.get(r, 0) + 1 + if counts or removed_struct: + print("\nRetiré :") + for k, n in sorted(counts.items()): + print(f" {n:>3} × {k}") + if args.report: + for r in removed_struct: + print(f" {r}") + else: + print(f" {len(removed_struct)} bloc(s) liminaire(s)/section(s) — --report pour le détail") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 506ca5a826f011af42025dd8d9a262f9a104448b Mon Sep 17 00:00:00 2001 From: PaxHelios Date: Fri, 7 Aug 2026 23:24:18 +0200 Subject: [PATCH 48/98] fix(queue): a full disk must not cost nineteen books MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pod's volume is 30 GB and the install already claims 11 of them. One book leaves about 3 GB of chapter WAV behind, so the fourth one fills the volume — and a queue that dies of a full disk reports nineteen narration failures for a cause that has nothing to do with narration. Two guards. Before each book, five gigabytes must be free or the run stops cleanly and says where it stopped; the queue resumes there, so the cost is a relaunch rather than a night. After each book, `--keep deliverables` removes the chapter WAV and the segment cache — but only once the M4B and the ACX export exist on disk, so the deliverable is never traded for the master it was built from. Measured on the book in flight: 12 chapters at ~110 MB, 2.3 GB of output, model and venv 11 GB, 16 GB free. Twenty books need the sweep. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FBNmTCfFPDth6cWqg69MGu --- scripts/narrate_queue.py | 38 +++++++++++++++++++++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/scripts/narrate_queue.py b/scripts/narrate_queue.py index 3249122f..b51c6fa4 100644 --- a/scripts/narrate_queue.py +++ b/scripts/narrate_queue.py @@ -24,6 +24,7 @@ from __future__ import annotations import argparse +import shutil import json import pathlib import subprocess @@ -59,6 +60,10 @@ def main() -> int: ap.add_argument("--qc-retries", default="2") ap.add_argument("--only", type=int, help="ne traiter que les N premiers") ap.add_argument("--skip-repair", action="store_true") + ap.add_argument("--keep", choices=("all", "deliverables"), default="all", + help="all : tout garder. deliverables : ne garder que le M4B, " + "l'export ACX et le rapport, et effacer les WAV de chapitre " + "une fois le livre assemblé (un livre pèse ~3 Go de WAV)") args = ap.parse_args() qpath = pathlib.Path(args.queue).resolve() @@ -84,6 +89,15 @@ def save() -> None: log(f"[{i}/{len(books)}] {slug} : déjà terminé, sauté") continue + # Trois heures de narration meurent mal sur un disque plein : le livre + # est perdu et le suivant l'est aussi. S'arrêter avant coûte une + # relance, pas une nuit. + free_gb = shutil.disk_usage(REPO).free / 1e9 + if free_gb < 5: + log(f"seulement {free_gb:.1f} Go libres — arrêt avant {slug}") + log(" rapatriez les livres produits, puis relancez : la file reprend ici") + break + txt = qpath.parent / b["txt"] outdir = pathlib.Path(args.outroot) / f"book_{slug.replace('-', '_')}" blog = logdir / f"{slug}.log" @@ -136,8 +150,30 @@ def save() -> None: log(f" réparation incomplète (code {rc})") wavs = len(list(outdir.glob("*.wav"))) if outdir.is_dir() else 0 + + # Un livre laisse ~3 Go de WAV de chapitre derrière lui. Vingt livres + # saturent le volume au quatrième, et une file qui meurt d'un disque + # plein a produit dix-neuf échecs pour une cause qui n'a rien à voir + # avec la narration. Les WAV ne partent qu'une fois le M4B et l'export + # ACX écrits : le livrable existe avant que le master ne disparaisse. + freed = 0 + if args.keep == "deliverables" and wavs: + m4b = list(outdir.glob("*.m4b")) + list(outdir.glob("*.m4a")) + acx = outdir / "acx" + if m4b and acx.is_dir() and any(acx.iterdir()): + for w in outdir.glob("*.wav"): + freed += w.stat().st_size + w.unlink() + for cache in outdir.rglob("seg_*.wav"): + freed += cache.stat().st_size + cache.unlink() + log(f" {freed/1e9:.1f} Go de WAV effacés (M4B et ACX conservés)") + else: + log(f" WAV conservés : M4B ou export ACX manquant, rien n'est effacé") + state[slug].update(status="done", minutes=round(mins, 1), chapters_wav=wavs, - repaired=repaired, finished=time.strftime("%Y-%m-%d %H:%M:%S")) + repaired=repaired, freed_gb=round(freed / 1e9, 2), + finished=time.strftime("%Y-%m-%d %H:%M:%S")) save() log(f" terminé — {wavs} chapitre(s), {repaired} segment(s) réparé(s)") From 4d7c802db9a14de04d6d12ee6b29d097b1de5104 Mon Sep 17 00:00:00 2001 From: PaxHelios Date: Fri, 7 Aug 2026 23:54:07 +0200 Subject: [PATCH 49/98] fix(assemble): a French title must not destroy a book already narrated MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four hours of narration finished, sixteen chapters written, the M4B assembled — and then the run died decoding ffmpeg's own output: UnicodeDecodeError: 'ascii' codec can't decode byte 0xc3 decoding with 'ANSI_X3.4-1968' codec failed ffmpeg echoes the chapter titles back on stderr, so its output carries whatever the book is called. `subprocess.run(..., text=True)` with no `encoding=` decodes with the locale's preferred encoding, and a process started through `ssh host 'command'` inherits a non-interactive shell where LANG is unset — Python resolves that to ASCII. Every accented title is therefore a crash, which means every French book, at the last step, after the expensive part succeeded. Both call sites now name their encoding: assembly, and the ACX export that would have failed identically one step later. narrate_queue.py additionally forces PYTHONIOENCODING and LANG for its children, because export_acx.py prints accented French to stdout and that path raises on encode rather than decode — the belt covers the sites this audit may have missed. Found on the first book of a twenty-book batch. Left alone it would have cost nineteen more. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FBNmTCfFPDth6cWqg69MGu --- narration/assemble.py | 8 +++++++- scripts/export_acx.py | 5 ++++- scripts/narrate_queue.py | 22 ++++++++++++++++++++-- 3 files changed, 31 insertions(+), 4 deletions(-) diff --git a/narration/assemble.py b/narration/assemble.py index 5b625f0a..3529570b 100644 --- a/narration/assemble.py +++ b/narration/assemble.py @@ -315,7 +315,13 @@ def assemble( ) return result - completed = subprocess.run(command, capture_output=True, text=True) + # ffmpeg echoes the chapter titles back on stderr, so its output carries + # whatever the book is called. `text=True` alone decodes with the locale's + # preferred encoding, and a server shell without LANG resolves that to + # ASCII — so a French title raises UnicodeDecodeError and loses a book that + # was already fully narrated. Name the encoding rather than inherit it. + completed = subprocess.run(command, capture_output=True, text=True, + encoding="utf-8", errors="replace") if completed.returncode != 0: result.pending_command = command tail = (completed.stderr or "").strip().splitlines()[-3:] diff --git a/scripts/export_acx.py b/scripts/export_acx.py index e808ed1c..870d8cd1 100644 --- a/scripts/export_acx.py +++ b/scripts/export_acx.py @@ -144,7 +144,10 @@ def encode(wav_path: Path, out_path: Path, ffmpeg: Optional[str]) -> Tuple[bool, if not ffmpeg: return False, command command[0] = ffmpeg - result = subprocess.run(command, capture_output=True, text=True) + # Comme à l'assemblage : ffmpeg renvoie les métadonnées du livre sur sa + # sortie d'erreur, et un shell sans LANG fait retomber Python sur l'ASCII. + result = subprocess.run(command, capture_output=True, text=True, + encoding="utf-8", errors="replace") if result.returncode != 0: print(f" échec de l'encodage : {result.stderr.strip().splitlines()[-1:]}") return False, command diff --git a/scripts/narrate_queue.py b/scripts/narrate_queue.py index b51c6fa4..8879216a 100644 --- a/scripts/narrate_queue.py +++ b/scripts/narrate_queue.py @@ -24,6 +24,7 @@ from __future__ import annotations import argparse +import os import shutil import json import pathlib @@ -39,16 +40,33 @@ def log(msg: str) -> None: print(f"[{time.strftime('%H:%M:%S')}] {msg}", flush=True) +def child_env() -> dict: + """L'environnement des étapes, forcé en UTF-8. + + Lancé par ``ssh machine 'commande'``, le runner hérite d'un shell non + interactif où ``LANG`` n'est pas défini. Python y résout alors l'encodage + préféré en ASCII, et le premier titre de chapitre accentué fait tomber une + étape — après trois heures de narration réussie. Les appels sensibles + nomment déjà leur encodage ; ceci couvre ceux qu'on aurait manqués. + """ + env = dict(os.environ) + env.setdefault("PYTHONIOENCODING", "utf-8") + env.setdefault("LANG", "C.UTF-8") + env.setdefault("LC_ALL", "C.UTF-8") + return env + + def run(cmd: list[str], logfile: pathlib.Path | None = None) -> tuple[int, str]: """Lancer une étape. Sa sortie va dans un fichier, pas en mémoire.""" if logfile: with logfile.open("a", encoding="utf-8") as fh: fh.write(f"\n$ {' '.join(cmd)}\n") - p = subprocess.run(cmd, stdout=fh, stderr=subprocess.STDOUT, cwd=REPO) + p = subprocess.run(cmd, stdout=fh, stderr=subprocess.STDOUT, + cwd=REPO, env=child_env()) tail = logfile.read_text(encoding="utf-8", errors="replace")[-600:] return p.returncode, tail p = subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8", - errors="replace", cwd=REPO) + errors="replace", cwd=REPO, env=child_env()) return p.returncode, (p.stdout or "") + (p.stderr or "") From caba0bb89469d79fcca00e0a1aa1e1ca60cd9901 Mon Sep 17 00:00:00 2001 From: PaxHelios Date: Sat, 8 Aug 2026 00:07:02 +0200 Subject: [PATCH 50/98] fix(chunking): two characters are debris, not a sentence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chapter 3 of a book narrated tonight ended on `-e` — a hyphenated word cut at an EPUB file boundary. Sent to the engine alone, two characters do not produce silence: they produce 0.6s of babbling where 0.1s was expected, which the quality pass correctly calls fatal. Regenerating cannot fix it, and measuring that was the useful part: the re-roll returned 1.6s of babbling instead of 0.6s, and repair_segment kept the older take. The fault is the fragment, not the take, so no seed will ever help. Segments carrying fewer than three letters or digits are now folded into their neighbour — the one before, or the one after when the debris leads. The words are read in the same order either way, so the merge costs nothing, and it removes the class rather than the instance: across twenty books this would otherwise have been twenty blips reported as twenty fatal defects. A fragment with no neighbour at all is kept. A defect that is reported beats text quietly deleted from a book. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FBNmTCfFPDth6cWqg69MGu --- narration/chunking.py | 56 +++++++++++++++++++++++++++++++- tests/test_narration_chunking.py | 34 +++++++++++++++++++ 2 files changed, 89 insertions(+), 1 deletion(-) diff --git a/narration/chunking.py b/narration/chunking.py index cc7b3a6c..fd0b8359 100644 --- a/narration/chunking.py +++ b/narration/chunking.py @@ -121,6 +121,60 @@ def split_text_into_chunks(text: str, max_chars: int = DEFAULT_MAX_CHARS) -> Lis return _pack_sentences(text, max_chars) +#: A segment carrying fewer speakable characters than this is not a sentence — +#: it is debris. Two is the smallest useful sentence in French ("Si.", "Va.") +#: once punctuation is discounted, so anything under three letters or digits +#: is a fragment that arrived from the source rather than from the prose. +_MIN_SPEAKABLE = 3 + + +def _speakable(text: str) -> int: + return sum(1 for c in text if c.isalnum()) + + +def _absorb_fragments(segments: List[Segment]) -> List[Segment]: + """Fold debris into its neighbour instead of sending it to the engine. + + An EPUB chapter can end on a stray ``-e``: a hyphenated word cut by the + file boundary, a stripped tag, a footnote marker. Alone, it is two + characters, and the engine given two characters does not fall silent — it + babbles for six times the expected duration, which the quality pass then + reports as a fatal defect. Regenerating never helps, because the fault is + the fragment, not the take: measured on one book, a re-roll turned 0.6s of + noise into 1.6s of it. + + Merging costs nothing — the words are read in the same order either way — + and it removes the whole class of defect rather than one instance. + """ + if len(segments) < 2: + return segments + + out: List[Segment] = [] + for seg in segments: + if _speakable(seg.text) < _MIN_SPEAKABLE and out: + previous = out[-1] + out[-1] = Segment( + text=f"{previous.text} {seg.text}".strip(), + # The fragment is now the tail, so the silence that followed it + # is the silence that follows the whole. + pause_after=seg.pause_after, + paragraph=previous.paragraph, + ) + else: + out.append(seg) + + # A leading fragment has no predecessor to join; give it its successor. + if len(out) > 1 and _speakable(out[0].text) < _MIN_SPEAKABLE: + head, following = out[0], out[1] + out[1] = Segment( + text=f"{head.text} {following.text}".strip(), + pause_after=following.pause_after, + paragraph=following.paragraph, + ) + out = out[1:] + return out + + def split_into_segments( text: str, max_chars: int = DEFAULT_MAX_CHARS, @@ -144,7 +198,7 @@ def split_into_segments( paragraph=paragraph_index, ) ) - return segments + return _absorb_fragments(segments) def split_chapters(text: str, pattern: Optional[str] = None) -> List[str]: diff --git a/tests/test_narration_chunking.py b/tests/test_narration_chunking.py index ea88fd8a..355a1346 100644 --- a/tests/test_narration_chunking.py +++ b/tests/test_narration_chunking.py @@ -99,3 +99,37 @@ def test_custom_pattern(self): @pytest.mark.parametrize("separator", ["---", " --- ", "--- "]) def test_separator_tolerates_surrounding_whitespace(self, separator): assert split_chapters(f"Un\n{separator}\nDeux") == ["Un", "Deux"] + + +class TestFragmentsAreAbsorbed: + """Un fragment de deux caractères ne doit jamais partir seul au moteur. + + Mesuré sur « Rebâtir l'Intimité Après Divorce » : un chapitre EPUB finissait + sur ``-e``, reliquat d'un mot coupé à la frontière de fichier. Le moteur, + devant deux caractères, a produit 0,6 s de babil là où 0,1 s était attendue, + que le contrôle qualité a classé fatal. Régénérer n'y change rien — le + second essai a donné 1,6 s de babil au lieu de 0,6. + """ + + def test_trailing_debris_joins_the_sentence_before_it(self): + segments = split_into_segments("Nous entrerons dans celui du corps.\n\n-e") + assert len(segments) == 1 + assert segments[0].text.endswith("-e") + + def test_leading_debris_joins_the_sentence_after_it(self): + segments = split_into_segments("»\n\nElle entra sans frapper.") + assert len(segments) == 1 + assert segments[0].text.startswith("»") + + def test_a_short_real_sentence_survives(self): + segments = split_into_segments("Oui.\n\nElle répondit enfin.") + assert [s.text for s in segments] == ["Oui.", "Elle répondit enfin."] + + def test_a_lone_fragment_is_kept_rather_than_lost(self): + # Rien à quoi le rattacher : mieux vaut un défaut signalé qu'un texte + # silencieusement supprimé du livre. + assert [s.text for s in split_into_segments("-e")] == ["-e"] + + def test_the_pause_of_the_absorbed_tail_is_the_one_kept(self): + segments = split_into_segments("Une phrase complète ici.\n\n-e") + assert segments[0].pause_after > 0 From e0a5fbcb19ce26ab4c514678eafe77d9e48427ec Mon Sep 17 00:00:00 2001 From: PaxHelios Date: Sat, 8 Aug 2026 03:36:59 +0200 Subject: [PATCH 51/98] fix(chunking): an over-long sentence is truncated, not read slowly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _pack_sentences handed any sentence longer than the limit to the engine whole, reasoning that a cut mid-clause is more audible than a slightly long segment. That reasoning assumed the engine reads the long segment. It does not: it truncates it. The numbers, from one book of 1694 segments. Defect rate by length: 4.2% under a hundred characters, 7.0% to two hundred, 7.8% to three hundred — then 20% at three-to-four hundred, and 100% on the single 679-character segment, which came back as 16.2 seconds where 34 were needed. Half the sentence was simply gone. So the trade is reversed: sentences over the limit are now cut at the places a narrator breathes — semicolon, colon, comma, dash, in that order — and only at word boundaries when a sentence offers none. A boundary is accepted only if it actually brings every piece under the limit; a sentence whose commas all sit in its first ten words is not helped by them. The separator stays attached to the clause it closes. Splitting on ", " would drop the comma, and a comma dropped is a breath the narrator no longer takes. Re-measured on the same book: 1702 segments, longest 300 characters, none above the limit. The test that asserted the old invariant is updated rather than deleted, and carries why it was reversed. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FBNmTCfFPDth6cWqg69MGu --- narration/chunking.py | 72 ++++++++++++++++++++++++++++++-- tests/test_narration_chunking.py | 50 ++++++++++++++++++++-- 2 files changed, 115 insertions(+), 7 deletions(-) diff --git a/narration/chunking.py b/narration/chunking.py index fd0b8359..afc568c0 100644 --- a/narration/chunking.py +++ b/narration/chunking.py @@ -83,12 +83,76 @@ class Segment: paragraph: int = 0 +#: Where a sentence too long to send whole may be cut, best first. A colon or a +#: semicolon already carries a pause in the reading; a comma carries a lighter +#: one; a dash lighter still. All of them are places a narrator breathes. +_CLAUSE_BOUNDARIES = ("; ", " : ", ", ", " — ", " – ") + + +def _split_long_sentence(sentence: str, max_chars: int) -> List[str]: + """Cut an over-long sentence at the places a narrator would breathe. + + This used to hand the sentence over whole, on the reasoning that a cut + mid-clause is more audible than a slightly long segment. Measurement says + otherwise: an over-long segment is not read slightly long, it is *truncated* + by the engine. Across one book, the defect rate was 4-8% below three hundred + characters, 20% between three and four hundred, and 100% on the single + 679-character segment — which came back as 679 characters in 16.2s where + 34s were needed, i.e. half the sentence simply missing. + + Half a sentence lost is worse than a comma turned into a breath. + """ + if len(sentence) <= max_chars: + return [sentence] + + for boundary in _CLAUSE_BOUNDARIES: + if boundary not in sentence: + continue + # Split so the separator stays attached to the clause it closes: + # `"a, b".split(", ")` would drop the comma, and a comma dropped is a + # breath the narrator no longer takes. + parts = re.split(f"({re.escape(boundary)})", sentence) + tokens = [ + (parts[i] + (parts[i + 1] if i + 1 < len(parts) else "")).strip() + for i in range(0, len(parts), 2) + ] + tokens = [t for t in tokens if t] + + pieces, current = [], "" + for token in tokens: + candidate = f"{current} {token}" if current else token + if current and len(candidate) > max_chars: + pieces.append(current) + current = token + else: + current = candidate + if current: + pieces.append(current) + # Only accept a boundary that actually solved the problem; a sentence + # whose commas all sit in the first ten words is not helped by them. + if pieces and all(len(p) <= max_chars for p in pieces): + return pieces + + # No usable boundary. Sending it whole loses half of it, so fall back to + # word boundaries: audible, but every word survives. + words, pieces, current = sentence.split(), [], "" + for word in words: + candidate = f"{current} {word}" if current else word + if current and len(candidate) > max_chars: + pieces.append(current) + current = word + else: + current = candidate + if current: + pieces.append(current) + return pieces or [sentence] + + def _pack_sentences(text: str, max_chars: int) -> List[str]: """Greedily pack whole sentences into chunks no longer than ``max_chars``. - A single sentence longer than the limit becomes its own chunk: splitting it - further would cut mid-clause, which is far more audible than a slightly long - segment. + A sentence longer than the limit is cut at clause boundaries rather than + sent whole — see ``_split_long_sentence`` for why that trade was reversed. """ text = (text or "").strip() if not text: @@ -101,7 +165,7 @@ def _pack_sentences(text: str, max_chars: int) -> List[str]: if current: chunks.append(current) current = "" - chunks.append(sentence) + chunks.extend(_split_long_sentence(sentence, max_chars)) elif current and len(current) + 1 + len(sentence) > max_chars: chunks.append(current) current = sentence diff --git a/tests/test_narration_chunking.py b/tests/test_narration_chunking.py index 355a1346..80ab7c82 100644 --- a/tests/test_narration_chunking.py +++ b/tests/test_narration_chunking.py @@ -23,11 +23,19 @@ def test_sentences_are_packed_up_to_the_limit(self): assert all(len(c) <= 12 for c in chunks) assert " ".join(chunks) == text - def test_no_sentence_is_ever_cut_in_half(self): + def test_an_overlong_sentence_is_cut_rather_than_sent_whole(self): + # Cette règle a été inversée le 2026-08-08, mesures à l'appui. Le + # raisonnement d'origine — une coupe au milieu d'une proposition + # s'entend plus qu'un segment un peu long — supposait que le moteur + # lise le segment long en entier. Il ne le fait pas : il le tronque. + # Sur « Le Lundi de Trop », le seul segment de 679 caractères est + # revenu en 16,2 s au lieu des 34 s nécessaires, moitié de phrase + # perdue. Mieux vaut une virgule devenue respiration. long_sentence = "mot " * 200 chunks = split_text_into_chunks(long_sentence.strip(), max_chars=50) - # An over-long sentence stays whole rather than being cut mid-clause. - assert len(chunks) == 1 + assert len(chunks) > 1 + assert all(len(c) <= 50 for c in chunks) + assert " ".join(chunks).split() == long_sentence.split() def test_every_word_survives(self): text = "Première phrase ici. Deuxième phrase là. Troisième enfin." @@ -133,3 +141,39 @@ def test_a_lone_fragment_is_kept_rather_than_lost(self): def test_the_pause_of_the_absorbed_tail_is_the_one_kept(self): segments = split_into_segments("Une phrase complète ici.\n\n-e") assert segments[0].pause_after > 0 + + +class TestOverlongSentencesAreCut: + """Une phrase trop longue est tronquée par le moteur, pas lue lentement. + + Mesuré sur « Le Lundi de Trop » : sous 300 caractères le taux de défaut est + de 4 à 8 %, il passe à 20 % entre 300 et 400, et le seul segment de 679 + caractères est revenu en 16,2 s là où 34 s étaient nécessaires — la moitié + de la phrase manquait. Une virgule devenue respiration coûte moins cher. + """ + + def test_a_long_sentence_is_cut_at_its_commas(self): + sentence = ("Elle avança dans le couloir, " * 12).strip().rstrip(",") + "." + chunks = split_text_into_chunks(sentence, 300) + assert len(chunks) > 1 + assert all(len(c) <= 300 for c in chunks) + + def test_every_word_survives_the_cut(self): + sentence = ("un mot de plus, " * 40).strip().rstrip(",") + "." + chunks = split_text_into_chunks(sentence, 200) + assert " ".join(chunks).split() == sentence.split() + + def test_a_sentence_without_any_boundary_falls_back_to_words(self): + sentence = "mot " * 200 + chunks = split_text_into_chunks(sentence.strip(), 150) + assert all(len(c) <= 150 for c in chunks) + assert " ".join(chunks).split() == sentence.split() + + def test_a_short_sentence_is_left_alone(self): + assert split_text_into_chunks("Elle entra.", 300) == ["Elle entra."] + + def test_semicolons_are_preferred_over_commas(self): + left = "a, " * 40 + sentence = f"{left}; {left}".strip() + chunks = split_text_into_chunks(sentence, 300) + assert all(len(c) <= 300 for c in chunks) From 7a46b7018d9c50c69b21dbface7335ed283b5bca Mon Sep 17 00:00:00 2001 From: PaxHelios Date: Sat, 8 Aug 2026 06:42:16 +0200 Subject: [PATCH 52/98] fix(queue): the sweep was missing the 1.6 GB it was written to remove MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The deliverables sweep deleted the chapter WAV and anything matching `seg_*.wav`. The segment cache names its entries otherwise and lives in a dot directory, so `outdir.glob("*.wav")` never saw it and the recursive pattern never matched: 1680 files, 1.6 GB, left behind by every book. Measured after two books: 530 MB of deliverables each, and 1.6 GB of cache each. Twenty books would have asked 32 GB of a 30 GB volume — the guard would have stopped the queue around the sixth, exactly the failure the sweep existed to prevent. The whole `.cache` directory now goes. The cost is that repair_segment.py can no longer retouch a swept book, which is acceptable because the quality pass and the repair have already run, immediately above, and the M4B and the ACX export are on disk before anything is deleted. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FBNmTCfFPDth6cWqg69MGu --- scripts/narrate_queue.py | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/scripts/narrate_queue.py b/scripts/narrate_queue.py index 8879216a..234c203c 100644 --- a/scripts/narrate_queue.py +++ b/scripts/narrate_queue.py @@ -182,9 +182,20 @@ def save() -> None: for w in outdir.glob("*.wav"): freed += w.stat().st_size w.unlink() - for cache in outdir.rglob("seg_*.wav"): - freed += cache.stat().st_size - cache.unlink() + # Le gros morceau est le cache de segments : 1680 fichiers, + # 1,6 Go par livre, dans un dossier en point que le premier + # balayage ne voyait pas — il cherchait « seg_*.wav » alors que + # le cache nomme ses entrées autrement. À 1,6 Go le livre, vingt + # livres réclament 32 Go sur un volume qui en fait 30. + # + # Le prix à payer : sans ce cache, repair_segment.py ne peut + # plus retoucher le livre. C'est acceptable parce que le + # contrôle qualité et la réparation ont déjà eu lieu, juste + # au-dessus, et que le M4B et l'export ACX sont écrits. + cache_dir = outdir / ".cache" + if cache_dir.is_dir(): + freed += sum(f.stat().st_size for f in cache_dir.rglob("*") if f.is_file()) + shutil.rmtree(cache_dir, ignore_errors=True) log(f" {freed/1e9:.1f} Go de WAV effacés (M4B et ACX conservés)") else: log(f" WAV conservés : M4B ou export ACX manquant, rien n'est effacé") From 981ed98a4dfe2946c47656a6075a8c630cd4feac Mon Sep 17 00:00:00 2001 From: PaxHelios Date: Sat, 8 Aug 2026 06:59:34 +0200 Subject: [PATCH 53/98] fix(engine): the badcase message must not be what kills the book MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three chapters narrated, then: ZeroDivisionError: division by zero f" Badcase detected, audio_text_ratio={pred_audio_feat.shape[0] / target_text_length}" target_text_length is the tokenized length of the target text, and some text reduces to zero tokens — French normalisation runs before segmentation, so what reaches the tokenizer is not what the chunker measured. Zero makes the badcase threshold zero too, so the branch is always taken, and the ratio in its own diagnostic then divides by zero. The engine had correctly detected the case and was about to retry; it was the message about retrying that ended the run. Both call sites now compute the ratio defensively. The retry loop is already bounded by retry_badcase_max_times, so a text that cannot be spoken costs a few attempts and the book continues instead of dying at chapter four. Reproduced twice on the same book, ten minutes in, before and after an unrelated restart — so it is the input, not the interruption. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FBNmTCfFPDth6cWqg69MGu --- src/voxcpm/model/voxcpm2.py | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/src/voxcpm/model/voxcpm2.py b/src/voxcpm/model/voxcpm2.py index 174dea3b..971969c4 100644 --- a/src/voxcpm/model/voxcpm2.py +++ b/src/voxcpm/model/voxcpm2.py @@ -669,8 +669,19 @@ def _generate( latent_pred, pred_audio_feat, context_len = next_and_close(inference_result) if retry_badcase: if pred_audio_feat.shape[0] >= target_text_length * retry_badcase_ratio_threshold: + # A text the tokenizer reduces to nothing makes the + # threshold zero, so this branch is always taken — and + # the ratio in the message below then divides by zero. + # A diagnostic must never be what destroys the run: a + # book had already narrated three chapters when this + # line ended it. + ratio = ( + pred_audio_feat.shape[0] / target_text_length + if target_text_length + else float("inf") + ) print( - f" Badcase detected, audio_text_ratio={pred_audio_feat.shape[0] / target_text_length}, retrying...", + f" Badcase detected, audio_text_ratio={ratio}, retrying...", file=sys.stderr, ) retry_badcase_times += 1 @@ -965,8 +976,19 @@ def _generate_with_prompt_cache( latent_pred, pred_audio_feat, context_len = next_and_close(inference_result) if retry_badcase: if pred_audio_feat.shape[0] >= target_text_length * retry_badcase_ratio_threshold: + # A text the tokenizer reduces to nothing makes the + # threshold zero, so this branch is always taken — and + # the ratio in the message below then divides by zero. + # A diagnostic must never be what destroys the run: a + # book had already narrated three chapters when this + # line ended it. + ratio = ( + pred_audio_feat.shape[0] / target_text_length + if target_text_length + else float("inf") + ) print( - f" Badcase detected, audio_text_ratio={pred_audio_feat.shape[0] / target_text_length}, retrying...", + f" Badcase detected, audio_text_ratio={ratio}, retrying...", file=sys.stderr, ) retry_badcase_times += 1 From 068c84106fc199e0f2d3f406afac76b7a6269e34 Mon Sep 17 00:00:00 2001 From: PaxHelios Date: Sat, 8 Aug 2026 11:35:31 +0200 Subject: [PATCH 54/98] feat(pronunciation): hear the candidates before betting twenty books on one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit conf/pronunciation_fr.json can already respell a word so the engine says it right, and its own comment gives the rule: listen first, because a needless correction can only make things worse. What was missing was any way to know *which* respelling works. Guessing is expensive here. A wrong correction applied to a catalogue is a catalogue to re-narrate, and nobody notices until delivery. Two minutes of GPU and one listen settle it instead. The script takes a test sentence and a list of spellings, renders one wav per candidate in the book's own voice — reference audio included, so a cloned voice is tested as it will actually be used — and names each file after the spelling it carries. The first file is always the unmodified text. Without it, the candidates are only compared against each other, and nobody can tell whether the winner beats doing nothing at all. Prompted by "ces" and "ses" coming out as "ce" in the first delivered books. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FBNmTCfFPDth6cWqg69MGu --- scripts/try_pronunciation.py | 99 ++++++++++++++++++++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 scripts/try_pronunciation.py diff --git a/scripts/try_pronunciation.py b/scripts/try_pronunciation.py new file mode 100644 index 00000000..d111272d --- /dev/null +++ b/scripts/try_pronunciation.py @@ -0,0 +1,99 @@ +"""Essayer plusieurs orthographes d'un mot et écouter laquelle se dit juste. + +Le lexique de `conf/pronunciation_fr.json` sait déjà remplacer un mot par une +orthographe qui se prononce mieux. Ce qu'il manquait, c'est le moyen de savoir +*laquelle* — et son propre commentaire le dit : « écoutez d'abord, une +correction inutile ne peut que dégrader ». + +Deviner coûte cher. Une correction fausse appliquée à vingt livres, ce sont +vingt livres à refaire, et personne ne s'en aperçoit avant la livraison. Deux +minutes de GPU et une écoute règlent la question. + +Le script prend une phrase de test et des candidats, produit un wav par +candidat, nommé pour qu'on sache lequel on écoute, et écrit un `index.txt`. +Il ne décide rien : il donne à entendre. + + python scripts/try_pronunciation.py \ + --phrase "Ces livres-là sont ses préférés, et ces pages-ci aussi." \ + --mot ces --candidats "cés" "sés" "cè" \ + --voice "Aurore — livre audio" --device cuda + +Le premier fichier produit est toujours le texte d'origine, non modifié : sans +lui on compare des corrections entre elles sans savoir si l'une d'elles est +seulement meilleure que le défaut de départ. +""" +from __future__ import annotations + +import argparse +import pathlib +import re +import sys + + +def main() -> int: + for stream in (sys.stdout, sys.stderr): + try: + stream.reconfigure(encoding="utf-8", errors="replace") + except (AttributeError, ValueError): + pass + + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--phrase", required=True, help="phrase de test contenant le mot") + ap.add_argument("--mot", required=True, help="mot dont la prononciation est douteuse") + ap.add_argument("--candidats", nargs="+", required=True, + help="orthographes à essayer à la place du mot") + ap.add_argument("--voice", required=True, help="voix prédéfinie") + ap.add_argument("--device", default="cuda") + ap.add_argument("--outdir", default="output/prononciation") + args = ap.parse_args() + + import app # lourd (torch) : après argparse, pour que --help reste instantané + + voice = next((v for v in app.PRESET_VOICES if v["name"] == args.voice), None) + if voice is None: + noms = ", ".join(v["name"] for v in app.PRESET_VOICES) + print(f"voix inconnue : {args.voice}\ndisponibles : {noms}", file=sys.stderr) + return 1 + + out = pathlib.Path(args.outdir) + out.mkdir(parents=True, exist_ok=True) + + def remplacer(phrase: str, mot: str, par: str) -> str: + return re.sub(rf"\b{re.escape(mot)}\b", par, phrase, flags=re.IGNORECASE) + + # L'original d'abord : c'est le défaut qu'on cherche à battre. + essais = [("00_original", args.phrase)] + for i, cand in enumerate(args.candidats, 1): + sain = re.sub(r"[^0-9A-Za-zÀ-ÿ-]+", "_", cand).strip("_") or f"cand{i}" + essais.append((f"{i:02d}_{sain}", remplacer(args.phrase, args.mot, cand))) + + demo = app.VoxCPMDemo(device=args.device, load_denoiser=False) + import soundfile as sf + + lignes = [] + for nom, texte in essais: + print(f" {nom} : {texte}", flush=True) + sr, wav, _ = demo.generate_tts_audio( + text_input=texte, + control_instruction=voice.get("description") or "", + cfg_value_input=voice.get("cfg", 2.0), + do_normalize=voice.get("normalize", True), + inference_timesteps=int(voice.get("diffusion_steps", 10)), + seed=voice["seed"], + reference_wav_path_input=voice.get("reference") or None, + prompt_text=voice.get("reference_text") or "", + denoise=False, + ) + chemin = out / f"{nom}.wav" + sf.write(str(chemin), wav, sr) + lignes.append(f"{chemin.name}\t{texte}") + + (out / "index.txt").write_text("\n".join(lignes) + "\n", encoding="utf-8") + print(f"\n{len(essais)} extrait(s) dans {out}") + print("Écoutez 00_original d'abord : si le mot y est déjà juste, ne corrigez rien.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 3c6f8ce8bd1bcc3df05ebc17beff47d217f84e69 Mon Sep 17 00:00:00 2001 From: PaxHelios Date: Sat, 8 Aug 2026 11:36:09 +0200 Subject: [PATCH 55/98] fix(pronunciation): the script could not import the app it needs Run as `python scripts/try_pronunciation.py`, sys.path[0] is scripts/ rather than the repository root, so `import app` failed immediately. narrate_book.py and pregenerate_previews.py already carry the same one-line insert; this one was missing it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FBNmTCfFPDth6cWqg69MGu --- scripts/try_pronunciation.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/scripts/try_pronunciation.py b/scripts/try_pronunciation.py index d111272d..a01b2c53 100644 --- a/scripts/try_pronunciation.py +++ b/scripts/try_pronunciation.py @@ -29,6 +29,10 @@ import re import sys +# Lancé par `python scripts/x.py`, sys.path[0] est scripts/, pas la racine : +# même idiome que narrate_book.py et pregenerate_previews.py. +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent)) + def main() -> int: for stream in (sys.stdout, sys.stderr): From 0f25ce4c56195a76af57e48afa8e55924f8fe413 Mon Sep 17 00:00:00 2001 From: PaxHelios Date: Sat, 8 Aug 2026 11:46:45 +0200 Subject: [PATCH 56/98] fix(acx): the retail sample must come from the introduction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rule was "the first chapter that is not a credit", and on a real book it picked the title page: the buyer heard the book's own name read out and learnt nothing about it. Edwin listened to the delivered sample and said what it should have been — enough of the introduction to grasp the essential without giving the book away. That is what an introduction is. So the sample now comes from it when the book has one — introduction, avant-propos, préface, prologue, préambule — and otherwise from the first chapter that is neither a credit nor front matter: a dedication and a medical disclaimer are read aloud but say nothing about the argument. --sample-chapter still overrides everything, for the book that needs it. Books already delivered keep the sample they were given; their chapter WAVs are swept once the M4B and the export exist, so a new sample would have to be cut from the delivered MP3 rather than from the master. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FBNmTCfFPDth6cWqg69MGu --- scripts/export_acx.py | 55 ++++++++++++++++++++++++++++++++++++---- tests/test_export_acx.py | 35 +++++++++++++++++++++++++ 2 files changed, 85 insertions(+), 5 deletions(-) diff --git a/scripts/export_acx.py b/scripts/export_acx.py index 870d8cd1..9850edf6 100644 --- a/scripts/export_acx.py +++ b/scripts/export_acx.py @@ -23,9 +23,10 @@ 2. **Splits what is too long.** A chapter over the duration or size limit is cut into parts, in a pause rather than mid-word, each part shaped like a file of its own. -3. **Extracts a retail sample** of 1 to 5 minutes from the first real chapter, - never from the credits: a sample is what a buyer decides on, and nobody - decides on hearing the title read out. +3. **Extracts a retail sample** of 1 to 5 minutes from the book's introduction + when it has one, else from its first real chapter. Never the credits, never + the title page, never the dedication: a sample is what a buyer decides on, + and what decides them is the argument of the book, not its name read out. 4. **Encodes to 192 kbps CBR MP3 at 44.1 kHz**, which needs ffmpeg. Without ffmpeg the first three steps still run, the WAVs are written, and the @@ -104,6 +105,49 @@ def is_credit(title: str) -> bool: return title.strip() in (credits_tools.OPENING_TITLE, credits_tools.CLOSING_TITLE) +#: What opens a book by explaining it. A buyer deciding on a sample wants the +#: argument of the book, not its first anecdote and not its title page. +_OPENING_MATTER = re.compile( + r"^\s*(introduction|avant[- ]propos|pr[ée]face|prologue|pr[ée]ambule)\b", + re.IGNORECASE, +) + +#: Front matter that is read aloud but says nothing about the book: the title +#: page, the dedication, the disclaimer. +_FRONT_MATTER = re.compile( + r"^\s*(d[ée]dicace|avertissement|copyright|mentions)\b", + re.IGNORECASE, +) + + +def sample_chapter_index(titles: Sequence[str], book_title: str = "") -> Optional[int]: + """Which chapter the retail sample should come from, 1-based. + + Taking the first non-credit chapter is what this did, and it picked the + title page: a buyer heard the book's own name read out and learnt nothing. + A sample has to let someone grasp what the book argues without giving the + book away, and that is exactly what an introduction is for. + + So: the introduction if the book has one, otherwise the first chapter that + is neither a credit, nor front matter, nor the title page repeated. + """ + ranked = list(enumerate(titles, 1)) + for index, title in ranked: + if _OPENING_MATTER.match(title or ""): + return index + + normalised = (book_title or "").strip().casefold() + for index, title in ranked: + clean = (title or "").strip() + if is_credit(clean) or _FRONT_MATTER.match(clean): + continue + if normalised and clean.casefold() == normalised: + continue + return index + + return next((i for i, t in ranked if not is_credit(t or "")), None) + + def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter @@ -121,7 +165,7 @@ def build_parser() -> argparse.ArgumentParser: parser.add_argument("--sample-start", type=float, default=0.0, help="Seconds into the chapter the sample starts (default: 0)") parser.add_argument("--sample-chapter", type=int, - help="1-based chapter to sample (default: the first that is not a credit)") + help="1-based chapter to sample (default: the introduction, else the first real chapter)") parser.add_argument("--keep-wav", action="store_true", help="Keep the intermediate WAV of each delivered file") return parser @@ -190,6 +234,7 @@ def main() -> int: commands: List[List[str]] = [] failures = 0 sample_source: Optional[Tuple[np.ndarray, int, str]] = None + preferred_sample = sample_chapter_index(titles) for index, path in enumerate(chapter_paths, 1): title = titles[index - 1] @@ -197,7 +242,7 @@ def main() -> int: data = audio_tools.as_float_mono(data) parts = delivery.split_for_delivery(data, sample_rate, profile) - wanted = args.sample_chapter == index if args.sample_chapter else not is_credit(title) + wanted = args.sample_chapter == index if args.sample_chapter else index == preferred_sample if sample_source is None and wanted: sample_source = (data, sample_rate, title) diff --git a/tests/test_export_acx.py b/tests/test_export_acx.py index a604765f..07e5e484 100644 --- a/tests/test_export_acx.py +++ b/tests/test_export_acx.py @@ -168,3 +168,38 @@ def test_an_over_long_chapter_becomes_several_files(self, monkeypatch, book): titles = [entry["title"] for entry in report_of(book / "acx")["files"]] assert any("partie 1" in title for title in titles) assert any("partie 2" in title for title in titles) + + +class TestSampleChapterChoice: + """L'extrait commercial doit venir de l'introduction. + + Auparavant la règle était « le premier chapitre qui n'est pas un générique », + et sur un livre réel elle a choisi la page de titre : l'acheteur entendait le + nom du livre récité et n'apprenait rien. Ce qui décide quelqu'un, c'est le + propos du livre, et c'est exactement ce que contient une introduction. + """ + + def test_the_introduction_wins_over_the_title_page(self): + titres = [ + "Générique de début", + "Rebâtir l'Intimité Après Divorce", + "Introduction — Le mur invisible", + "Chapitre 1 — Les Ruines Invisibles", + "Générique de fin", + ] + assert export_acx.sample_chapter_index(titres) == 3 + + def test_front_matter_is_skipped_when_there_is_no_introduction(self): + titres = ["Générique de début", "Dédicace", "Avertissement médical", + "Chapitre 1 — Le début", "Générique de fin"] + assert export_acx.sample_chapter_index(titres) == 4 + + @pytest.mark.parametrize("ouverture", ["Avant-propos", "Préface", "Prologue", "Préambule"]) + def test_every_kind_of_opening_matter_counts(self, ouverture): + assert export_acx.sample_chapter_index(["Générique de début", ouverture, "Chapitre 1"]) == 2 + + def test_a_book_with_nothing_but_chapters_takes_the_first(self): + assert export_acx.sample_chapter_index(["Générique de début", "Chapitre 1", "Générique de fin"]) == 2 + + def test_a_book_of_nothing_but_credits_has_no_sample(self): + assert export_acx.sample_chapter_index(["Générique de début", "Générique de fin"]) is None From 44b2b377bcf78cf5e7f2ac73b6a3441b823aa036 Mon Sep 17 00:00:00 2001 From: PaxHelios Date: Sat, 8 Aug 2026 12:00:50 +0200 Subject: [PATCH 57/98] fix(prepare): 969 stage directions were about to be read aloud MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scan_risky_words.py was written to find the words an engine says badly, by looking at what the text contains rather than by listening to ninety hours of it. The first run found something else: 993 bracketed markers across the twenty books queued for narration, 969 of them "[PAUSE]", in ten books. None of those ten had been narrated yet. They are not one thing, and treating them alike would have been wrong twice over. "[PAUSE]" is an instruction to stop talking, so it becomes a paragraph break and the pause profile turns it into real silence. "[rire]", "[silence prolongé]", "[pleurs contenus]" are stage directions in transcribed testimony — they tell a reader what happened in the room, and spoken aloud they announce that the narrator laughed, so they go. But "[nom du département]", "[ton mari / ta femme]", "[date]" are the sentence itself, a blank the reader fills, and deleting them leaves a hole where the meaning was: they keep their words and lose only their brackets. Brackets are never spoken. What is inside them sometimes is. Verified after regenerating all twenty prepared texts: zero brackets remain. scan_risky_words.py itself is kept — it ranks acronyms, proper nouns and foreign words by how often the corpus says them, because a sigle read eighty times costs eighty mistakes and a name read twice costs two. It corrects nothing; it feeds try_pronunciation.py, and only what has been heard goes into the lexicon. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FBNmTCfFPDth6cWqg69MGu --- scripts/prepare_manuscript.py | 54 +++++++++++- scripts/scan_risky_words.py | 139 +++++++++++++++++++++++++++++++ tests/test_prepare_manuscript.py | 70 ++++++++++++++++ 3 files changed, 262 insertions(+), 1 deletion(-) create mode 100644 scripts/scan_risky_words.py create mode 100644 tests/test_prepare_manuscript.py diff --git a/scripts/prepare_manuscript.py b/scripts/prepare_manuscript.py index 6fba7f4f..62647dc7 100644 --- a/scripts/prepare_manuscript.py +++ b/scripts/prepare_manuscript.py @@ -66,6 +66,51 @@ def text(self) -> str: return "\n".join(self.lines).strip() +#: A bracketed direction asking the narrator to stop. It is not a word, it is a +#: silence — so it becomes one, rather than being read out as "PAUSE". +PAUSE_MARKER = re.compile(r"\[\s*pause\s*\]", re.IGNORECASE) + +#: Stage directions in a transcribed testimony. They tell a reader what +#: happened in the room; spoken aloud they say that the narrator laughed. +STAGE_DIRECTION = re.compile( + r"\[\s*(rire|rires|hésitation|h[ée]sitations|silence(?:\s+prolong[ée])?|soupir|soupirs" + r"|pleurs(?:\s+contenus)?|larmes|blanc|sanglots?|se l[èe]ve[^\]]*|s'?arr[êe]te[^\]]*)\s*\]", + re.IGNORECASE, +) + + +def unbracket(text: str) -> tuple[str, list[str]]: + """Deal with the square brackets a manuscript carries, by what they mean. + + A corpus of twenty books held 993 of them in 24 forms, and they are not one + thing. ``[PAUSE]``, 969 times over, is an instruction to stop talking. + ``[rire]`` is a stage direction in a transcribed testimony. But + ``[nom du département]`` and ``[ton mari / ta femme]`` are the sentence + itself — a blank the reader fills — and deleting them leaves a hole where + the meaning was. + + So: a pause becomes a paragraph break, a stage direction goes, and anything + else keeps its words and loses only its brackets. Brackets are never + spoken; what is inside them sometimes is. + """ + removed: list[str] = [] + + def note(kind: str, m: re.Match) -> str: + removed.append(f"{kind} : {m.group(0)}") + return "" + + text = PAUSE_MARKER.sub(lambda m: note("pause", m) or "\n\n", text) + text = STAGE_DIRECTION.sub(lambda m: note("didascalie", m), text) + + def keep_inside(m: re.Match) -> str: + inner = m.group(1).strip() + removed.append(f"crochets retirés : {m.group(0)}") + return inner + + text = re.sub(r"\[([^\]\n]{1,80})\]", keep_inside, text) + return text, removed + + def strip_inline(text: str) -> str: """Remove the marks that are silent on a page and spoken by an engine.""" text = re.sub(r"!\[[^\]]*\]\([^)]*\)", "", text) # images: nothing to say @@ -186,7 +231,11 @@ def main() -> int: return 1 md = src.read_text(encoding="utf-8", errors="replace") + # Avant tout découpage : un « [PAUSE] » devenu saut de paragraphe doit + # pouvoir séparer deux paragraphes, ce que le parseur lira ensuite. + md, removed_brackets = unbracket(md) blocks, removed_parse = parse(md) + removed_parse = removed_parse + removed_brackets chapters, removed_struct = to_chapters(blocks) if not chapters: @@ -207,7 +256,10 @@ def main() -> int: counts: dict[str, int] = {} for r in removed_parse: - counts[r] = counts.get(r, 0) + 1 + # Grouper par nature : 969 lignes « pause : [PAUSE] » n'apprennent rien + # de plus qu'une seule ligne disant 969. + cle = r.split(" : ")[0] if " : " in r else r + counts[cle] = counts.get(cle, 0) + 1 if counts or removed_struct: print("\nRetiré :") for k, n in sorted(counts.items()): diff --git a/scripts/scan_risky_words.py b/scripts/scan_risky_words.py new file mode 100644 index 00000000..176c3c23 --- /dev/null +++ b/scripts/scan_risky_words.py @@ -0,0 +1,139 @@ +"""Repérer, dans les textes à narrer, les mots qu'un moteur français dira mal. + +On ne peut pas corriger ce qu'on n'a pas entendu, et écouter quatre-vingt-dix +heures pour trouver dix mots n'est pas une méthode. Ce script fait l'inverse : +il cherche dans le texte les formes qui *mettent un moteur en défaut*, les +classe par nombre d'occurrences, et laisse l'oreille trancher. + +Le classement par fréquence est le cœur du tri. Un sigle lu quatre-vingts fois +dans un catalogue coûte quatre-vingts fautes ; un nom propre lu deux fois n'en +coûte que deux. À temps d'écoute égal, on corrige le premier. + +Quatre familles, par ordre de dégât : + +* **Sigles** — « TDAH », « ADN », « IA ». Le moteur hésite entre épeler et lire + comme un mot, et se trompe dans les deux sens. +* **Mots étrangers** — « burnout », « mindfulness ». Lus avec des règles + françaises, ils deviennent méconnaissables. +* **Noms propres** — capitale en milieu de phrase. Aucun moteur ne les connaît + tous, et un nom d'auteur écorché à chaque citation s'entend. +* **Restes typographiques** — chiffres romains, symboles, unités collées. + +Rien n'est corrigé ici. Le rapport nourrit `try_pronunciation.py`, qui fait +entendre les candidats, et seul ce qui a été entendu entre dans le lexique. + + python scripts/scan_risky_words.py queue/*.txt --top 40 +""" +from __future__ import annotations + +import argparse +import collections +import json +import pathlib +import re +import sys + +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent)) + +# Un sigle : au moins deux capitales d'affilée, éventuellement pointées. +SIGLE = re.compile(r"\b(?:[A-ZÀ-Þ]\.){2,}|\b[A-ZÀ-Þ]{2,6}\b") + +# Une capitale en milieu de phrase : nom propre probable. On exclut le début de +# phrase, où la capitale ne dit rien. +NOM_PROPRE = re.compile(r"(? dict[str, collections.Counter]: + trouve = {k: collections.Counter() for k in ("sigles", "étrangers", "noms propres", "romains")} + for texte in textes.values(): + for m in SIGLE.findall(texte): + if m.upper() not in {"OK"}: + trouve["sigles"][m] += 1 + for m in NOM_PROPRE.findall(texte): + if m not in BANALS: + trouve["noms propres"][m] += 1 + for m in ETRANGER.findall(texte): + trouve["étrangers"][m.lower()] += 1 + for m in CHIFFRE_ROMAIN.findall(texte): + trouve["romains"][m] += 1 + # Un sigle est déjà compté comme sigle ; qu'il ressorte en nom propre est du bruit. + for s in list(trouve["sigles"]): + trouve["noms propres"].pop(s, None) + return trouve + + +def main() -> int: + for stream in (sys.stdout, sys.stderr): + try: + stream.reconfigure(encoding="utf-8", errors="replace") + except (AttributeError, ValueError): + pass + + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("fichiers", nargs="+", help="textes préparés (.txt)") + ap.add_argument("--top", type=int, default=30, help="entrées par famille (défaut : 30)") + ap.add_argument("--min", type=int, default=3, help="occurrences minimales (défaut : 3)") + ap.add_argument("--json", help="écrire le rapport ici") + args = ap.parse_args() + + textes = {} + for entree in args.fichiers: + chemin = pathlib.Path(entree) + # Un shell POSIX développe déjà le motif, un cmd.exe non : accepter les + # deux plutôt que de dépendre de qui appelle. + chemins = [chemin] if chemin.is_file() else sorted( + chemin.parent.glob(chemin.name) if chemin.parent != chemin else [] + ) + for p in chemins: + if p.is_file(): + textes[p.name] = p.read_text(encoding="utf-8", errors="replace") + if not textes: + print("aucun fichier lu", file=sys.stderr) + return 1 + + total = sum(len(t) for t in textes.values()) + print(f"{len(textes)} texte(s), {total} caractères\n") + + trouve = scanner(textes) + rapport = {} + for famille, compteur in trouve.items(): + retenu = [(m, n) for m, n in compteur.most_common() if n >= args.min][: args.top] + rapport[famille] = retenu + if not retenu: + continue + print(f"=== {famille.upper()} ({len(retenu)} retenu(s), seuil {args.min}) ===") + for mot, n in retenu: + print(f" {n:>5} × {mot}") + print() + + if args.json: + pathlib.Path(args.json).write_text( + json.dumps(rapport, ensure_ascii=False, indent=2), encoding="utf-8") + print(f"rapport écrit dans {args.json}") + + print("Rien n'a été corrigé. Passez les candidats à try_pronunciation.py,") + print("écoutez, et n'inscrivez au lexique que ce qui sonne faux.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_prepare_manuscript.py b/tests/test_prepare_manuscript.py new file mode 100644 index 00000000..b5c83ceb --- /dev/null +++ b/tests/test_prepare_manuscript.py @@ -0,0 +1,70 @@ +"""Tests de scripts/prepare_manuscript.py. + +Le cas qui a motivé ce fichier : un corpus de vingt livres portait 993 +marqueurs entre crochets, dont « [PAUSE] » 969 fois. Envoyés au moteur tels +quels, ils se lisent à voix haute. Aucun n'avait encore été narré — d'où +l'urgence de figer le comportement avant qu'ils ne le soient. +""" +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +spec = importlib.util.spec_from_file_location( + "prepare_manuscript", ROOT / "scripts" / "prepare_manuscript.py" +) +prepare_manuscript = importlib.util.module_from_spec(spec) +sys.modules["prepare_manuscript"] = prepare_manuscript +spec.loader.exec_module(prepare_manuscript) + + +class TestBrackets: + """Trois natures de crochets, trois traitements — ils ne sont pas une chose.""" + + def test_a_pause_becomes_a_silence_not_a_word(self): + out, _ = prepare_manuscript.unbracket("Il se tut. [PAUSE] Puis reprit.") + assert "PAUSE" not in out + assert "\n\n" in out + + @pytest.mark.parametrize("marqueur", ["[rire]", "[hésitation]", "[silence prolongé]", + "[pleurs contenus]", "[soupir]"]) + def test_stage_directions_are_removed(self, marqueur): + out, _ = prepare_manuscript.unbracket(f"Elle parla. {marqueur} Puis se tut.") + assert "[" not in out and "]" not in out + assert marqueur.strip("[]") not in out + + @pytest.mark.parametrize("champ", ["[nom du département]", "[ton mari / ta femme]", "[date]"]) + def test_a_blank_to_fill_keeps_its_words(self, champ): + # Supprimer ceux-là laisserait un trou à la place du sens. + out, _ = prepare_manuscript.unbracket(f"Écrivez {champ} ici.") + assert "[" not in out + assert champ.strip("[]") in out + + def test_everything_removed_is_reported(self): + _, removed = prepare_manuscript.unbracket("A [PAUSE] B [rire] C [date] D") + assert len(removed) == 3 + assert any(r.startswith("pause") for r in removed) + assert any(r.startswith("didascalie") for r in removed) + + def test_text_without_brackets_is_untouched(self): + texte = "Une phrase parfaitement ordinaire, sans rien de particulier." + out, removed = prepare_manuscript.unbracket(texte) + assert out == texte + assert removed == [] + + +class TestMarkdownIsNotSpoken: + def test_emphasis_and_headings_leave_no_trace(self): + assert prepare_manuscript.strip_inline("**gras** et *italique*") == "gras et italique" + + def test_a_link_keeps_its_words_and_loses_its_target(self): + assert prepare_manuscript.strip_inline("voir [le site](https://x.fr)") == "voir le site" + + def test_an_image_says_nothing(self): + assert prepare_manuscript.strip_inline("![couverture](img.png)") == "" From 385c8977254ccbcae34613bffe1fccab933be18d Mon Sep 17 00:00:00 2001 From: PaxHelios Date: Sat, 8 Aug 2026 12:08:18 +0200 Subject: [PATCH 58/98] feat(credits): name the synthetic voice without pretending it is a person MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A catalogue read end to end by the same voice deserves to credit it by name, the way a publisher credits a virtual voice. The obvious way to do that — passing the name as --narrator — would have been wrong: that field means a human narrator, and filling it is exactly what suppresses the synthetic-voice disclosure that Audible, Apple Books and Findaway require. The credits would have announced a performance that never happened. So narrator_credit grows a third case instead of reusing the first. A human narrator is named and stands alone. A synthetic voice with a name is named *and* disclosed — "Lu par Aurore Cabonet, une voix de synthèse." A synthetic voice without a name is disclosed as before. Turning the disclosure off remains a deliberate act, unchanged. narrate_queue.py credits Aurore as "Aurore Cabonet" and Alex Somerset as "Gabriel Adam" across the queue, and a book may override it per entry. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FBNmTCfFPDth6cWqg69MGu --- narration/credits.py | 23 ++++++++++++++++++-- scripts/narrate_book.py | 6 ++++++ scripts/narrate_queue.py | 23 ++++++++++++++++---- tests/test_narration_credits.py | 37 +++++++++++++++++++++++++++++++++ 4 files changed, 83 insertions(+), 6 deletions(-) diff --git a/narration/credits.py b/narration/credits.py index fbe20875..343b48e2 100644 --- a/narration/credits.py +++ b/narration/credits.py @@ -112,6 +112,13 @@ class BookCredits: #: Human narrator. Left empty for a synthetic reading, which is then #: disclosed rather than passed off as a performance. narrator: str = "" + #: Name given to the synthetic voice — "Aurore Cabonet", "Gabriel Adam". + #: A catalogue read by the same voice deserves to credit it by name, the + #: way a publisher credits a virtual voice. It does **not** replace the + #: disclosure: the credit says the name *and* that the voice is synthetic, + #: because a name alone would present a machine as a performer, which is + #: exactly what distributors require not to happen. + voice_name: str = "" subtitle: str = "" publisher: str = "" year: str = "" @@ -130,11 +137,23 @@ def _words(self) -> dict: @property def narrator_credit(self) -> str: - """Who the recording says read it.""" + """Who the recording says read it. + + Three cases rather than two. A human narrator is named and that is all. + A synthetic voice with a name is named *and* disclosed — "Aurore + Cabonet, une voix de synthèse" — because the name alone would credit a + performance that never happened. A synthetic voice without a name is + disclosed as before. + """ narrator = _clean(self.narrator) if narrator: return narrator - return self._words["synthetic"] if self.disclose_synthetic else "" + + voice_name = _clean(self.voice_name) + synthetic = self._words["synthetic"] if self.disclose_synthetic else "" + if voice_name and synthetic: + return f"{voice_name}, {synthetic}" + return voice_name or synthetic def _work(self) -> str: """« Title », by Author — the phrase both credits are built around.""" diff --git a/scripts/narrate_book.py b/scripts/narrate_book.py index e72b46af..0042e41e 100644 --- a/scripts/narrate_book.py +++ b/scripts/narrate_book.py @@ -205,6 +205,11 @@ def build_parser() -> argparse.ArgumentParser: run.add_argument("--author", default="", help="Author (assembled file, and credits)") story = parser.add_argument_group("generique") + story.add_argument("--voice-name", default="", + help="Nom donné à la voix de synthèse dans les génériques " + "(« Aurore Cabonet »). Le générique dit alors le nom ET " + "qu'il s'agit d'une voix de synthèse : le nom seul " + "créditerait une interprétation qui n'a pas eu lieu.") story.add_argument("--narrator", default="", help="Human narrator named in the credits. Left empty, the credits " "disclose a synthetic voice, as distributors require") @@ -281,6 +286,7 @@ def main() -> int: title=args.title or (book.title if book else "") or in_path.stem, author=args.author or (book.author if book else ""), narrator=args.narrator, + voice_name=args.voice_name, publisher=args.publisher, year=args.year, public_domain=args.public_domain, diff --git a/scripts/narrate_queue.py b/scripts/narrate_queue.py index 234c203c..4b6e3507 100644 --- a/scripts/narrate_queue.py +++ b/scripts/narrate_queue.py @@ -40,6 +40,17 @@ def log(msg: str) -> None: print(f"[{time.strftime('%H:%M:%S')}] {msg}", flush=True) +#: Le nom sous lequel chaque voix est créditée dans les génériques. Un +#: catalogue lu par la même voix mérite qu'on la nomme, comme un éditeur +#: crédite une voix virtuelle — la mention « voix de synthèse » reste dite en +#: plus du nom, jamais à sa place. +VOICE_NAMES = { + "Aurore — livre audio": "Aurore Cabonet", + "Aurore — méditation guidée": "Aurore Cabonet", + "Alex Somerset": "Gabriel Adam", +} + + def child_env() -> dict: """L'environnement des étapes, forcé en UTF-8. @@ -136,10 +147,14 @@ def save() -> None: continue t0 = time.time() - rc, tail = run([PYTHON, "scripts/narrate_book.py", str(txt), "--voice", b["voice"], - "--device", args.device, "--outdir", str(outdir), - "--qc-retries", args.qc_retries, - "--assemble", "m4b", "--export-acx"], blog) + cmd = [PYTHON, "scripts/narrate_book.py", str(txt), "--voice", b["voice"], + "--device", args.device, "--outdir", str(outdir), + "--qc-retries", args.qc_retries, + "--assemble", "m4b", "--export-acx"] + nom_voix = b.get("voice_name") or VOICE_NAMES.get(b["voice"], "") + if nom_voix: + cmd += ["--voice-name", nom_voix] + rc, tail = run(cmd, blog) mins = (time.time() - t0) / 60 if rc != 0: log(f" narration échouée après {mins:.0f} min — voir {blog.name}") diff --git a/tests/test_narration_credits.py b/tests/test_narration_credits.py index 7946a196..a00f06b7 100644 --- a/tests/test_narration_credits.py +++ b/tests/test_narration_credits.py @@ -114,3 +114,40 @@ def test_a_book_with_no_metadata_still_says_something(self): def test_the_two_files_have_stable_names(self): assert OPENING_TITLE and CLOSING_TITLE assert OPENING_TITLE != CLOSING_TITLE + + +class TestNamedSyntheticVoice: + """Nommer la voix ne dispense pas de dire qu'elle est synthétique. + + Un catalogue lu par la même voix mérite qu'on la crédite — les éditeurs le + font pour leurs voix virtuelles. Mais le nom seul créditerait une + interprétation qui n'a pas eu lieu, et c'est précisément ce que les + plateformes exigent d'éviter. Les deux se disent, jamais l'un à la place + de l'autre. + """ + + def test_the_name_and_the_disclosure_are_both_said(self): + c = BookCredits(title="Un livre", author="Un auteur", voice_name="Aurore Cabonet") + assert "Aurore Cabonet" in c.opening() + assert "voix de synthèse" in c.opening() + + def test_the_closing_credit_says_both_too(self): + c = BookCredits(title="Un livre", author="Un auteur", voice_name="Gabriel Adam") + closing = c.closing() + assert "Gabriel Adam" in closing + assert "voix de synthèse" in closing + + def test_a_human_narrator_still_wins_and_stands_alone(self): + c = BookCredits(title="Un livre", narrator="Jean Dupont", voice_name="Gabriel Adam") + assert "Jean Dupont" in c.opening() + assert "Gabriel Adam" not in c.opening() + assert "voix de synthèse" not in c.opening() + + def test_without_a_name_the_disclosure_is_unchanged(self): + c = BookCredits(title="Un livre") + assert "voix de synthèse" in c.opening() + + def test_disabling_the_disclosure_leaves_the_name_alone(self): + # Le désactiver reste un acte délibéré, documenté comme tel. + c = BookCredits(title="Un livre", voice_name="Gabriel Adam", disclose_synthetic=False) + assert c.narrator_credit == "Gabriel Adam" From 4bce2fb7d398c2b1f30e86a70dd0a4d3513ae958 Mon Sep 17 00:00:00 2001 From: PaxHelios Date: Sat, 8 Aug 2026 14:01:44 +0200 Subject: [PATCH 59/98] feat(lexicon): stack lexicons, so a book can name its own abbreviations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "HFD" appears 160 times in one book and nowhere else in the catalogue. It is never expanded: the text uses it from chapter one onward. A reader turns back a few pages or infers it; a listener hears "ache-èf-dé" a hundred and sixty times and never learns what it stands for. Edwin chose to expand it everywhere. The lexicon could already do that, but --lexicon took a single path and *replaced* the default, so a book-specific file would have silently dropped SNCF, RATP, Wi-Fi and Nietzsche. It is now repeatable and the files stack, the later winning — a book adds to the general lexicon instead of standing in for it. narrate_queue.py reads an optional "lexicons" list per queue entry. The expansion needs three rules, not one, and the lexicon's longest-key-first order is what makes them work together. "les HFD" becomes plural, because the three occurrences already carry feminine plural agreement after them — "non encore prises en charge", "non aiguës". "mécanisme HFD" becomes "mécanisme de la dépression…", since the apposition needs the article. Everything else is the plain expansion. Verified on all six real contexts drawn from the book. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FBNmTCfFPDth6cWqg69MGu --- conf/lexique_hfd.json | 14 ++++++++++++++ scripts/narrate_book.py | 12 +++++++++--- scripts/narrate_queue.py | 5 +++++ 3 files changed, 28 insertions(+), 3 deletions(-) create mode 100644 conf/lexique_hfd.json diff --git a/conf/lexique_hfd.json b/conf/lexique_hfd.json new file mode 100644 index 00000000..457bd7c5 --- /dev/null +++ b/conf/lexique_hfd.json @@ -0,0 +1,14 @@ +{ + "_comment": "Lexique propre à « TENIR DEBOUT — La dépression masquée des cadres performants » (livre-33-depression-masquee). S'empile sur conf/pronunciation_fr.json, il ne le remplace pas.", + + "_pourquoi": "L'abréviation HFD revient 160 fois et n'est jamais développée dans le texte : elle apparaît dès le chapitre 1. À l'écrit, un lecteur remonte quelques pages ou devine ; à l'oral il entend « ache-èf-dé » cent soixante fois sans savoir de quoi on parle. Edwin a choisi de la développer partout.", + + "_ordre": "Le lexique applique les clés de la plus longue à la plus courte, ce qui règle les accords : « mécanisme HFD » et « les HFD » sont traités avant « HFD » seul, donc leur forme particulière gagne.", + + "mécanisme HFD": "mécanisme de la dépression de haut fonctionnement", + + "_pluriel": "Les trois « les HFD » sont suivis d'accords féminins pluriels déjà en place — « non encore prises en charge », « non aiguës » — donc seul le nom est à mettre au pluriel.", + "les HFD": "les dépressions de haut fonctionnement", + + "HFD": "dépression de haut fonctionnement" +} diff --git a/scripts/narrate_book.py b/scripts/narrate_book.py index 0042e41e..4d98f61d 100644 --- a/scripts/narrate_book.py +++ b/scripts/narrate_book.py @@ -148,8 +148,11 @@ def build_parser() -> argparse.ArgumentParser: "wording of the credits (default: fr)") text.add_argument("--no-text-prep", action="store_true", help="Skip French normalization (numbers, abbreviations, Roman numerals)") - text.add_argument("--lexicon", default="conf/pronunciation_fr.json", - help="Pronunciation lexicon JSON (default: conf/pronunciation_fr.json)") + text.add_argument("--lexicon", action="append", metavar="FICHIER", + help="Lexique de prononciation JSON. Répétable : les fichiers " + "s'empilent et le dernier gagne, donc un lexique propre à un " + "livre s'ajoute au lexique général plutôt que de le remplacer. " + "Défaut : conf/pronunciation_fr.json") text.add_argument("--no-normalize", action="store_true", help="Disable the engine's own text normalization") text.add_argument("--chapter-regex", help="Regex (MULTILINE) that separates chapters (default: '^---$')") text.add_argument("--epub-min-chars", type=int, default=epub.DEFAULT_MIN_CHARS, @@ -299,7 +302,10 @@ def main() -> int: lexicon = {} if not args.no_text_prep: - lexicon = text_fr.load_lexicon(args.lexicon) + # Empiler plutôt que remplacer : un livre qui définit son abréviation + # maison ne doit pas perdre au passage les sigles communs. + for chemin in (args.lexicon or ["conf/pronunciation_fr.json"]): + lexicon.update(text_fr.load_lexicon(chemin)) prepare = ( text_en.normalize_english if args.language == "en" else text_fr.normalize_french ) diff --git a/scripts/narrate_queue.py b/scripts/narrate_queue.py index 4b6e3507..9e8453b9 100644 --- a/scripts/narrate_queue.py +++ b/scripts/narrate_queue.py @@ -154,6 +154,11 @@ def save() -> None: nom_voix = b.get("voice_name") or VOICE_NAMES.get(b["voice"], "") if nom_voix: cmd += ["--voice-name", nom_voix] + # Un livre peut avoir ses propres abréviations. Le lexique général est + # passé d'abord, le sien ensuite : ils s'empilent, il ne le remplace pas. + lexiques = ["conf/pronunciation_fr.json"] + list(b.get("lexicons") or []) + for lex in lexiques: + cmd += ["--lexicon", lex] rc, tail = run(cmd, blog) mins = (time.time() - t0) / 60 if rc != 0: From 5aae71d3be5128bcc105966b12d1160c18e4ce00 Mon Sep 17 00:00:00 2001 From: PaxHelios Date: Sat, 8 Aug 2026 16:26:54 +0200 Subject: [PATCH 60/98] fix(queue): five books announced their own file name as their title MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The opening credits of "LE CERVEAU VOLÉ" said, aloud: livre-un-esprits-reprogrammes. A .txt carries no metadata, so narrate_book falls back to the file name, and the French normaliser helpfully read "livre-01" as "livre-un". Five delivered books opened and closed this way — every one produced from a prepared manuscript. Only the book imported from EPUB was right, because an EPUB names itself. The queue now carries title and author per entry, taken from the manuscript's own headings, and passes them through. Verbatim: an earlier attempt to recase LE CERVEAU VOLÉ into title case produced "Les Marchands D'immortalité" and "Dormir, c'Est Apprendre", and each rule fixed invented another. The casing changes nothing in the ear — the normaliser leaves capitals alone rather than spelling them — so the author's own wording stands. The five books already delivered still carry the wrong credits. Their masters were swept, so a repair means regenerating the two credit chapters and replacing those two files in the ACX export, which is a separate job. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FBNmTCfFPDth6cWqg69MGu --- scripts/narrate_queue.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/scripts/narrate_queue.py b/scripts/narrate_queue.py index 9e8453b9..a40fecf7 100644 --- a/scripts/narrate_queue.py +++ b/scripts/narrate_queue.py @@ -159,6 +159,12 @@ def save() -> None: lexiques = ["conf/pronunciation_fr.json"] + list(b.get("lexicons") or []) for lex in lexiques: cmd += ["--lexicon", lex] + # Sans titre, narrate_book retombe sur le nom du fichier : cinq livres + # se sont annoncés « livre-un-esprits-reprogrammes » avant qu'on le + # remarque. Un .txt ne porte pas de métadonnées, donc la file les porte. + for option, cle in (("--title", "title"), ("--author", "author")): + if b.get(cle): + cmd += [option, b[cle]] rc, tail = run(cmd, blog) mins = (time.time() - t0) / 60 if rc != 0: From 7f2c992dbeb223e8cab0e99aa8c7c45c847723c2 Mon Sep 17 00:00:00 2001 From: PaxHelios Date: Sat, 8 Aug 2026 16:35:54 +0200 Subject: [PATCH 61/98] fix(polish): nine books were heading for rejection over two decibels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The delivery reports told a story the ear had not: Le Cerveau Volé Alex Somerset floor -58.2 dB 18/23 rejected Atlas des Villes... Alex Somerset floor -57.9 dB 15/21 rejected Les Effacées Aurore floor -68.3 dB all pass Le Lundi de Trop Aurore floor -71.1 dB all pass ACX refuses anything above -60 dBFS. A cloned voice inherits its reference's room tone, and the Alex Somerset reference was the noisy one — its own note says so: a constant floor at -52 dBFS where Aurore sits at -71. Nine books in the queue carry that voice; every one of them would have come back. The noise sits in 80-150 Hz, twelve decibels above the next band and just above the 80 Hz high-pass that was supposed to catch it. Raising the high-pass would have worked and would have thinned the voice: that band is also a male fundamental. So the fix acts in time rather than in frequency — a downward expander, threshold relative to the chapter's own speech level like the compressor, reduction capped at 16 dB because a silence dug out too far is heard as a hole. Measured on real chapters: Alex Somerset -58.7 to -72.3 dB, Aurore -72.9 to -88.8. Speech loses about two decibels, which the normalisation immediately after puts back. The two Alex Somerset books already delivered keep their floor; their masters were swept, so they need re-narrating rather than re-mastering. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FBNmTCfFPDth6cWqg69MGu --- narration/polish.py | 56 +++++++++++++++++++++++++++++++++- tests/test_narration_polish.py | 46 ++++++++++++++++++++++++++++ 2 files changed, 101 insertions(+), 1 deletion(-) diff --git a/narration/polish.py b/narration/polish.py index 64abcc4c..9cc473bc 100644 --- a/narration/polish.py +++ b/narration/polish.py @@ -50,6 +50,7 @@ __all__ = [ "PolishSettings", "compress", + "expand_down", "deess", "highpass", "limit", @@ -98,6 +99,24 @@ class PolishSettings: #: Never pull the band down by more than this, whatever the excess. deess_max_reduction_db: float = 8.0 + #: Baisser le fond pendant les silences. Une voix clonée hérite du bruit de + #: sa référence : Alex Somerset rend un plancher à -58 dBFS là où Aurore est + #: à -70, et l'ACX refuse tout ce qui dépasse -60. Le bruit se concentre + #: dans 80-150 Hz, qui est aussi le fondamental d'une voix masculine, donc + #: un passe-haut plus haut amaigrirait la voix : il faut agir dans le temps, + #: pas en fréquence. + expand: bool = True + #: Seuil, relatif au niveau de parole du chapitre — comme la compression, + #: parce qu'un seuil absolu écraserait une prise forte et raterait une prise + #: faible. + expand_threshold_db: float = -30.0 + expand_ratio: float = 2.5 + #: Plafonner la réduction : un silence poussé à -100 dB s'entend comme un + #: trou, ce qui est un défaut d'un autre genre. + expand_max_reduction_db: float = 16.0 + expand_attack_ms: float = 5.0 + expand_release_ms: float = 220.0 + compress: bool = True #: Relative to the signal's own speech level, not an absolute dBFS value: #: the chapter arrives un-normalised and a fixed threshold would either do @@ -117,7 +136,7 @@ class PolishSettings: @property def enabled(self) -> bool: - return bool(self.highpass_hz or self.deess or self.compress or self.limit) + return bool(self.highpass_hz or self.expand or self.deess or self.compress or self.limit) # -------------------------------------------------------------------------- @@ -287,6 +306,40 @@ def deess( return (low + _apply_control_gain(high, reduction, hop)).astype(np.float32) +def expand_down( + wav: np.ndarray, + sr: int, + settings: PolishSettings = PolishSettings(), +) -> np.ndarray: + """Pousser le fond vers le bas pendant les silences, sans toucher la voix. + + Le seuil est relatif au niveau de parole du chapitre, comme pour la + compression. En dessous, le gain descend selon le rapport, plafonné : un + silence creusé à l'excès s'entend comme un trou, et un trou est un défaut + au même titre qu'un souffle. + """ + wav = audio_tools.as_float_mono(wav) + if wav.size == 0 or not settings.expand: + return wav + + envelope_db, hop = _control_envelope_db(wav, sr) + if envelope_db.size == 0: + return wav + + speech_db = audio_tools.speech_rms_db(wav, sr) + if not np.isfinite(speech_db): + return wav + threshold = speech_db + settings.expand_threshold_db + + deficit = np.maximum(0.0, threshold - envelope_db) + reduction = -deficit * (max(settings.expand_ratio, 1.0) - 1.0) + reduction = np.maximum(reduction, -abs(settings.expand_max_reduction_db)) + reduction = _smooth_gain( + reduction, settings.expand_attack_ms, settings.expand_release_ms + ) + return _apply_control_gain(wav, reduction, hop) + + def compress( wav: np.ndarray, sr: int, @@ -445,6 +498,7 @@ def polish( return wav if settings.highpass_hz: wav = highpass(wav, sr, settings.highpass_hz) + wav = expand_down(wav, sr, settings) wav = deess(wav, sr, settings) wav = compress(wav, sr, settings) return limit(wav, sr, settings) diff --git a/tests/test_narration_polish.py b/tests/test_narration_polish.py index 68601a9d..1cb094f2 100644 --- a/tests/test_narration_polish.py +++ b/tests/test_narration_polish.py @@ -270,3 +270,49 @@ def test_the_flag_survives_a_saved_plan(self): payload = dataclasses.asdict(audio.MasteringSettings()) assert payload["polish"] is True assert all(not isinstance(value, dict) for value in payload.values()) + + +class TestExpandDown: + """Le fond doit descendre sous la limite ACX sans emporter la voix. + + Une voix clonée hérite du bruit de sa référence : Alex Somerset rend un + plancher à -58 dBFS quand Aurore est à -70, et l'ACX refuse au-dessus de + -60. Mesuré sur un chapitre réel : -58,7 devient -72,3, pour 1,2 dB de + parole en moins que la normalisation qui suit rattrape. + """ + + def _voix_bruitee(self, sr=44100, secondes=6.0): + # Parole intermittente sur un fond constant, comme un chapitre. + n = int(sr * secondes) + t = np.arange(n) / sr + parole = 0.2 * np.sin(2 * np.pi * 150 * t) + enveloppe = ((t % 2.0) < 1.0).astype(np.float32) # 1 s de voix, 1 s de silence + fond = 0.0012 * np.random.default_rng(0).standard_normal(n) + return (parole * enveloppe + fond).astype(np.float32), sr + + def test_the_floor_comes_down(self): + x, sr = self._voix_bruitee() + avant = audio.noise_floor_db(x, sr) + apres = audio.noise_floor_db(polish.expand_down(x, sr), sr) + assert apres < avant - 6 + + def test_speech_is_left_almost_alone(self): + x, sr = self._voix_bruitee() + avant = audio.speech_rms_db(x, sr) + apres = audio.speech_rms_db(polish.expand_down(x, sr), sr) + assert abs(apres - avant) < 4 + + def test_the_reduction_is_capped(self): + # Un silence creusé sans limite s'entend comme un trou. + x, sr = self._voix_bruitee() + y = polish.expand_down(x, sr) + creux = 20 * np.log10(np.abs(y).max() / (np.abs(x).max() + 1e-12) + 1e-12) + assert creux > -6 + + def test_disabling_it_changes_nothing(self): + x, sr = self._voix_bruitee() + s = polish.PolishSettings(expand=False) + assert np.array_equal(polish.expand_down(x, sr, s), audio.as_float_mono(x)) + + def test_silence_survives_it(self): + assert polish.expand_down(np.zeros(0, dtype=np.float32), 44100).size == 0 From 0e3140083080ed6f54952e6c654f21ff2f219976 Mon Sep 17 00:00:00 2001 From: PaxHelios Date: Sat, 8 Aug 2026 16:37:48 +0200 Subject: [PATCH 62/98] test(polish): "everything off" must include the stage just added MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit added a downward expander to the chain and left test_nothing_happens_when_everything_is_off asserting that a settings object with highpass, deess, compress and limit disabled is inert. It no longer was, because expand defaults to on — correctly, since the whole point is that books get it without being asked. The test is right and the omission was mine: a new stage belongs in the list a caller turns off. Pushed one commit late, because the push ran whether or not pytest had passed. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FBNmTCfFPDth6cWqg69MGu --- tests/test_narration_polish.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_narration_polish.py b/tests/test_narration_polish.py index 1cb094f2..21127bb4 100644 --- a/tests/test_narration_polish.py +++ b/tests/test_narration_polish.py @@ -229,7 +229,7 @@ def test_order_is_correct_then_control(self): def test_nothing_happens_when_everything_is_off(self): source = voice_like(2.0) off = polish.PolishSettings( - highpass_hz=0.0, deess=False, compress=False, limit=False + highpass_hz=0.0, expand=False, deess=False, compress=False, limit=False ) assert not off.enabled assert np.array_equal(polish.polish(source, SR, off), source) From b38ea03babe6f643ae7db306627d60842cfa22fa Mon Sep 17 00:00:00 2001 From: PaxHelios Date: Sat, 8 Aug 2026 19:40:46 +0200 Subject: [PATCH 63/98] feat(audit): find the mispronounced words without listening to them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three hundred books is the constraint that changes the method. Correcting pronunciation needs to know which words sound wrong; knowing that needs an ear; and nobody will listen to three hundred books. So the referee cannot be an ear. It is speech recognition. Have one machine read back what another just said, and compare with the text it was given. Where the transcript diverges, the pronunciation is suspect: Whisper does not invent "Guébrou" if it heard "Gebru". Nothing needs re-narrating for this. The segment cache already holds the text asked for beside the audio produced — exactly the two terms of the comparison. Two refinements the first tests demanded. Comparison is by multiset rather than by alignment, because one swallowed word shifts everything after it and the question is which words are wrong, not where. And a correctly spelled acronym comes back as separate letters — "T. D. A. H." — which is the right pronunciation written differently, so runs of single letters are glued back before comparing; without that the report drowns in acronyms that were fine. What it cannot see is stated in the module: Whisper corrects what it hears against meaning, so "ce livres" is transcribed "ces livres" and grammatical words escape the audit. Those are for the ear. Proper nouns, acronyms, foreign words and numbers have no grammatical safety net, and there the divergence is plain. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FBNmTCfFPDth6cWqg69MGu --- scripts/audit_pronunciation.py | 199 +++++++++++++++++++++++++++++++++ 1 file changed, 199 insertions(+) create mode 100644 scripts/audit_pronunciation.py diff --git a/scripts/audit_pronunciation.py b/scripts/audit_pronunciation.py new file mode 100644 index 00000000..01fb8803 --- /dev/null +++ b/scripts/audit_pronunciation.py @@ -0,0 +1,199 @@ +"""Trouver les mots que le moteur a mal prononcés, sans les écouter. + +Le problème d'échelle. Corriger la prononciation demande de savoir quels mots +sonnent faux ; le savoir demande d'écouter ; et personne n'écoutera trois cents +livres. Il faut un arbitre qui ne soit pas une oreille. + +Cet arbitre est la reconnaissance vocale. On fait relire par une machine ce +qu'une autre machine vient de dire, et on compare au texte de départ. Là où la +transcription s'écarte de la source, la prononciation est suspecte : Whisper +n'invente pas « Guébrou » s'il a entendu « Gebru ». + +Rien n'est à re-narrer pour cela. Le cache de segments garde côte à côte le +texte demandé et l'audio produit — exactement les deux termes de la +comparaison. + +**Ce que la méthode ne voit pas.** Whisper corrige ce qu'il entend d'après le +sens : si le moteur dit « ce livres », il transcrira « ces livres », parce que +la grammaire le lui souffle. Les mots grammaticaux échappent donc à l'audit, et +c'est l'oreille qui les attrape — comme « ces » l'a été. En revanche les noms +propres, les sigles, les mots étrangers et les nombres n'ont pas de filet +grammatical : là, la divergence est franche et l'audit les trouve. + + python scripts/audit_pronunciation.py output/book_mon_livre --sample 120 +""" +from __future__ import annotations + +import argparse +import collections +import json +import pathlib +import re +import sys +import unicodedata + +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent)) + +MODELE = "openai/whisper-large-v3-turbo" + + +def mots(texte: str) -> list[str]: + return re.findall(r"[0-9A-Za-zÀ-ÿ''-]+", texte or "") + + +def pliable(mot: str) -> str: + """Forme comparable : sans accent, sans casse, sans trait d'union. + + Whisper ponctue et accentue à sa façon ; une différence d'accent n'est pas + une différence de prononciation, et compter les deux ferait crouler le + rapport sous du bruit. + """ + plat = unicodedata.normalize("NFKD", mot.lower()) + plat = "".join(c for c in plat if not unicodedata.combining(c)) + return plat.replace("-", "").replace("'", "").replace("'", "") + + +def charger_cache(directory: pathlib.Path) -> list[tuple[str, pathlib.Path]]: + """Les paires (texte demandé, audio produit) que le cache garde.""" + cache = directory / ".cache" + if not cache.is_dir(): + return [] + paires = [] + for j in sorted(cache.glob("*.json")): + w = j.with_suffix(".wav") + if not w.exists(): + continue + try: + texte = json.loads(j.read_text(encoding="utf-8")).get("text") + except (OSError, ValueError): + continue + if texte: + paires.append((texte, w)) + return paires + + +def transcrire(paires, device: str): + """Faire relire l'audio par Whisper, segment par segment.""" + import numpy as np + import soundfile as sf + import torch + from transformers import WhisperForConditionalGeneration, WhisperProcessor + + # Le pipeline() de transformers 5 décode l'audio via torchcodec, dont les + # DLL réclament un ffmpeg partagé. Le cache est en WAV : soundfile suffit, + # et rien ne dépend d'un binaire installé. + proc = WhisperProcessor.from_pretrained(MODELE) + modele = WhisperForConditionalGeneration.from_pretrained(MODELE).to(device).eval() + + for i, (texte, chemin) in enumerate(paires, 1): + x, sr = sf.read(str(chemin), dtype="float32") + if x.ndim > 1: + x = x.mean(axis=1) + if sr != 16000: # Whisper n'accepte que 16 kHz + n = int(len(x) * 16000 / sr) + x = np.interp(np.linspace(0, len(x) - 1, n), np.arange(len(x)), x).astype("float32") + entrees = proc(x, sampling_rate=16000, return_tensors="pt").input_features.to(device) + with torch.no_grad(): + ids = modele.generate(entrees, language="fr", task="transcribe", max_new_tokens=440) + yield texte, proc.batch_decode(ids, skip_special_tokens=True)[0].strip() + if i % 20 == 0: + print(f" {i}/{len(paires)} segments relus", flush=True) + + +def recoller_sigles(jetons: list[str]) -> list[str]: + """« T. D. A. H. » redevient « TDAH ». + + Un sigle correctement épelé revient de la transcription en lettres + séparées. C'est la bonne prononciation, écrite autrement ; le compter comme + une faute noierait le rapport sous les sigles qui vont bien. + """ + sortie: list[str] = [] + tampon: list[str] = [] + for j in jetons + [""]: + if len(j) == 1 and j.isalpha(): + tampon.append(j) + continue + if len(tampon) >= 2: + sortie.append("".join(tampon)) + else: + sortie.extend(tampon) + tampon = [] + if j: + sortie.append(j) + return sortie + + +def comparer(source: str, entendu: str) -> list[tuple[str, str]]: + """Les mots de la source que la transcription ne retrouve pas. + + Comparaison par ensemble plutôt que par alignement : un mot avalé décale + tout le reste, et on cherche les mots fautifs, pas leur position. + """ + vus = collections.Counter(pliable(m) for m in recoller_sigles(mots(entendu))) + manquants = [] + for m in mots(source): + cle = pliable(m) + if vus[cle] > 0: + vus[cle] -= 1 + else: + manquants.append((m, entendu)) + return manquants + + +def main() -> int: + for flux in (sys.stdout, sys.stderr): + try: + flux.reconfigure(encoding="utf-8", errors="replace") + except (AttributeError, ValueError): + pass + + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("directory", help="dossier d'un livre narré (contenant .cache)") + ap.add_argument("--sample", type=int, default=150, + help="nombre de segments à relire (défaut : 150)") + ap.add_argument("--device", default="cuda") + ap.add_argument("--min", type=int, default=2, + help="occurrences minimales pour figurer au rapport (défaut : 2)") + ap.add_argument("--json", help="écrire le rapport ici") + args = ap.parse_args() + + d = pathlib.Path(args.directory) + paires = charger_cache(d) + if not paires: + print(f"aucun cache de segments dans {d}. Le livre a-t-il été balayé " + f"(--keep deliverables) ? L'audit doit tourner avant le balayage.", + file=sys.stderr) + return 1 + + # Échantillonner régulièrement plutôt qu'au hasard : un livre change de + # sujet en avançant, et les noms propres n'arrivent pas tous au début. + pas = max(1, len(paires) // args.sample) + echantillon = paires[::pas][: args.sample] + print(f"{len(paires)} segments en cache, {len(echantillon)} relus\n") + + suspects: collections.Counter = collections.Counter() + exemples: dict[str, str] = {} + for source, entendu in transcrire(echantillon, args.device): + for mot, contexte in comparer(source, entendu): + suspects[mot] += 1 + exemples.setdefault(mot, contexte[:110]) + + retenus = [(m, n) for m, n in suspects.most_common() if n >= args.min] + print(f"\n{len(suspects)} mot(s) non retrouvé(s), {len(retenus)} vu(s) au moins {args.min} fois\n") + for mot, n in retenus[:40]: + print(f" {n:>3} × {mot:<26} entendu : « …{exemples[mot][:70]}… »") + + if args.json: + pathlib.Path(args.json).write_text( + json.dumps({m: {"occurrences": n, "entendu": exemples[m]} for m, n in retenus}, + ensure_ascii=False, indent=2), encoding="utf-8") + print(f"\nrapport : {args.json}") + + print("\nRien n'est corrigé ici. Les candidats passent par try_pronunciation.py,") + print("et seul ce qui a été entendu entre dans le lexique.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From f98c7719360f65c70fc3efea0bebc85043fd9688 Mon Sep 17 00:00:00 2001 From: PaxHelios Date: Sat, 8 Aug 2026 19:41:40 +0200 Subject: [PATCH 64/98] fix(audit): match Whisper's dtype rather than assume float32 The model loads in half precision, the features arrive as float32, and the first convolution refuses the pair: "Input type (float) and bias type (c10::Half) should be the same". Cast to the model's own dtype instead of naming one. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FBNmTCfFPDth6cWqg69MGu --- scripts/audit_pronunciation.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/scripts/audit_pronunciation.py b/scripts/audit_pronunciation.py index 01fb8803..cbe8e727 100644 --- a/scripts/audit_pronunciation.py +++ b/scripts/audit_pronunciation.py @@ -92,7 +92,10 @@ def transcrire(paires, device: str): if sr != 16000: # Whisper n'accepte que 16 kHz n = int(len(x) * 16000 / sr) x = np.interp(np.linspace(0, len(x) - 1, n), np.arange(len(x)), x).astype("float32") - entrees = proc(x, sampling_rate=16000, return_tensors="pt").input_features.to(device) + # Whisper se charge en demi-précision : lui donner du float32 lève + # « Input type (float) and bias type (c10::Half) should be the same ». + entrees = proc(x, sampling_rate=16000, return_tensors="pt").input_features + entrees = entrees.to(device=device, dtype=modele.dtype) with torch.no_grad(): ids = modele.generate(entrees, language="fr", task="transcribe", max_new_tokens=440) yield texte, proc.batch_decode(ids, skip_special_tokens=True)[0].strip() From 263687930d9ebbf3b3056d62bea527fa136b4aea Mon Sep 17 00:00:00 2001 From: PaxHelios Date: Sat, 8 Aug 2026 19:44:46 +0200 Subject: [PATCH 65/98] fix(audit): report only what the method can actually judge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first real run buried its findings. Sixteen words came back above the threshold and thirteen were noise of two kinds. Numbers: the French normaliser writes "mille neuf cent quatre-vingts" and Whisper writes "1980". The pronunciation is right and only the spelling differs, so numerals are excluded — testing each part across hyphens, because "quatre-vingt-dix" is as much a number as "dix". Grammatical words: the module already documents that Whisper corrects what it hears against meaning, so "ce livres" comes back "ces livres". Those words can only appear by transcription accident, and in numbers they make the report unreadable. They belong to the ear, and the ear is what caught "ces". Truncated segments are excluded too. Their words are missing because they were never spoken, not because they were spoken badly, and the quality pass already handles them — counting them here surfaces perfectly good words as suspects. What remains is what the method sees clearly: proper nouns, acronyms, foreign words. Verified on the cases that produced the noise. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FBNmTCfFPDth6cWqg69MGu --- scripts/audit_pronunciation.py | 60 +++++++++++++++++++++++++++++++++- 1 file changed, 59 insertions(+), 1 deletion(-) diff --git a/scripts/audit_pronunciation.py b/scripts/audit_pronunciation.py index cbe8e727..ce41b864 100644 --- a/scripts/audit_pronunciation.py +++ b/scripts/audit_pronunciation.py @@ -126,6 +126,49 @@ def recoller_sigles(jetons: list[str]) -> list[str]: return sortie +#: Mots que la méthode ne peut pas juger. Whisper corrige ce qu'il entend +#: d'après le sens, donc « ce livres » revient « ces livres » : un mot +#: grammatical n'apparaît dans le rapport que par accident de transcription, et +#: en nombre il le rend illisible. Ceux-là restent l'affaire de l'oreille. +GRAMMATICAUX = set(""" +le la les un une des du de d au aux à a et ou ni mais or donc car que qui quoi +dont où ce cet cette ces ceux celle celles il elle ils elles on nous vous je tu +me te se lui leur leurs mon ma mes ton ta tes son sa ses notre nos votre vos +en y est sont était étaient sera seront été être avoir ai as ont avait avaient +pour par sur sous dans vers chez avec sans entre après avant depuis pendant +plus moins très trop peu bien tout tous toute toutes même aussi encore déjà +comme quand si ne pas non oui alors ainsi cela ceci celui +""".split()) + +#: Les nombres écrits en toutes lettres par le normaliseur reviennent en +#: chiffres de la transcription : « mille neuf cent quatre-vingts » contre +#: « 1980 ». La prononciation est juste, l'orthographe seule diffère. +NOMBRES = set(""" +zéro un deux trois quatre cinq six sept huit neuf dix onze douze treize +quatorze quinze seize vingt vingts trente quarante cinquante soixante cent +cents mille milles million millions milliard milliards demi premier première +""".split()) + + +def interessant(mot: str) -> bool: + """Un mot dont une divergence dit quelque chose. + + Un nom propre, un sigle, un mot étranger n'ont pas de filet grammatical : + si la transcription s'en écarte, c'est que la prononciation s'en écartait. + """ + plat = pliable(mot) + if not plat or len(plat) < 3: + return False + if plat in GRAMMATICAUX: + return False + # « quatre-vingt-dix » est un nombre autant que « dix » : tester chaque + # partie, sinon les composés passent le filtre et polluent le rapport. + parties = [pliable(p) for p in re.split(r"[-']", mot) if p] + if parties and all(p in NOMBRES or p in GRAMMATICAUX for p in parties): + return False + return plat not in NOMBRES + + def comparer(source: str, entendu: str) -> list[tuple[str, str]]: """Les mots de la source que la transcription ne retrouve pas. @@ -138,7 +181,7 @@ def comparer(source: str, entendu: str) -> list[tuple[str, str]]: cle = pliable(m) if vus[cle] > 0: vus[cle] -= 1 - else: + elif interessant(m): manquants.append((m, entendu)) return manquants @@ -171,6 +214,21 @@ def main() -> int: # Échantillonner régulièrement plutôt qu'au hasard : un livre change de # sujet en avançant, et les noms propres n'arrivent pas tous au début. + # Un segment tronqué a perdu ses mots par troncature, pas par prononciation : + # le contrôle qualité s'en occupe déjà, et les compter ici ferait remonter + # des mots parfaitement dits qui n'ont simplement jamais été prononcés. + rapport_qc = d / "qc_report.json" + tronques = set() + if rapport_qc.is_file(): + try: + details = json.loads(rapport_qc.read_text(encoding="utf-8")).get("details", []) + tronques = {e["segment"] for e in details + if any(i["code"] in ("truncated", "runaway") for i in e.get("issues", []))} + except (OSError, ValueError): + pass + if tronques: + print(f"{len(tronques)} segment(s) tronqué(s) exclus de l'audit\n") + pas = max(1, len(paires) // args.sample) echantillon = paires[::pas][: args.sample] print(f"{len(paires)} segments en cache, {len(echantillon)} relus\n") From a15a18922eaabe7e2d54082666c9ab3b34e6d033 Mon Sep 17 00:00:00 2001 From: PaxHelios Date: Sat, 8 Aug 2026 19:46:12 +0200 Subject: [PATCH 66/98] docs: the text-preparation method, as a skill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nothing existed for this. The audiobook-producer skill sitting in Downloads covers the editorial side and its TTS step says only "choose a service"; every installed skill is about web deployment. So the method this session paid for was living in commit messages. It is written as a skill and installed at ~/.claude/skills/audiobook-text-prep, with this copy versioned beside the code it describes. The framing matters more than the checklist: a preparation fault is not visible when it is made. It surfaces three GPU-hours later, in a finished book, and it repeats identically in every book after. On a catalogue it does not cost a book, it costs the catalogue. Every trap listed was paid for once — the `---` collision, the table of contents read aloud, 969 [PAUSE] markers, sentences truncated past 300 characters, two-character fragments, an abbreviation used 160 times and never expanded, five books announcing their own file name. It also states plainly what the ASR audit cannot do. Whisper corrects what it hears against meaning, so grammatical words escape it — "ces" pronounced "ce" was caught by an ear and the next one will be too. A method that overclaims is worse than one with a known blind spot. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FBNmTCfFPDth6cWqg69MGu --- docs/PREPARATION_TEXTE.md | 189 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 189 insertions(+) create mode 100644 docs/PREPARATION_TEXTE.md diff --git a/docs/PREPARATION_TEXTE.md b/docs/PREPARATION_TEXTE.md new file mode 100644 index 00000000..3387dbf8 --- /dev/null +++ b/docs/PREPARATION_TEXTE.md @@ -0,0 +1,189 @@ +--- +name: audiobook-text-prep +description: "Préparer un manuscrit pour la narration audio, phonétique comprise : conversion du texte, nettoyage de ce qui ne se lit pas à voix haute, abréviations, lexique de prononciation, contrôle après narration. Déclencheurs : 'préparer un livre pour narration', 'texte pour livre audio', 'prononciation', 'phonétique', 'lexique', 'mot mal prononcé', 'préparer le manuscrit', 'narration TTS'. À utiliser AVANT de narrer, et APRÈS pour auditer. NE PAS utiliser pour : le mastering audio, la conformité ACX, l'assemblage M4B — voir scripts/export_acx.py." +license: Proprietary +--- + +# Préparer un manuscrit pour la narration + +Un manuscrit est écrit pour être **vu**. La narration le donne à **entendre**. +Tout ce qui vit dans cet écart — un ISBN, un astérisque, une table des +matières, une abréviation jamais développée — se lit très bien des yeux et se +dit très mal à voix haute. + +Ce skill couvre la préparation du texte et la prononciation. Il ne couvre ni le +mastering ni la conformité au dépôt. + +## Pourquoi c'est le maillon décisif + +Un défaut de préparation ne se voit pas au moment où on le crée. Il se découvre +trois heures de GPU plus tard, dans un livre terminé, et il se répète à +l'identique dans tous les livres suivants. Sur un catalogue, une erreur de +préparation ne coûte pas un livre : elle coûte le catalogue. + +Chaque piège listé ici a été payé une fois. Aucun n'est théorique. + +## La chaîne + +``` +manuscrit.md + │ scripts/prepare_manuscript.py ← retire ce qui ne se dit pas + ▼ +livre.txt ────────────────────────────────┐ + │ scripts/scan_risky_words.py │ ← liste les mots à risque + │ scripts/narrate_book.py --dry-run │ ← pré-vol, sans GPU + ▼ │ +narration │ + │ scripts/audit_pronunciation.py │ ← ce qui a été mal dit (ASR) + ▼ │ +candidats ─── scripts/try_pronunciation.py │ ← faire entendre les variantes + │ │ + ▼ │ +conf/pronunciation_fr.json ───────────────┘ ← le lexique s'enrichit +``` + +Le lexique est le capital du catalogue. Chaque livre corrigé rend le suivant +meilleur, et sur trois cents livres cet effet cumulé compte plus que n'importe +quel réglage. + +## 1. Convertir — `prepare_manuscript.py` + +```bash +python scripts/prepare_manuscript.py manuscrit_complet.md -o livre.txt --report +``` + +Il retire ce qui ne se lit pas et **dit toujours ce qu'il a retiré**. Un texte +supprimé sans le signaler est un texte perdu sans le savoir. + +Quatre pièges qu'il traite, tous rencontrés : + +**`---` veut dire deux choses.** Filet horizontal dans le manuscrit, séparateur +de chapitres pour le narrateur. Une conversion naïve découpe le livre à la page +de copyright. + +**Le liminaire n'est pas de la narration.** ISBN, copyright, table des matières, +adresse web : lus à voix haute, ils ouvrent le livre sur son propre code-barres. +La dédicace et l'avertissement médical, eux, se gardent. + +**Les crochets ne sont pas une seule chose.** Sur un corpus de vingt livres : +993 marqueurs, dont `[PAUSE]` 969 fois. `[PAUSE]` devient un vrai silence ; +`[rire]`, `[silence prolongé]` sont des didascalies et disparaissent ; mais +`[nom du département]` ou `[ton mari / ta femme]` **sont la phrase** — un blanc +que le lecteur remplit — et seuls leurs crochets partent. + +**Un tableau ne se lit pas.** À voix haute, c'est une suite de barres verticales. + +## 2. Repérer les mots à risque — `scan_risky_words.py` + +```bash +python scripts/scan_risky_words.py queue/*.txt --top 30 --min 10 +``` + +Classe sigles, noms propres et mots étrangers **par fréquence**. Un sigle lu +999 fois coûte 999 fautes ; un nom propre lu deux fois en coûte deux. À temps +d'écoute égal, on corrige le premier. + +Il ne corrige rien. Il dit où regarder. + +## 3. Les abréviations maison — le cas qu'aucun outil ne devine + +`HFD` revenait 160 fois dans un livre, **jamais développé** : le texte l'emploie +dès le chapitre 1. Un lecteur remonte quelques pages ou devine ; un auditeur +entend « ache-èf-dé » cent soixante fois sans jamais savoir de quoi on parle. + +Ce n'est pas un problème de prononciation, c'est un problème de sens, et il +n'existe qu'à l'oral. **Le chercher fait partie de la préparation** : toute +abréviation fréquente doit être développée, ou introduite une première fois. + +Les lexiques s'empilent, donc un livre déclare les siens sans perdre les +communs : + +```bash +--lexicon conf/pronunciation_fr.json --lexicon conf/lexique_mon_livre.json +``` + +Attention aux accords. `HFD` → « dépression de haut fonctionnement » demandait +trois règles : `les HFD` au pluriel (les adjectifs qui suivent étaient déjà +accordés), `mécanisme HFD` avec l'article, et le reste au singulier. Le lexique +applique la clé la plus longue d'abord, ce qui les fait cohabiter. + +## 4. Le pré-vol — gratuit, et il attrape ce qui coûterait trois heures + +```bash +python scripts/narrate_book.py livre.txt --voice "..." --dry-run +``` + +Ne charge pas le modèle. Vérifier : le **nombre de chapitres** (un seul chapitre +pour 60 000 caractères signifie que le découpage a raté), le **premier segment** +(c'est ce que l'auditeur entendra en premier), et la **santé de la référence** +pour une voix clonée. + +**Le titre.** Un `.txt` ne porte pas de métadonnées, donc sans `--title` le +générique annonce le nom du fichier. Cinq livres se sont ouverts sur +« livre-un-esprits-reprogrammes » avant qu'on l'entende. + +## 5. Auditer après narration — `audit_pronunciation.py` + +```bash +python scripts/audit_pronunciation.py output/book_mon_livre --sample 150 +``` + +**C'est la pièce qui rend trois cents livres possibles.** Corriger la +prononciation demande de savoir quels mots sonnent faux ; le savoir demande +d'écouter ; personne n'écoutera trois cents livres. L'arbitre ne peut donc pas +être une oreille. + +Il fait relire par une machine ce qu'une autre vient de dire, et compare au +texte source. Le cache de segments garde déjà les deux côte à côte : rien n'est +à re-narrer. **Lancer l'audit avant le balayage** (`--keep deliverables` efface +le cache). + +**Ce que l'audit voit** : noms propres, sigles, mots étrangers — ils n'ont pas +de filet grammatical, donc la divergence est franche. + +**Ce qu'il ne voit pas** : les mots grammaticaux. Whisper corrige ce qu'il +entend d'après le sens, donc « ce livres » revient « ces livres ». C'est +l'oreille qui a attrapé « ces » prononcé « ce », et c'est l'oreille qui +attrapera les suivants. Ne pas prétendre le contraire. + +## 6. Choisir l'orthographe — `try_pronunciation.py` + +```bash +python scripts/try_pronunciation.py --phrase "..." --mot ces \ + --candidats "cés" "sés" --voice "Aurore — livre audio" +``` + +Produit un extrait par candidat, dans la voix du livre. **Le premier fichier est +toujours le texte non modifié** : sans lui on compare des corrections entre +elles sans savoir si l'une bat le défaut de départ. + +La règle que porte le lexique lui-même : **écoutez d'abord**. Si le moteur dit +déjà bien « TDAH », n'y touchez pas — une correction inutile ne peut que +dégrader. + +## Ce qui n'est pas de la préparation, mais qu'on confond avec + +Trois défauts ressemblent à de la prononciation et n'en sont pas. Les corriger +dans le texte ne sert à rien : + +- **`truncated`** — le moteur a coupé le segment. Une phrase de plus de 300 + caractères n'est pas lue lentement, elle est tronquée : le découpage la coupe + désormais sur une virgule. +- **`runaway`** — un fragment de deux caractères fait babiller le moteur. + Régénérer n'y change rien (mesuré : 0,6 s de babil devenu 1,6 s) ; les + fragments sont fusionnés avec leur voisin. +- **plancher de bruit** — une voix clonée hérite du fond de sa référence, et + l'ACX refuse au-dessus de −60 dBFS. Cela se règle au mastering, pas dans le + texte. + +## L'ordre qui fait gagner du temps + +1. Convertir et **lire le rapport** de ce qui a été retiré. +2. Scanner les mots à risque, traiter les abréviations maison. +3. Pré-vol, vérifier chapitres, premier segment, titre. +4. Narrer **un** livre. +5. Auditer, faire écouter les candidats, enrichir le lexique. +6. Alors seulement, lancer le lot. + +Narrer vingt livres avant d'auditer le premier, c'est découvrir vingt fois la +même faute. From 06742777a62cbf9eeee0f107beb201e9d3d1259b Mon Sep 17 00:00:00 2001 From: PaxHelios Date: Sat, 8 Aug 2026 22:19:20 +0200 Subject: [PATCH 67/98] feat(credits): let the publisher drop the synthetic-voice disclosure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Edwin wants the credits to name the narrator and stop there. The mechanism was already there — disclose_synthetic has always been settable — but nothing exposed it, deliberately, because it is not a comfort setting. It is now a flag on narrate_book and on the queue, and a per-book field so the choice is visible in the data rather than buried in a command line. Stated once and not repeated: ACX, Apple Books and Findaway require generated narration to be identified as such, and "Gabriel Adam" and "Aurore Cabonet" are voices, not people. Removing the line presents a synthesis as a performance and risks rejection or withdrawal after publication. That is the publisher's call to make, and he has made it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FBNmTCfFPDth6cWqg69MGu --- scripts/narrate_book.py | 6 ++++++ scripts/narrate_queue.py | 7 +++++++ 2 files changed, 13 insertions(+) diff --git a/scripts/narrate_book.py b/scripts/narrate_book.py index 4d98f61d..96359c24 100644 --- a/scripts/narrate_book.py +++ b/scripts/narrate_book.py @@ -208,6 +208,11 @@ def build_parser() -> argparse.ArgumentParser: run.add_argument("--author", default="", help="Author (assembled file, and credits)") story = parser.add_argument_group("generique") + story.add_argument("--no-synthetic-disclosure", action="store_true", + help="Ne pas dire « une voix de synthèse » dans les génériques. " + "ACX, Apple Books et Findaway l'exigent pour une narration " + "générée : le retirer est un choix d'éditeur, pas un réglage " + "de confort, et il expose au rejet ou au retrait.") story.add_argument("--voice-name", default="", help="Nom donné à la voix de synthèse dans les génériques " "(« Aurore Cabonet »). Le générique dit alors le nom ET " @@ -290,6 +295,7 @@ def main() -> int: author=args.author or (book.author if book else ""), narrator=args.narrator, voice_name=args.voice_name, + disclose_synthetic=not args.no_synthetic_disclosure, publisher=args.publisher, year=args.year, public_domain=args.public_domain, diff --git a/scripts/narrate_queue.py b/scripts/narrate_queue.py index a40fecf7..ea9ff52b 100644 --- a/scripts/narrate_queue.py +++ b/scripts/narrate_queue.py @@ -89,6 +89,8 @@ def main() -> int: ap.add_argument("--qc-retries", default="2") ap.add_argument("--only", type=int, help="ne traiter que les N premiers") ap.add_argument("--skip-repair", action="store_true") + ap.add_argument("--no-synthetic-disclosure", action="store_true", + help="Retirer la mention « voix de synthèse » de tous les génériques") ap.add_argument("--keep", choices=("all", "deliverables"), default="all", help="all : tout garder. deliverables : ne garder que le M4B, " "l'export ACX et le rapport, et effacer les WAV de chapitre " @@ -154,6 +156,11 @@ def save() -> None: nom_voix = b.get("voice_name") or VOICE_NAMES.get(b["voice"], "") if nom_voix: cmd += ["--voice-name", nom_voix] + # Choix d'éditeur, porté par la file plutôt que codé ici : la mention + # de voix de synthèse est exigée par les plateformes, et la retirer + # doit rester une décision visible dans les données. + if b.get("no_synthetic_disclosure") or args.no_synthetic_disclosure: + cmd += ["--no-synthetic-disclosure"] # Un livre peut avoir ses propres abréviations. Le lexique général est # passé d'abord, le sien ensuite : ils s'empilent, il ne le remplace pas. lexiques = ["conf/pronunciation_fr.json"] + list(b.get("lexicons") or []) From 3bc5fcb3a8c69d1b0837e0083e74e817b00eaed9 Mon Sep 17 00:00:00 2001 From: PaxHelios Date: Sat, 8 Aug 2026 22:37:31 +0200 Subject: [PATCH 68/98] fix(queue): "repaired" was counting attempts, not repairs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every book reported "19 segment(s) réparé(s)" and then, on the next line, "réparation incomplète". Both came from the same run and only one was true: the count was the number of fatal segments *found*, and repair_segment keeps the older take whenever the new one is worse — which it says, in as many words. Measured once on a real case: 0.6 s of babbling became 1.6 s on the retry, and the old take was rightly kept. A defect that cannot be re-rolled away is exactly the kind Edwin is hearing, so the number that matters is how many actually improved. The runner now counts what repair_segment reports rather than what the attempt hoped for, and records both: fatal_found and repaired. A book whose repairs all failed now says so instead of claiming nineteen fixes. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FBNmTCfFPDth6cWqg69MGu --- scripts/narrate_queue.py | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/scripts/narrate_queue.py b/scripts/narrate_queue.py index ea9ff52b..96bb670d 100644 --- a/scripts/narrate_queue.py +++ b/scripts/narrate_queue.py @@ -184,7 +184,7 @@ def save() -> None: # Réparation ciblée : un segment fatal ne justifie pas de refaire son # chapitre, encore moins le livre. - repaired = 0 + repaired = fatal = 0 if not args.skip_repair: rc, out = run([PYTHON, "scripts/repair_segment.py", str(outdir), "--list"]) # Le marqueur est « FATAL » en capitales (repair_segment.py:68). @@ -195,9 +195,18 @@ def save() -> None: if fatal: log(f" {fatal} segment(s) fatal(s) — réparation") rc, out = run([PYTHON, "scripts/repair_segment.py", str(outdir), - "--all-fatal", "--device", args.device], blog) - repaired = fatal - if rc != 0: + "--all-fatal", "--device", args.device]) + blog.open("a", encoding="utf-8").write(out) + # « réparé » disait jusqu'ici « fatal trouvé », ce qui promettait + # des corrections qui n'avaient pas eu lieu : repair_segment + # conserve l'ancienne prise quand la nouvelle est pire, et il le + # dit. Compter ce qu'il dit plutôt que ce qu'on espérait. + conserves = out.count("le nouvel essai est moins bon") + repaired = max(0, fatal - conserves) + if conserves: + log(f" {repaired} amélioré(s), {conserves} inchangé(s) " + f"(la nouvelle prise était pire)") + elif rc != 0: log(f" réparation incomplète (code {rc})") wavs = len(list(outdir.glob("*.wav"))) if outdir.is_dir() else 0 @@ -234,6 +243,7 @@ def save() -> None: log(f" WAV conservés : M4B ou export ACX manquant, rien n'est effacé") state[slug].update(status="done", minutes=round(mins, 1), chapters_wav=wavs, + fatal_found=fatal, repaired=repaired, freed_gb=round(freed / 1e9, 2), finished=time.strftime("%Y-%m-%d %H:%M:%S")) save() From e05101108d7ef48f22290e5a282732aedca2fde1 Mon Sep 17 00:00:00 2001 From: PaxHelios Date: Sat, 8 Aug 2026 23:01:38 +0200 Subject: [PATCH 69/98] feat(prepare): a chapter marker that says more than its number MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two things were missing from the listening copy, and Edwin found both by putting a book on his phone. No cover. Forty-six books in the catalogue carry one, named audio-cover.jpg — made for exactly this — and none of them reached the M4B. The queue now carries a cover path per book and narrate_book already knew what to do with it. And the chapter list read "Chapitre 1", "Chapitre 2". The marker title is the first line of the prepared text, and a manuscript writes "# Chapitre 1" with the real formulation underneath as "## Le mythe de la croissance infinie". The two are now joined, so the phone shows a table of contents worth reading and the narrator announces the whole title rather than a number. The rule stays cautious: it fires only on a heading that says nothing but its rank, and only when what follows is short and unpunctuated — a subtitle, not a paragraph. One chapter in eighteen kept its bare title, which is the correct outcome for that chapter. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FBNmTCfFPDth6cWqg69MGu --- scripts/narrate_queue.py | 3 ++- scripts/prepare_manuscript.py | 28 ++++++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/scripts/narrate_queue.py b/scripts/narrate_queue.py index 96bb670d..890e7ae8 100644 --- a/scripts/narrate_queue.py +++ b/scripts/narrate_queue.py @@ -169,7 +169,8 @@ def save() -> None: # Sans titre, narrate_book retombe sur le nom du fichier : cinq livres # se sont annoncés « livre-un-esprits-reprogrammes » avant qu'on le # remarque. Un .txt ne porte pas de métadonnées, donc la file les porte. - for option, cle in (("--title", "title"), ("--author", "author")): + for option, cle in (("--title", "title"), ("--author", "author"), + ("--cover", "cover")): if b.get(cle): cmd += [option, b[cle]] rc, tail = run(cmd, blog) diff --git a/scripts/prepare_manuscript.py b/scripts/prepare_manuscript.py index 62647dc7..f6685049 100644 --- a/scripts/prepare_manuscript.py +++ b/scripts/prepare_manuscript.py @@ -52,6 +52,33 @@ # two words, it belongs to the chapter that follows. PART_HEADING = re.compile(r"^\s*partie\b", re.IGNORECASE) +#: Un en-tête qui ne dit que son rang : « Chapitre 4 », « Introduction ». Le +#: manuscrit met la vraie formule juste en dessous, en niveau 2 — et comme le +#: marqueur du lecteur audio est la première ligne du chapitre, le sommaire +#: n'affichait que « Chapitre 4 ». +BARE_HEADING = re.compile( + r"^\s*(chapitre\s+[0-9IVXLC]+|introduction|conclusion|[ée]pilogue|prologue" + r"|avant[- ]propos|pr[ée]face|annexes?)\s*$", + re.IGNORECASE, +) + + +def join_subtitle(chapter: str) -> str: + """« Chapitre 1 » + « Le grand malentendu » = un titre qui dit quelque chose. + + Utile deux fois : le sommaire devient lisible sur un téléphone, et + l'annonce sonne juste, parce qu'un narrateur lit le titre entier plutôt que + son numéro seul. + """ + blocs = chapter.split("\n\n") + if len(blocs) < 2 or not BARE_HEADING.match(blocs[0].strip()): + return chapter + suite = blocs[1].strip() + # Un sous-titre est court et ne se termine pas ; un paragraphe fait les deux. + if not suite or len(suite) > 90 or suite.endswith((".", "!", "?", "…")): + return chapter + return "\n\n".join([f"{blocs[0].strip()} — {suite}"] + blocs[2:]) + @dataclass class Block: @@ -206,6 +233,7 @@ def to_chapters(blocks: List[Block]) -> tuple[List[str], List[str]]: if current: chapters.append("\n\n".join(current).strip()) + chapters = [join_subtitle(c) for c in chapters] return [c for c in chapters if c.strip()], removed From c61059be01e8ac511b2e831320887a64443a1166 Mon Sep 17 00:00:00 2001 From: PaxHelios Date: Sat, 8 Aug 2026 23:19:43 +0200 Subject: [PATCH 70/98] feat(lexicon): a domain dictionary for the catalogue's own vocabulary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Edwin proposed a preprocessing layer with per-domain pronunciation dictionaries. Most of what he sketched already exists — the STT feedback loop is audit_pronunciation.py, lexicons already stack, segmentation already works on sense groups, punctuation already drives the pauses — but the domain dictionaries did not, and his catalogue is specialised enough to need them. The engine settles the rest of the design. It has no phoneme, IPA, SSML or g2p path anywhere: an SSML tag tokenises to twenty tokens and would be read aloud, and the IPA symbols exist in the vocabulary only as ordinary characters, since the model was trained on graphemes. The cleanest option in his plan is therefore unavailable, which makes respelling — the fallback he rightly warns against — the only lever there is. One part of the design is not needed. "ces" and "ces" are not homographs: "ces" is always /se/ and "ce" always /sə/, and the engine is not choosing wrongly between them, it is swallowing the final s. No part-of-speech tagger changes that. Context does matter for the real homographs — plus, est, fils, couvent — and the lexicon has carried that since before this session. Entries ship disabled, with the measured frequency of each term across the twenty books beside them: TSPT 89 times, cortisol 60, EMDR 59, accumbens 3. A term read eighty-nine times costs eighty-nine mistakes. Nothing is enabled until it has been heard to be wrong. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FBNmTCfFPDth6cWqg69MGu --- conf/lexique/neurosciences.json | 39 +++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 conf/lexique/neurosciences.json diff --git a/conf/lexique/neurosciences.json b/conf/lexique/neurosciences.json new file mode 100644 index 00000000..8bcd3fea --- /dev/null +++ b/conf/lexique/neurosciences.json @@ -0,0 +1,39 @@ +{ + "_comment": "Termes de neurosciences et de psychotraumatologie du catalogue. S'empile sur conf/pronunciation_fr.json : python scripts/narrate_book.py ... --lexicon conf/pronunciation_fr.json --lexicon conf/lexique/neurosciences.json", + + "_regle": "Les entrées ci-dessous sont DÉSACTIVÉES (préfixe _ = ignoré). Écoutez d'abord : le moteur lit peut-être déjà « cortisol » ou « hippocampe » très bien, et une correction inutile ne peut que dégrader. Retirez le préfixe uniquement pour ce que vous avez entendu faux.", + + "_methode": "Pour trouver la bonne graphie : python scripts/try_pronunciation.py --phrase '...' --mot amygdale --candidats 'amigdale' 'a-mig-dale' --voice '...'. Le premier extrait produit est toujours le texte non modifié, pour savoir si la correction bat le défaut de départ.", + + "_frequences": "Mesurées sur les 20 livres de la file : TSPT 89, cortisol 60, EMDR 59, cortex 59, neuroplasticité 46, dopamine 41, préfrontal 36, hypervigilance 35, hippocampe 30, dissociation 29, rumination 28, sympathique 22, amygdale 20, parasympathique 19, vagal 15, accumbens 3. Un terme lu 89 fois coûte 89 fautes ; un terme lu 3 fois en coûte 3.", + + "_sigles": "--- sigles, à épeler ou non selon ce qu'on entend ---", + "_EMDR": "E M D R", + "_TSPT": "T S P T", + + "_latin": "--- termes latins, sans règle française qui les gouverne ---", + "_accumbens": "acumbennsse", + "_insula": "inn-sula", + + "_anatomie": "--- structures cérébrales ---", + "_amygdale": "amigdale", + "_hippocampe": "hipocampe", + "_thalamus": "talamusse", + "_hypothalamus": "ipotalamusse", + "_cingulaire": "saingulaire", + + "_hormones": "--- ---", + "_cortisol": "cortizol", + "_ocytocine": "ocitocine", + "_sérotonine": "sérotonine", + + "_etrangers": "--- mots anglais lus avec des règles françaises ---", + "_mindfulness": "maïndfoulness", + "_flashback": "flachbaque", + + "_noms": "--- noms propres du domaine ---", + "_Porges": "Porguesse", + "_Shapiro": "Chapiro", + "_Bessel van der Kolk": "Bessel vane der Kolk", + "_Francine": "Francine" +} From 1afc5e7fa5b7b773adaa746375128cec8c981a5b Mon Sep 17 00:00:00 2001 From: PaxHelios Date: Sun, 9 Aug 2026 00:29:48 +0200 Subject: [PATCH 71/98] fix(lexicon): "ces" and "ses" lost their final sound, and now keep it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Edwin heard it in the delivered books and named it precisely: the engine says "ce" where the text says "ces". He listened to four spellings and chose "cés". "ses" gets "sés" by the same logic — restore the accent, restore the /e/ — applied without a separate listen because the parallel is exact, and marked as such in the file so it can be pulled if it turns out otherwise. No context rule is needed. "ces" is always /se/ and "ce" always /sə/: they are different spellings, not homographs, and the engine is not choosing wrongly between them but swallowing a letter. That distinction is why "plus" and "fils" carry context conditions in this file and these two do not. _apply_lexicon now restores capitalisation. Matching ignores case, so "Ces" at the head of a sentence was becoming "cés" and opening a sentence in lower case for no reason. The rule applies to every entry, not just the one that revealed it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FBNmTCfFPDth6cWqg69MGu --- conf/pronunciation_fr.json | 14 ++++++-------- narration/text_fr.py | 17 ++++++++++++++++- 2 files changed, 22 insertions(+), 9 deletions(-) diff --git a/conf/pronunciation_fr.json b/conf/pronunciation_fr.json index 86225a87..751e4fe9 100644 --- a/conf/pronunciation_fr.json +++ b/conf/pronunciation_fr.json @@ -1,23 +1,17 @@ { "_comment": "Lexique de prononciation. Clé = ce qui est écrit dans le texte, valeur = ce qui doit être prononcé. Le remplacement est insensible à la casse et ne s'applique qu'à des mots entiers. Les clés commençant par _ sont ignorées (commentaires). Utile surtout pour les noms propres, les sigles et les mots étrangers d'un livre donné.", - "_exemple_sigles": "--- sigles lus lettre par lettre ---", "SNCF": "S N C F", "RATP": "R A T P", "ONU": "O N U", "URSS": "U R S S", - "_exemple_etrangers": "--- mots étrangers ---", "Wi-Fi": "wifi", "email": "i-mail", - "_exemple_noms": "--- noms propres à adapter à votre livre ---", "Nietzsche": "Nitche", - "_comment_contexte": "Une valeur peut aussi être un objet, pour ne remplacer QUE dans un contexte donné : {\"prononcer\": \"...\", \"après\": \"regex\", \"avant\": \"regex\"}. C'est le seul moyen de traiter un homographe : « il est » et « à l'est » s'écrivent pareil et ne se disent pas pareil, donc une règle qui vise le mot seul casse forcément l'un des deux.", - "_comment_homographes": "Les entrées ci-dessous sont des MODÈLES, volontairement désactivées (préfixe _ = ignoré). Écoutez d'abord : si la voix lit déjà correctement « l'est » ou « le couvent », n'y touchez pas — une correction inutile ne peut que dégrader. Quand vous en repérez une fausse, recopiez la ligne sans le préfixe et ajustez l'orthographe phonétique à l'oreille.", - "_est": { "prononcer": "èsste", "après": "à l'|dans l'|vers l'|de l'|l'", @@ -57,5 +51,9 @@ "prononcer": "pluss", "avant": "de|que|d'", "_pourquoi": "le s se prononce dans « plus de dix », pas dans « plus tard »" - } -} + }, + "_comment_ces": "Validé à l'oreille par Edwin le 2026-08-09 : le moteur avale le s final de « ces » et le dit « ce ». « cés » rétablit le son /se/. Ce n'est pas un homographe — « ces » se dit toujours /se/ — donc aucune règle de contexte n'est nécessaire ici, contrairement à « plus » ou « fils ».", + "ces": "cés", + "_comment_ses": "Même défaut que « ces », même correction par analogie : l'accent rétablit le /e/ final. Appliqué sans validation directe à l'oreille — le parallèle est exact, mais si « sés » sonne faux, retirez cette ligne.", + "ses": "sés" +} \ No newline at end of file diff --git a/narration/text_fr.py b/narration/text_fr.py index d80741ab..9d12f77f 100644 --- a/narration/text_fr.py +++ b/narration/text_fr.py @@ -346,7 +346,22 @@ def _apply_lexicon(text: str, lexicon: Mapping[str, object]) -> str: pattern = re.compile(word + (rf"(?=\s*(?:{entry.before}))" if entry.before else ""), re.IGNORECASE) replacement = entry.spoken.replace("\\", "\\\\") - text = pattern.sub(replacement, text) + + if entry.after: + text = pattern.sub(replacement, text) + else: + # La correspondance ignore la casse, donc « Ces » en tête de phrase + # tomberait sur « cés » en minuscule. Rendre la majuscule quand le + # mot d'origine en portait une : une phrase qui commence en + # minuscule est une anomalie gratuite, et la règle vaut pour toutes + # les entrées, pas seulement celle qui l'a révélée. + def _garder_la_casse(m: re.Match) -> str: + trouve = m.group(0) + if trouve[:1].isupper() and entry.spoken[:1].islower(): + return entry.spoken[:1].upper() + entry.spoken[1:] + return entry.spoken + + text = pattern.sub(_garder_la_casse, text) return text From d06220ffebd3ada8bd81211486205a79eb287a1e Mon Sep 17 00:00:00 2001 From: PaxHelios Date: Sun, 9 Aug 2026 01:32:17 +0200 Subject: [PATCH 72/98] fix(prepare): one dash in a chapter title, not two MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first book to carry the new markers showed the flaw: "Chapitre 1 — La fin du culte du charisme — Pourquoi l'extraversion à cessé d'êtr". The subtitle already carried its own dash, joining added a second, and chapter_title cut the result at eighty characters mid-word. A subtitle that dashes into a longer gloss has its real title in front. Take that part and leave the gloss out — the marker has to read on a phone, not carry the whole sentence. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FBNmTCfFPDth6cWqg69MGu --- scripts/prepare_manuscript.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/scripts/prepare_manuscript.py b/scripts/prepare_manuscript.py index f6685049..c013950f 100644 --- a/scripts/prepare_manuscript.py +++ b/scripts/prepare_manuscript.py @@ -77,6 +77,13 @@ def join_subtitle(chapter: str) -> str: # Un sous-titre est court et ne se termine pas ; un paragraphe fait les deux. if not suite or len(suite) > 90 or suite.endswith((".", "!", "?", "…")): return chapter + # « La fin du culte du charisme — Pourquoi l'extraversion a cessé… » porte + # déjà son propre tiret : recoller tel quel donnerait deux tirets et un + # marqueur tronqué à 80 caractères. Le titre est ce qui précède. + for coupure in (" — ", " – ", " : "): + if coupure in suite: + suite = suite.split(coupure, 1)[0].strip() + break return "\n\n".join([f"{blocs[0].strip()} — {suite}"] + blocs[2:]) From 8103006beca00bdb03e3857908a0e90bd8ad111f Mon Sep 17 00:00:00 2001 From: PaxHelios Date: Sun, 9 Aug 2026 04:36:39 +0200 Subject: [PATCH 73/98] fix(prepare): the mispronounced word was a misspelled one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Edwin reported "degré" coming out as "degre", and the lexicon was the obvious place to fix it. It was the wrong place. The manuscript writes "degres", without the accent, and the engine read exactly what it was given. Measured across the queue: twenty-two files of twenty-four carry the defect. 31 "maniere", 17 "difference", 7 "degre" and "degres", plus "plongee", "societe", "desir", "dedicace". Every one of them reaches the listener as a pronunciation fault that nobody made at synthesis. Accents are now restored in the text, which is the only honest repair — a lexicon entry would have replaced a misspelled word with an invented spelling and stacked two approximations instead of removing one. The list is short and hand-checked, and holds only words whose unaccented form is not a French word. "cote", "tache", "sur", "mure", "pecheur" all have two legitimate readings and are deliberately absent: correcting those blind would trade one fault for another. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FBNmTCfFPDth6cWqg69MGu --- scripts/prepare_manuscript.py | 55 +++++++++++++++++++++++++++++++- tests/test_prepare_manuscript.py | 37 +++++++++++++++++++++ 2 files changed, 91 insertions(+), 1 deletion(-) diff --git a/scripts/prepare_manuscript.py b/scripts/prepare_manuscript.py index c013950f..d78e0391 100644 --- a/scripts/prepare_manuscript.py +++ b/scripts/prepare_manuscript.py @@ -145,6 +145,58 @@ def keep_inside(m: re.Match) -> str: return text, removed +#: Mots dont la forme sans accent n'est pas un mot français : la restaurer ne +#: peut donc pas créer d'ambiguïté. Volontairement court et vérifié à la main — +#: « cote », « tache », « sur », « mure », « pecheur » ont tous deux lectures +#: légitimes et n'ont rien à faire ici. +#: +#: Pourquoi c'est nécessaire : le manuscrit écrit « degres », le moteur lit +#: « degre », et l'auditeur entend une faute que personne n'a commise à la +#: synthèse. Mesuré sur vingt-deux fichiers sur vingt-quatre. +ACCENTS_PERDUS = { + "degre": "degré", "degres": "degrés", "maniere": "manière", + "difference": "différence", "differences": "différences", + "plongee": "plongée", "societe": "société", "societes": "sociétés", + "desir": "désir", "desirs": "désirs", "dedicace": "dédicace", + "annee": "année", "annees": "années", "realite": "réalité", + "realites": "réalités", "probleme": "problème", "problemes": "problèmes", + "systeme": "système", "systemes": "systèmes", "modele": "modèle", + "modeles": "modèles", "premiere": "première", "premieres": "premières", + "derniere": "dernière", "dernieres": "dernières", "matiere": "matière", + "matieres": "matières", "experience": "expérience", + "experiences": "expériences", "etre": "être", "etait": "était", + "etaient": "étaient", "meme": "même", "memes": "mêmes", "tres": "très", + "apres": "après", "present": "présent", "presente": "présente", + "reponse": "réponse", "reponses": "réponses", "resultat": "résultat", + "resultats": "résultats", "periode": "période", "periodes": "périodes", + "sante": "santé", "verite": "vérité", "verites": "vérités", +} +_ACCENTS_RE = re.compile( + r"(? tuple[str, list[str]]: + """Rendre aux mots l'accent que le manuscrit leur a pris. + + Le moteur lit ce qui est écrit : « degres » se dit « degre », et l'auditeur + entend une faute de prononciation là où il y a une faute d'orthographe. La + corriger dans le texte est la seule réparation juste — un lexique + remplacerait un mot mal écrit par une graphie inventée, ce qui empile deux + approximations au lieu d'en retirer une. + """ + trouves: list[str] = [] + + def remplacer(m: re.Match) -> str: + mot = m.group(0) + juste = ACCENTS_PERDUS[mot.lower()] + trouves.append(f"accent rendu : {mot} → {juste}") + return juste[:1].upper() + juste[1:] if mot[:1].isupper() else juste + + return _ACCENTS_RE.sub(remplacer, text), trouves + + def strip_inline(text: str) -> str: """Remove the marks that are silent on a page and spoken by an engine.""" text = re.sub(r"!\[[^\]]*\]\([^)]*\)", "", text) # images: nothing to say @@ -269,8 +321,9 @@ def main() -> int: # Avant tout découpage : un « [PAUSE] » devenu saut de paragraphe doit # pouvoir séparer deux paragraphes, ce que le parseur lira ensuite. md, removed_brackets = unbracket(md) + md, removed_accents = restore_accents(md) blocks, removed_parse = parse(md) - removed_parse = removed_parse + removed_brackets + removed_parse = removed_parse + removed_brackets + removed_accents chapters, removed_struct = to_chapters(blocks) if not chapters: diff --git a/tests/test_prepare_manuscript.py b/tests/test_prepare_manuscript.py index b5c83ceb..8872fae0 100644 --- a/tests/test_prepare_manuscript.py +++ b/tests/test_prepare_manuscript.py @@ -68,3 +68,40 @@ def test_a_link_keeps_its_words_and_loses_its_target(self): def test_an_image_says_nothing(self): assert prepare_manuscript.strip_inline("![couverture](img.png)") == "" + + +class TestAccentsRestored: + """Le moteur lit ce qui est écrit, y compris les fautes du manuscrit. + + Edwin a signalé « degré » prononcé « degre ». Ce n'était pas la synthèse : + le manuscrit écrit « degres » sans accent, et le moteur avait raison. Vingt- + deux fichiers sur vingt-quatre portent ce défaut — 31 « maniere », 17 + « difference », 7 « degre(s) ». + """ + + @pytest.mark.parametrize("faute,juste", [ + ("degres", "degrés"), ("maniere", "manière"), ("difference", "différence"), + ("plongee", "plongée"), ("societe", "société"), ("dedicace", "dédicace"), + ]) + def test_a_lost_accent_comes_back(self, faute, juste): + out, notes = prepare_manuscript.restore_accents(f"une {faute} ici") + assert juste in out + assert notes + + def test_capitalisation_survives(self): + out, _ = prepare_manuscript.restore_accents("Dedicace au lecteur") + assert out.startswith("Dédicace") + + @pytest.mark.parametrize("ambigu", ["cote", "tache", "sur", "mure", "pecheur"]) + def test_words_with_two_readings_are_left_alone(self, ambigu): + # « la cote atlantique » et « la côte » sont deux mots : corriger à + # l'aveugle remplacerait une faute par une autre. + texte = f"voici le mot {ambigu} dans sa phrase" + out, notes = prepare_manuscript.restore_accents(texte) + assert out == texte + assert notes == [] + + def test_a_clean_text_is_untouched(self): + texte = "Un texte déjà correctement accentué, avec ses différences." + out, notes = prepare_manuscript.restore_accents(texte) + assert out == texte and notes == [] From 8b7f58253fc1b7aebf83424655195a85bd1f4a22 Mon Sep 17 00:00:00 2001 From: PaxHelios Date: Sun, 9 Aug 2026 07:24:37 +0200 Subject: [PATCH 74/98] fix(quality): a lone chapter title is not a runaway generation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "0 amélioré(s), 3 inchangé(s)" — the first book where every repair failed. Two of the three defects were these: ch002/seg001 1.44s 6 car/s « Dedicace » ch003/seg019 1.44s 6 car/s « EPILOGUE » A single word takes about a second however long it is, so measuring characters per second says nothing at that scale — and the runaway rule, which fires below six, declared both of them broken. The repairs then failed because there was nothing to repair, and the report claimed three defects where there was one. The rate rule now applies only above twenty-five characters. Below that, duration carries no information about the text, and min_duration_sec already catches a generation that returned nothing. The third defect was real: a whole paragraph returned in 0.64 seconds. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FBNmTCfFPDth6cWqg69MGu --- narration/quality.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/narration/quality.py b/narration/quality.py index bba85da4..a493e036 100644 --- a/narration/quality.py +++ b/narration/quality.py @@ -106,6 +106,12 @@ class QualityThresholds: truncated_chars_per_second: float = 35.0 #: Below this, there is far more audio than the text can account for. runaway_chars_per_second: float = 6.0 + #: En deçà, le débit ne mesure plus rien. Un mot seul — « Dédicace », + #: « ÉPILOGUE », un titre de chapitre isolé — prend une seconde quelle que + #: soit sa longueur, et la règle du débit le déclarait alors emballé. + #: Mesuré : deux « défauts » sur trois d'un livre étaient des titres, et + #: leurs réparations échouaient parce qu'il n'y avait rien à réparer. + min_chars_for_rate: int = 25 #: Segments shorter than this are treated as a failed generation outright. min_duration_sec: float = 0.2 #: A segment whose peak sits below this carries no speech at all. @@ -374,7 +380,10 @@ def inspect_segment( f"{characters} caractères en {duration:.1f}s (~{expected:.1f}s attendues)", ) ) - elif rate < thresholds.runaway_chars_per_second: + elif ( + rate < thresholds.runaway_chars_per_second + and characters >= thresholds.min_chars_for_rate + ): expected = characters / thresholds.expected_chars_per_second issues.append( Issue( From 5fe38139445e60c3c8c525bd3b7a00ca2127b154 Mon Sep 17 00:00:00 2001 From: PaxHelios Date: Sun, 9 Aug 2026 07:29:51 +0200 Subject: [PATCH 75/98] fix(quality): bounding the rate rule left a hole, now closed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit stopped the rate rule below twenty-five characters, so a lone chapter title would no longer be called a runaway. It also stopped catching a genuine one: three characters returned as twenty seconds of audio is broken, and the existing test said so. Below the length threshold, duration alone judges. A word takes about a second however long it is, and never ten. "Dédicace" at 1.44s is healthy, "Dédicace" at twenty seconds is not, and both are now decided correctly. Pushed the previous commit while that test was red — the push runs after the commit whether or not pytest passed. This one was gated on a green suite. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FBNmTCfFPDth6cWqg69MGu --- narration/quality.py | 9 +++++++++ tests/test_narration_quality.py | 22 ++++++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/narration/quality.py b/narration/quality.py index a493e036..541d651c 100644 --- a/narration/quality.py +++ b/narration/quality.py @@ -112,6 +112,12 @@ class QualityThresholds: #: Mesuré : deux « défauts » sur trois d'un livre étaient des titres, et #: leurs réparations échouaient parce qu'il n'y avait rien à réparer. min_chars_for_rate: int = 25 + #: Pour un texte trop court pour que le débit signifie quelque chose, c'est + #: la durée seule qui juge : un mot met une seconde, jamais dix. Sans cette + #: borne, borner la règle du débit laisserait passer un vrai emballement + #: sur un titre — « Dédicace » en vingt secondes est aussi cassé que la + #: même chose sur un paragraphe. + max_short_segment_sec: float = 4.0 #: Segments shorter than this are treated as a failed generation outright. min_duration_sec: float = 0.2 #: A segment whose peak sits below this carries no speech at all. @@ -383,6 +389,9 @@ def inspect_segment( elif ( rate < thresholds.runaway_chars_per_second and characters >= thresholds.min_chars_for_rate + ) or ( + characters < thresholds.min_chars_for_rate + and duration > thresholds.max_short_segment_sec ): expected = characters / thresholds.expected_chars_per_second issues.append( diff --git a/tests/test_narration_quality.py b/tests/test_narration_quality.py index f7caf2eb..a950d176 100644 --- a/tests/test_narration_quality.py +++ b/tests/test_narration_quality.py @@ -474,3 +474,25 @@ def test_reference_thresholds_separate_the_measured_recordings(): assert bounds.min_chars_per_second > 10.4 assert bounds.min_chars_per_second < 15.9 assert bounds.max_chars_per_second > 19.0 + + +def test_a_lone_title_is_not_a_runaway(): + """Un mot seul met une seconde, quelle que soit sa longueur. + + Mesuré : « Dedicace » et « EPILOGUE », titres isolés en début de chapitre, + revenaient à 6 caractères par seconde et étaient déclarés emballés. Les + réparations échouaient ensuite, faute de défaut à réparer. + """ + report = quality.inspect_segment(with_edges(speech(1.44)), SR, "Dédicace") + assert "runaway" not in report.codes + + +def test_a_short_text_is_still_judged_on_duration(): + """Borner la règle du débit ne doit pas ouvrir un trou. + + « Dédicace » en vingt secondes est aussi cassé que la même chose sur un + paragraphe : sous le seuil de longueur, c'est la durée seule qui juge. + """ + report = quality.inspect_segment(with_edges(speech(20.0)), SR, "Dédicace") + assert "runaway" in report.codes + assert report.fatal From 2325816d7ff3266d96daca36a8da8232ba50b465 Mon Sep 17 00:00:00 2001 From: PaxHelios Date: Sun, 9 Aug 2026 10:51:58 +0200 Subject: [PATCH 76/98] fix(text_fr): a superscript letter killed a book forty-one minutes in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AssertionError: assert len(input) > 0 wetext/token_parser.py, load() The narration of "JE ME SOUVIENS" died there, and the segment that did it read: le DSM-5-TR (Diagnostic and Statistical Manual, 5ᵉ édition révisée…) "5ᵉ" carries U+1D49, MODIFIER LETTER SMALL E. It looks like an ordinary "e" and is not one, so the ordinal rule never sees it: "la 5e édition" normalises to "la cinquième édition" and "la 5ᵉ édition" passes through untouched, all the way to the engine's own normaliser, which chokes on a token that reduces to nothing. Eighteen occurrences across nine files of the queue. Every one of them was a book waiting to fail at whatever minute it reached them. Superscript modifier letters are folded to their plain forms first thing in _clean_typography, before any rule that reads them — which fixes the crash and the pronunciation in the same move, since "5ᵉ" now becomes "cinquième" rather than whatever the engine would have made of it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FBNmTCfFPDth6cWqg69MGu --- narration/text_fr.py | 17 +++++++++++++++++ tests/test_narration_text_fr.py | 23 +++++++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/narration/text_fr.py b/narration/text_fr.py index 9d12f77f..14f81871 100644 --- a/narration/text_fr.py +++ b/narration/text_fr.py @@ -268,11 +268,28 @@ def _spell_decimal(whole: str, frac: str) -> str: # -------------------------------------------------------------------------- +#: Lettres modificatives en exposant, telles qu'un traitement de texte les +#: produit pour « 5ᵉ » ou « 1ʳᵉ ». Elles ressemblent à leurs équivalents +#: ordinaires et n'en sont pas : la règle des ordinaux ne les voit pas, « 5ᵉ » +#: traverse la normalisation intact, et le normaliseur interne du moteur meurt +#: dessus — assert len(input) > 0, après quarante et une minutes de narration. +_EXPOSANTS = { + "ᵃ": "a", "ᵇ": "b", "ᶜ": "c", "ᵈ": "d", "ᵉ": "e", "ᶠ": "f", "ᵍ": "g", + "ʰ": "h", "ⁱ": "i", "ʲ": "j", "ᵏ": "k", "ˡ": "l", "ᵐ": "m", "ⁿ": "n", + "ᵒ": "o", "ᵖ": "p", "ʳ": "r", "ˢ": "s", "ᵗ": "t", "ᵘ": "u", "ᵛ": "v", + "ʷ": "w", "ˣ": "x", "ʸ": "y", "ᶻ": "z", +} +_EXPOSANTS_RE = re.compile("|".join(map(re.escape, _EXPOSANTS))) + + def _clean_typography(text: str) -> str: """Normalise Unicode punctuation to forms the engine handles predictably.""" text = unicodedata.normalize("NFC", text) text = text.replace("’", "'").replace("‘", "'") text = text.replace("“", '"').replace("”", '"') + # Avant tout le reste : « 5ᵉ » doit redevenir « 5e » pour que la règle des + # ordinaux le lise, sinon il arrive intact jusqu'au moteur. + text = _EXPOSANTS_RE.sub(lambda m: _EXPOSANTS[m.group(0)], text) text = re.sub(r"[   ]", " ", text) text = re.sub(r"\.{3,}", "…", text) return text diff --git a/tests/test_narration_text_fr.py b/tests/test_narration_text_fr.py index 9e40b010..dfd0501a 100644 --- a/tests/test_narration_text_fr.py +++ b/tests/test_narration_text_fr.py @@ -334,3 +334,26 @@ def test_a_json_list_is_not_a_lexicon(self, tmp_path): path = tmp_path / "list.json" path.write_text("[1, 2]", encoding="utf-8") assert load_lexicon(path) == {} + + +class TestSuperscriptLetters: + """« 5ᵉ » n'est pas « 5e », et cette différence a tué une narration. + + Un traitement de texte produit une lettre modificative en exposant (U+1D49) + qui ressemble à un « e » sans en être un. La règle des ordinaux ne la voit + pas, le fragment traverse la normalisation intact, et le normaliseur interne + du moteur meurt dessus — assert len(input) > 0 — après quarante et une + minutes de narration. Dix-huit occurrences dans neuf fichiers de la file. + """ + + def test_a_superscript_ordinal_is_spoken(self): + assert normalize_french("la 5ᵉ édition") == "la cinquième édition" + + def test_a_two_digit_superscript_ordinal(self): + assert normalize_french("la 11ᵉ édition") == "la onzième édition" + + def test_a_feminine_first(self): + assert normalize_french("la 1ʳᵉ fois") == "la première fois" + + def test_the_plain_form_still_works(self): + assert normalize_french("la 5e édition") == "la cinquième édition" From e30baea8764655ce537addff340d31693efa45f5 Mon Sep 17 00:00:00 2001 From: PaxHelios Date: Sun, 9 Aug 2026 17:38:46 +0200 Subject: [PATCH 77/98] feat(text_fr): say the symbols a manuscript keeps, or say nothing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A systematic sweep of the twenty-one queued books turned up forty-three characters outside ordinary French, and most of them reach the listener. The counts are the argument: 571 fill-in rules, 163 footnote markers, 107 middle dots, 55 word-processor commands, 47 degree signs, 26 ampersands, 21 interface paths, 20 arrows, 8 checkboxes. One was worse than untouched. "\newpage" lost its backslash to the markdown cleaner and became "ewpage", pronounced as a word in the middle of a chapter. Word-processor commands are now removed first, before anything can strip the backslash that identifies them. The rest become words: 18,5 °C is spoken in degrees Celsius, "&" is "et", "×" and "=" are read, arrows and "Réglages > Temps d'écran" become "puis", and "conjoint·e" is read out as "conjoint ou conjointe" — which needs the whole word to rebuild, since the ending alone gives "conjoint ou e". Form leftovers, checkboxes and footnote asterisks are dropped, because they are page furniture and not speech. Order matters in one place and the test says so: without the Celsius rule before the general degree rule, "18,5 °C" becomes "18,5 degrésC". Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FBNmTCfFPDth6cWqg69MGu --- narration/text_fr.py | 51 +++++++++++++++++++++++++++++++++ tests/test_narration_text_fr.py | 47 ++++++++++++++++++++++++++++++ 2 files changed, 98 insertions(+) diff --git a/narration/text_fr.py b/narration/text_fr.py index 14f81871..701f1996 100644 --- a/narration/text_fr.py +++ b/narration/text_fr.py @@ -268,6 +268,56 @@ def _spell_decimal(whole: str, frac: str) -> str: # -------------------------------------------------------------------------- +def _clean_symbols(text: str) -> str: + """Traduire en mots les signes qu'un manuscrit garde et qu'on ne dit pas. + + Un manuscrit n'est pas que de la prose : il porte des restes de mise en + page, des cases à cocher, des flèches, de l'écriture inclusive, des + commandes de traitement de texte. Le moteur les lit — ou pire, il en lit + une partie : `` +ewpage`` perdait sa barre oblique au nettoyage markdown et + devenait « ewpage », prononcé tel quel au milieu d'un chapitre. + + Chaque règle vient d'un relevé sur les vingt et un livres de la file, pas + d'une liste imaginée : 571 lignes à remplir, 163 appels de note, 107 points + médians, 55 commandes LaTeX, 47 degrés, 26 esperluettes. + """ + # D'abord les commandes de traitement de texte : le nettoyage markdown + # mangerait la barre oblique et laisserait « ewpage », lu tel quel. + text = re.sub(r"\\[a-zA-Z]+\*?(?:\{[^}]*\})*", " ", text) + + # Unités collées à un nombre. L'ordre compte : sans la règle Celsius avant + # la règle générale, « 18,5 °C » deviendrait « 18,5 degrésC ». + text = re.sub(r"\s*°\s*C(?![a-zà-ÿ])", " degrés Celsius", text) + text = re.sub(r"\s*°\s*F(?![a-zà-ÿ])", " degrés Fahrenheit", text) + text = re.sub(r"(\d)\s*°", r"\1 degrés", text) + + # Signes mathématiques au fil d'une phrase. + text = re.sub(r"\s*×\s*", " fois ", text) + text = re.sub(r"(?<=[\w)])\s*=\s*(?=[\w(])", " égale ", text) + text = re.sub(r"\s*&\s*", " et ", text) + + # Flèches et chemins d'interface : « Réglages > Temps d'écran ». + text = re.sub(r"\s*[→⟶➜]\s*", " puis ", text) + text = re.sub(r"(?<=[a-zà-ÿ0-9])\s*>\s*(?=[A-ZÀ-Þa-zà-ÿ])", " puis ", text) + + # Écriture inclusive : « conjoint·e » se dit « conjoint ou conjointe ». Il + # faut le mot entier pour reconstruire la forme accordée ; la terminaison + # seule ne suffit pas, et « conjoint ou e » ne veut rien dire. + text = re.sub( + r"([a-zà-ÿ]{2,})[·‧∙]([a-zà-ÿ]{1,3})(?![a-zà-ÿ])", + lambda m: f"{m.group(1)} ou {m.group(1)}{m.group(2)}", + text, + ) + + # Restes de formulaire : lignes à remplir, cases à cocher, appels de note. + text = re.sub(r"_{2,}", " ", text) + text = re.sub(r"[☐☑✓✗▢]", " ", text) + text = re.sub(r"(?<=[a-zà-ÿ])\*(?=[\s,.;:)])", "", text) + + return re.sub(r"[ ]{2,}", " ", text) + + #: Lettres modificatives en exposant, telles qu'un traitement de texte les #: produit pour « 5ᵉ » ou « 1ʳᵉ ». Elles ressemblent à leurs équivalents #: ordinaires et n'en sont pas : la règle des ordinaux ne les voit pas, « 5ᵉ » @@ -519,6 +569,7 @@ def normalize_french( return "" text = _clean_typography(text) + text = _clean_symbols(text) if strip_markdown: text = _strip_markdown(text) if lexicon: diff --git a/tests/test_narration_text_fr.py b/tests/test_narration_text_fr.py index dfd0501a..07c02813 100644 --- a/tests/test_narration_text_fr.py +++ b/tests/test_narration_text_fr.py @@ -357,3 +357,50 @@ def test_a_feminine_first(self): def test_the_plain_form_still_works(self): assert normalize_french("la 5e édition") == "la cinquième édition" + + +class TestSymbolsAManuscriptKeeps: + """Un manuscrit n'est pas que de la prose. + + Relevé sur les vingt et un livres de la file : 571 lignes à remplir, 163 + appels de note, 107 points médians, 55 commandes LaTeX, 47 degrés, 26 + esperluettes, 21 chemins d'interface, 20 flèches, 8 cases à cocher. Chacun + se lit à voix haute, ou pire : « \newpage » perdait sa barre oblique au + nettoyage markdown et devenait « ewpage », prononcé tel quel. + """ + + def test_a_word_processor_command_leaves_nothing_behind(self): + # La barre oblique doit être littérale : écrite « \n », elle devient un + # saut de ligne et le test reproduit le défaut qu'il vérifie. + assert "ewpage" not in normalize_french("gratuits. " + chr(92) + "newpage Voici") + + def test_degrees_celsius_are_spoken(self): + assert normalize_french("réglé à 18,5 °C") == "réglé à dix-huit virgule cinq degrés Celsius" + + def test_the_celsius_rule_wins_over_the_general_one(self): + # Sans l'ordre, « 18,5 °C » deviendrait « 18,5 degrésC ». + assert "degrésC" not in normalize_french("réglé à 18,5 °C") + + def test_an_ampersand_becomes_a_word(self): + assert normalize_french("Sparrow, Liu & Wegner") == "Sparrow, Liu et Wegner" + + def test_inclusive_writing_is_read_in_full(self): + assert normalize_french("votre conjoint·e") == "votre conjoint ou conjointe" + + @pytest.mark.parametrize("source,attendu", [ + ("Réglages > Temps", "Réglages puis Temps"), + ("Tête → visage", "Tête puis visage"), + ]) + def test_arrows_and_interface_paths_become_puis(self, source, attendu): + assert normalize_french(source) == attendu + + def test_form_leftovers_are_removed(self): + assert "_" not in normalize_french("Date: ___ fin: ___") + assert "☐" not in normalize_french("☐ Je consulte") + + def test_a_footnote_marker_is_not_spoken(self): + assert normalize_french("Jamie*, trente ans") == "Jamie, trente ans" + + def test_ordinary_prose_is_untouched(self): + texte = "Une phrase parfaitement ordinaire, sans aucun signe particulier." + assert normalize_french(texte) == texte From 6882cb752bb8947cd9b28b02af5d3666dafb2452 Mon Sep 17 00:00:00 2001 From: PaxHelios Date: Sun, 9 Aug 2026 21:01:39 +0200 Subject: [PATCH 78/98] feat(audit): one ranked list for the catalogue, not one report per book MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pronunciation audit worked and was useless at scale: three hundred books would produce three hundred reports, and nobody reads three hundred reports. What is wanted is a single ranked list, fed by every book, reviewed once. --merge accumulates into one file, counting occurrences across books and recording which books each word came from. A word heard wrong in eight books gets corrected before one heard wrong once, at equal listening time — the same ordering principle the risky-word scan already uses. The runner can now run it per book, and does so before the sweep rather than after: the audit reads the segment cache, and --keep deliverables deletes it. Getting that order wrong would have made the whole thing silently empty. It stays opt-in. Whisper costs GPU minutes per book, and a catalogue owner should decide whether to spend them. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FBNmTCfFPDth6cWqg69MGu --- scripts/audit_pronunciation.py | 21 +++++++++++++++++++++ scripts/narrate_queue.py | 13 +++++++++++++ 2 files changed, 34 insertions(+) diff --git a/scripts/audit_pronunciation.py b/scripts/audit_pronunciation.py index ce41b864..fdfb1531 100644 --- a/scripts/audit_pronunciation.py +++ b/scripts/audit_pronunciation.py @@ -251,6 +251,27 @@ def main() -> int: ensure_ascii=False, indent=2), encoding="utf-8") print(f"\nrapport : {args.json}") + if args.merge: + cumul_path = pathlib.Path(args.merge) + try: + cumul = json.loads(cumul_path.read_text(encoding="utf-8")) + except (OSError, ValueError): + cumul = {} + for mot, n in suspects.items(): + entree = cumul.setdefault(mot, {"occurrences": 0, "livres": [], "entendu": ""}) + entree["occurrences"] += n + if d.name not in entree["livres"]: + entree["livres"].append(d.name) + entree["entendu"] = entree["entendu"] or exemples.get(mot, "")[:110] + # Trié par fréquence : un mot vu dans huit livres se corrige avant un + # mot vu une fois, à temps d'écoute égal. + ordonne = dict(sorted(cumul.items(), key=lambda kv: -kv[1]["occurrences"])) + cumul_path.parent.mkdir(parents=True, exist_ok=True) + cumul_path.write_text(json.dumps(ordonne, ensure_ascii=False, indent=2), encoding="utf-8") + recurrents = [m for m, v in ordonne.items() if len(v["livres"]) >= 2] + print(f"\ncumul : {len(ordonne)} mot(s), dont {len(recurrents)} vu(s) dans " + f"plusieurs livres — {cumul_path}") + print("\nRien n'est corrigé ici. Les candidats passent par try_pronunciation.py,") print("et seul ce qui a été entendu entre dans le lexique.") return 0 diff --git a/scripts/narrate_queue.py b/scripts/narrate_queue.py index 890e7ae8..9f3eafcf 100644 --- a/scripts/narrate_queue.py +++ b/scripts/narrate_queue.py @@ -89,6 +89,10 @@ def main() -> int: ap.add_argument("--qc-retries", default="2") ap.add_argument("--only", type=int, help="ne traiter que les N premiers") ap.add_argument("--skip-repair", action="store_true") + ap.add_argument("--audit", type=int, metavar="N", default=0, + help="relire N segments par livre avec la reconnaissance vocale et " + "cumuler les mots suspects dans queue/prononciation_a_valider.json " + "(0 = ne pas auditer)") ap.add_argument("--no-synthetic-disclosure", action="store_true", help="Retirer la mention « voix de synthèse » de tous les génériques") ap.add_argument("--keep", choices=("all", "deliverables"), default="all", @@ -210,6 +214,15 @@ def save() -> None: elif rc != 0: log(f" réparation incomplète (code {rc})") + # L'audit doit passer AVANT le balayage : il lit le cache de segments, + # que --keep deliverables efface. Il cumule dans un classement unique — + # trois cents rapports isolés ne seraient jamais relus, un seul l'est. + if args.audit: + log(f" audit de prononciation ({args.audit} segments)") + run([PYTHON, "scripts/audit_pronunciation.py", str(outdir), + "--sample", str(args.audit), "--device", args.device, + "--merge", str(qpath.parent / "prononciation_a_valider.json")], blog) + wavs = len(list(outdir.glob("*.wav"))) if outdir.is_dir() else 0 # Un livre laisse ~3 Go de WAV de chapitre derrière lui. Vingt livres From 84f12d42819eadd0adab05ab9e9c1fe0d53aeb9a Mon Sep 17 00:00:00 2001 From: PaxHelios Date: Sun, 9 Aug 2026 23:00:43 +0200 Subject: [PATCH 79/98] fix(audit): --merge was documented, wired, and never declared MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The runner called audit_pronunciation with --merge, the merge code was there, and argparse rejected the flag: "unrecognized arguments: --merge". The audit died in under a second, the runner logged that it had run, and the ranked list was never written. The cause is a script of mine that did two replacements and asserted between them. The second assertion failed, so the file was never written at all — and the first change, the argparse option, went with it. I then added the merge logic with an editor and never noticed the option was missing, because the runner reports that the step ran, not that it worked. Two lessons, one of them already applied: a step that cannot fail loudly should not be trusted quietly. The audit now runs and writes; the next book will produce the first ranked list. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FBNmTCfFPDth6cWqg69MGu --- scripts/audit_pronunciation.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/scripts/audit_pronunciation.py b/scripts/audit_pronunciation.py index fdfb1531..1c71ea7c 100644 --- a/scripts/audit_pronunciation.py +++ b/scripts/audit_pronunciation.py @@ -202,6 +202,10 @@ def main() -> int: ap.add_argument("--min", type=int, default=2, help="occurrences minimales pour figurer au rapport (défaut : 2)") ap.add_argument("--json", help="écrire le rapport ici") + ap.add_argument("--merge", metavar="FICHIER", + help="cumuler dans ce fichier plutôt que d'écrire un rapport isolé. " + "Trois cents livres feraient trois cents rapports que personne " + "ne lira ; un seul classement, nourri par tous, se relit.") args = ap.parse_args() d = pathlib.Path(args.directory) From 6571e2b7dd90c38131378dc8ae3207b01200eafb Mon Sep 17 00:00:00 2001 From: PaxHelios Date: Mon, 10 Aug 2026 00:40:55 +0200 Subject: [PATCH 80/98] fix(engine): an empty string must not kill a book at minute ninety-eight MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three books died on the same line, at 41, 69 and 98 minutes in, each after everything expensive had already succeeded: assert len(input) > 0 wetext/token_parser.py The first was traced to "5ᵉ", a superscript letter, and fixed there. The third had no superscript at all. The trigger was never the point: clean_text strips markdown, emoji and newlines, and when a fragment is nothing but those, it hands wetext an empty string, which refuses it with an assertion that unwinds the entire run. Chasing triggers one at a time was a losing race. The call is now guarded: an empty result returns instead of raising. Nothing downstream needed the call to have happened, and an empty segment is already caught by the quality pass and by the badcase ratio guard added earlier today. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FBNmTCfFPDth6cWqg69MGu --- src/voxcpm/utils/text_normalize.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/voxcpm/utils/text_normalize.py b/src/voxcpm/utils/text_normalize.py index 423a173b..0082df41 100644 --- a/src/voxcpm/utils/text_normalize.py +++ b/src/voxcpm/utils/text_normalize.py @@ -171,6 +171,19 @@ def normalize(self, text, split=False): # 去除 Markdown 语法,去除表情符号,去除换行符 lang = "zh" if contains_chinese(text) else "en" text = clean_text(text) + + # clean_text can empty the string outright — a fragment of markup, a + # stray symbol, a line that was only punctuation. wetext then refuses + # it with `assert len(input) > 0`, and the assertion kills the whole + # run: three books died this way, at 41, 69 and 98 minutes in, each + # after everything expensive had already succeeded. + # + # Chasing the characters that trigger it one by one was losing race: + # the superscript "5ᵉ" was only the first. Nothing downstream needs + # this call to have happened, and the empty segment is caught by the + # quality pass, so hand the empty string back instead of raising. + if not text.strip(): + return text if lang == "zh": text = text.replace( "=", "等于" From cf86c3c5c47cb70592735dc0c969933a075ac8b6 Mon Sep 17 00:00:00 2001 From: PaxHelios Date: Mon, 10 Aug 2026 00:46:41 +0200 Subject: [PATCH 81/98] fix(engine): normalisation is a nicety, not a reason to lose a book MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous guard was in the right file and did nothing. It checked for an empty string before the call; the text reaching it was not empty. wetext's own tagger reduces some inputs to nothing and then asserts on its own intermediate result — the failure is inside the library, past anything a caller can inspect. So the call is wrapped instead of predicted. Any exception, or an empty result, returns the text untouched. Normalisation turns "5" into "five" and tidies spacing; the raw text is a serviceable fallback, and a chapter with one un-normalised sentence beats a book that does not exist. Catching broadly is deliberate here. The failure to guard against is *any* failure in third-party text handling, not a specific exception — and this is the fourth time this session that a book of three hours died on a line that had nothing to do with speech. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FBNmTCfFPDth6cWqg69MGu --- src/voxcpm/utils/text_normalize.py | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/src/voxcpm/utils/text_normalize.py b/src/voxcpm/utils/text_normalize.py index 0082df41..af9d7efa 100644 --- a/src/voxcpm/utils/text_normalize.py +++ b/src/voxcpm/utils/text_normalize.py @@ -160,6 +160,27 @@ def clean_text(text): return text +def _safely(normalise, text): + """Run a third-party normaliser, or hand the text back untouched. + + wetext asserts on its own intermediate result — `assert len(input) > 0` + fires when its tagger reduces a non-empty input to nothing — and the + assertion unwinds the entire render. Three books died that way, at 41, 69 + and 98 minutes, each after all the expensive work had already succeeded. + + Normalisation is a nicety: it turns "5" into "five" and tidies spacing. The + raw text is a perfectly serviceable fallback, and a chapter narrated with + one un-normalised sentence beats a book that does not exist. Catching + broadly is the point — the failure mode to guard against is *any* failure, + not one known exception. + """ + try: + result = normalise(text) + except Exception: + return text + return result if result and result.strip() else text + + class TextNormalizer: def __init__(self, tokenizer=None): self.tokenizer = tokenizer @@ -190,12 +211,12 @@ def normalize(self, text, split=False): ) # 修复 ”550 + 320 等于 870 千卡。“ 被错误正则为 ”五百五十加三百二十等于八七十千卡.“ if re.search(r"([\d$%^*_+≥≤≠×÷?=])", text): # 避免 英文连字符被错误正则为减 text = re.sub(r"(?<=[a-zA-Z0-9])-(?=\d)", " - ", text) # 修复 x-2 被正则为 x负2 - text = self.zh_tn_model.normalize(text) + text = _safely(self.zh_tn_model.normalize, text) text = replace_blank(text) text = replace_corner_mark(text) text = remove_bracket(text) else: - text = self.en_tn_model.normalize(text) + text = _safely(self.en_tn_model.normalize, text) text = spell_out_number(text, self.inflect_parser) if split is False: return text From 2c1700a81ee05ddaefec38cf61a6f293870dc581 Mon Sep 17 00:00:00 2001 From: PaxHelios Date: Mon, 10 Aug 2026 01:44:56 +0200 Subject: [PATCH 82/98] fix(audit): a botched take is not a mispronounced word MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first real ranking came back with 180 entries and most were noise: "l'enfant", "parent", "anxiété", "volume" — ordinary French that nothing was wrong with. Reading the contexts explained it. Those words all came from segments whose transcription diverged wholesale: bibliographies, lists of resources, a take that went astray. One bad segment contributes every word it contains, and eleven such segments bury the one finding that mattered — "Filliozat" heard as "Filiozat", a proper name with a letter too few. A segment now has to have at least sixty percent of its words found before any of them counts as suspect. Below that the take is botched, not badly spoken, and the quality pass already reports it. Measured on the two shapes: a sentence with one mangled name matches 78% and is kept, a wholesale divergence matches 20% and is dropped. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FBNmTCfFPDth6cWqg69MGu --- scripts/audit_pronunciation.py | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/scripts/audit_pronunciation.py b/scripts/audit_pronunciation.py index 1c71ea7c..1586dd2b 100644 --- a/scripts/audit_pronunciation.py +++ b/scripts/audit_pronunciation.py @@ -202,6 +202,9 @@ def main() -> int: ap.add_argument("--min", type=int, default=2, help="occurrences minimales pour figurer au rapport (défaut : 2)") ap.add_argument("--json", help="écrire le rapport ici") + ap.add_argument("--match", type=float, default=0.6, metavar="TAUX", + help="part minimale des mots retrouvés pour qu'un segment compte " + "(défaut : 0.6). En deçà, la prise est ratée et non mal dite.") ap.add_argument("--merge", metavar="FICHIER", help="cumuler dans ce fichier plutôt que d'écrire un rapport isolé. " "Trois cents livres feraient trois cents rapports que personne " @@ -239,10 +242,23 @@ def main() -> int: suspects: collections.Counter = collections.Counter() exemples: dict[str, str] = {} + ecartes = 0 for source, entendu in transcrire(echantillon, args.device): - for mot, contexte in comparer(source, entendu): + manquants = comparer(source, entendu) + # Un segment dont la transcription s'écarte massivement n'est pas mal + # prononcé : il est raté, et le contrôle qualité s'en occupe. Le compter + # ici ferait remonter tous ses mots — « l'enfant », « parent », + # « anxiété » — et noierait les vraies trouvailles comme « Filliozat ». + total = len(mots(source)) or 1 + if len(manquants) / total > 1 - args.match: + ecartes += 1 + continue + for mot, contexte in manquants: suspects[mot] += 1 exemples.setdefault(mot, contexte[:110]) + if ecartes: + print(f"\n{ecartes} segment(s) écarté(s) : transcription trop éloignée " + f"pour juger d'une prononciation") retenus = [(m, n) for m, n in suspects.most_common() if n >= args.min] print(f"\n{len(suspects)} mot(s) non retrouvé(s), {len(retenus)} vu(s) au moins {args.min} fois\n") From 9f079a88f5301d30dafb534b1e2bd89adf962e7f Mon Sep 17 00:00:00 2001 From: PaxHelios Date: Mon, 10 Aug 2026 05:29:20 +0200 Subject: [PATCH 83/98] fix(audit): report proper nouns and acronyms, and nothing else MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three books audited, 264 words flagged, none of them seen in more than one book. A signal that never repeats across a catalogue is not a signal. The cause is what speech recognition is: Whisper does not transcribe word for word, it paraphrases — drops a hesitation, rewrites a turn of phrase. An ordinary word missing from the transcript proves nothing, and at any tolerance loose enough to keep real findings, hundreds of them get through. A proper noun and an acronym have no such cover. Whisper does not write "Filiozat" for "Filliozat" if it heard the name correctly, and it was that one finding, buried under 179 others, that showed what the tool is actually for. So the filter now keeps capitalised words and runs of capitals, and drops the rest. The known cost: a lowercase foreign word — "mindfulness" — is no longer distinguishable by shape from ordinary French and falls out. That gap is covered by scan_risky_words.py, which finds foreign words by spelling pattern rather than by transcription. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FBNmTCfFPDth6cWqg69MGu --- scripts/audit_pronunciation.py | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/scripts/audit_pronunciation.py b/scripts/audit_pronunciation.py index 1586dd2b..369eac71 100644 --- a/scripts/audit_pronunciation.py +++ b/scripts/audit_pronunciation.py @@ -151,22 +151,33 @@ def recoller_sigles(jetons: list[str]) -> list[str]: def interessant(mot: str) -> bool: - """Un mot dont une divergence dit quelque chose. + """Un mot dont une divergence dit vraiment quelque chose. - Un nom propre, un sigle, un mot étranger n'ont pas de filet grammatical : - si la transcription s'en écarte, c'est que la prononciation s'en écartait. + Restreint aux noms propres et aux sigles, et c'est délibéré. Whisper ne + transcrit pas mot à mot : il paraphrase, supprime une hésitation, reformule + une tournure. Un mot courant absent de la transcription ne prouve donc rien, + et l'expérience le confirme — 264 mots signalés sur trois livres, aucun + revu d'un livre à l'autre, c'est-à-dire que du bruit. + + Un nom propre et un sigle n'ont pas ce filet : la reconnaissance vocale + n'invente pas « Filiozat » pour « Filliozat » si elle a entendu le nom + correctement. Là, la divergence est le signal. """ plat = pliable(mot) if not plat or len(plat) < 3: return False - if plat in GRAMMATICAUX: + if plat in GRAMMATICAUX or plat in NOMBRES: return False - # « quatre-vingt-dix » est un nombre autant que « dix » : tester chaque - # partie, sinon les composés passent le filtre et polluent le rapport. parties = [pliable(p) for p in re.split(r"[-']", mot) if p] if parties and all(p in NOMBRES or p in GRAMMATICAUX for p in parties): return False - return plat not in NOMBRES + # Un sigle : au moins deux capitales d'affilée. + if re.match(r"^[A-ZÀ-Þ]{2,}$", mot): + return True + # Un nom propre : capitale initiale, minuscules ensuite. Une majuscule de + # début de phrase passe aussi, et c'est acceptable — le bruit qu'elle + # ajoute est borné, là où les mots courants sont sans fin. + return bool(re.match(r"^[A-ZÀ-Þ][a-zà-ÿ]", mot)) def comparer(source: str, entendu: str) -> list[tuple[str, str]]: From 6cadd030845ce983b43a65080c351e5b9483eec4 Mon Sep 17 00:00:00 2001 From: PaxHelios Date: Mon, 10 Aug 2026 19:12:23 +0200 Subject: [PATCH 84/98] =?UTF-8?q?Aplatir=20les=20incises=20entre=20parenth?= =?UTF-8?q?=C3=A8ses=20avant=20la=20synth=C3=A8se?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Une parenthèse ne s'entend pas, mais le modèle la traite comme une paire à refermer et abandonne au milieu d'une longue énumération. Sur les vingt premiers livres, 86 % des segments tronqués contenaient une parenthèse, contre 29 % des segments en général — et 30 % des segments de même longueur, donc c'est la construction qui coûte et non la longueur. « Les approches alternatives (keynésienne, institutionnaliste, marxiste, écologique) » sortait en 2,1 s d'audio pour 260 caractères. Les incises courtes — une date, une source — sont laissées telles quelles : elles n'ont jamais tronqué. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FBNmTCfFPDth6cWqg69MGu --- narration/text_fr.py | 35 +++++++++++++++++++++++++++++++++ tests/test_narration_text_fr.py | 35 +++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+) diff --git a/narration/text_fr.py b/narration/text_fr.py index 701f1996..d8fb76fc 100644 --- a/narration/text_fr.py +++ b/narration/text_fr.py @@ -536,6 +536,39 @@ def _clean_dialogue(text: str, strip_quotes: bool) -> str: return text +_RE_PARENTHETICAL = re.compile(r"\(([^()]{0,400})\)") + + +def _flatten_parentheses(text: str) -> str: + """Turn a parenthetical aside into the apposition a narrator would speak. + + Parentheses make no sound of their own, but the model treats an opening one + as a bracket it must close, and on a long enumeration it gives up partway. + Across the first twenty books, 86% of the truncated segments held a + parenthesis against 29% of segments overall — and 30% of segments of the + same length, so it is the construction that costs, not the length. "Les + approches alternatives (keynésienne, institutionnaliste, marxiste, + écologique)" came out as 2.1 seconds of audio for 260 characters of text. + + Commas read aloud the same way. Short asides are left alone — a date, a + radio station, a source — because they never truncated, and every rewrite + is another chance to break something that already worked. + """ + + def replace(match: "re.Match[str]") -> str: + inner = match.group(1).strip() + if not inner: + return " " + if "," not in inner and len(inner) <= 30: + return match.group(0) + return f", {inner}, " + + text = _RE_PARENTHETICAL.sub(replace, text) + # The apposition's closing comma lands on whatever punctuation ended the + # host sentence: "…marxiste, ." Nothing in French wants a comma there. + return re.sub(r",\s*([.;:!?…])", r"\1", text) + + def _tidy_whitespace(text: str) -> str: text = re.sub(r"[ \t]+", " ", text) text = re.sub(r" ([,.;:!?…])", r"\1", text) @@ -582,6 +615,8 @@ def normalize_french( text = _expand_percent(text) text = _expand_ordinal_marks(text) text = _expand_numbers(text) + # After the markdown pass, so that a link's "(url)" is already gone. + text = _flatten_parentheses(text) text = _clean_dialogue(text, strip_quotes) return _tidy_whitespace(text) diff --git a/tests/test_narration_text_fr.py b/tests/test_narration_text_fr.py index 07c02813..4192e824 100644 --- a/tests/test_narration_text_fr.py +++ b/tests/test_narration_text_fr.py @@ -404,3 +404,38 @@ def test_a_footnote_marker_is_not_spoken(self): def test_ordinary_prose_is_untouched(self): texte = "Une phrase parfaitement ordinaire, sans aucun signe particulier." assert normalize_french(texte) == texte + + +class TestParentheses: + """Une parenthèse ne s'entend pas — et une longue énumération entre + parenthèses est ce qui a tronqué 86 % des segments défectueux.""" + + def test_an_enumeration_becomes_an_apposition(self): + assert normalize_french( + "Les approches alternatives (keynésienne, marxiste) existent." + ) == "Les approches alternatives, keynésienne, marxiste, existent." + + def test_no_comma_is_left_against_the_full_stop(self): + # « …aux médias. » et non « …aux médias, . » + rendu = normalize_french("Ils ont des moyens (financements, accès aux médias).") + assert rendu == "Ils ont des moyens, financements, accès aux médias." + + def test_a_short_aside_is_left_alone(self): + # Une date ou une source n'a jamais tronqué ; on n'y touche pas. Le + # nombre, lui, est écrit en toutes lettres par la passe précédente. + assert normalize_french("le rapport (2008)") == "le rapport (deux mille huit)" + assert normalize_french("sur France Culture (Paris)") == "sur France Culture (Paris)" + + def test_a_long_aside_without_a_comma_is_flattened_too(self): + rendu = normalize_french( + "un effet rebond (la consommation augmente avec l'efficacité) connu" + ) + assert "(" not in rendu + assert "la consommation augmente" in rendu + + def test_the_parenthesis_characters_never_reach_the_model(self): + rendu = normalize_french("des ressources (a, b, c) et (d, e) ailleurs") + assert "(" not in rendu and ")" not in rendu + + def test_an_empty_parenthesis_disappears(self): + assert "(" not in normalize_french("un mot () suivant") From 5fe3d13559c1b76b92ac20ffc73cab31a0b4b7db Mon Sep 17 00:00:00 2001 From: PaxHelios Date: Tue, 11 Aug 2026 09:52:22 +0200 Subject: [PATCH 85/98] =?UTF-8?q?Effacer=20les=20livrables=20de=20la=20pri?= =?UTF-8?q?se=20pr=C3=A9c=C3=A9dente=20avant=20une=20reprise?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Une reprise écrit par-dessus la production précédente, mais seulement là où les noms de fichiers coïncident. « Le Pouvoir Silencieux » est ressorti avec vingt-huit fichiers ACX pour vingt chapitres : sept rescapés de la prise du 8 août, portant des titres à peine différents. Le M4B était sain — c'est le jeu qu'on livre à une plateforme qui ne l'était pas. Le dossier `.cache` est épargné : c'est lui qui permet à une narration interrompue de reprendre où elle en était. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FBNmTCfFPDth6cWqg69MGu --- scripts/narrate_queue.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/scripts/narrate_queue.py b/scripts/narrate_queue.py index 9f3eafcf..b06e8faf 100644 --- a/scripts/narrate_queue.py +++ b/scripts/narrate_queue.py @@ -142,6 +142,27 @@ def save() -> None: log(f"[{i}/{len(books)}] {slug} — {b['chars']} car., voix « {b['voice']} »") + # Une reprise écrit par-dessus la production précédente, mais seulement + # là où les noms coïncident : un chapitre renommé, un découpage qui + # bouge d'un fichier, et l'ancien reste. « Le Pouvoir Silencieux » est + # ressorti avec vingt-huit fichiers ACX pour vingt chapitres — sept + # rescapés du 8 août, portant des titres à peine différents. Le M4B + # était sain ; le jeu qu'on livre à une plateforme ne l'était pas. + # + # On efface donc les livrables de la prise précédente — et eux seuls. + # `.cache` reste : c'est lui qui permet à une narration interrompue de + # reprendre où elle en était plutôt que de recommencer trois heures. + if outdir.exists(): + efface = 0 + for item in outdir.iterdir(): + if item.name == ".cache": + continue + efface += (item.stat().st_size if item.is_file() + else sum(f.stat().st_size for f in item.rglob("*") if f.is_file())) + shutil.rmtree(item) if item.is_dir() else item.unlink() + if efface: + log(f" livrables précédents effacés ({efface / 1e9:.1f} Go), cache conservé") + # Pré-vol : il ne charge pas le modèle, donc il coûte des secondes et # attrape ce qui ferait échouer trois heures plus tard. rc, out = run([PYTHON, "scripts/narrate_book.py", str(txt), "--voice", b["voice"], From 3a30ad03ed429115b65a240227a24740cc9cc7b0 Mon Sep 17 00:00:00 2001 From: PaxHelios Date: Tue, 11 Aug 2026 13:39:25 +0200 Subject: [PATCH 86/98] fix(text_fr): trois choses que le manuscrit garde et ne dit jamais MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L'audit de prononciation du catalogue a signalé 166 mots. Presque aucun n'était mal prononcé : ils étaient les débris de trois constructions que la narration lit sans les comprendre. Les lignes à remplir. Le trait était bien effacé, mais lui seul : « Jour 5: ___ minutes (objectif: 10 min) / Ressenti: ___ » devenait « Jour 5 : minutes (objectif : 10 min) / Ressenti : », narré tel quel et relu par l'audit en « Jour 5, minute objectif, 10 mines, essenci », sept fois de suite, dans un livre déjà livré. Ce qui suit le trait n'a de sens qu'avec lui — « ___ heures ___ minutes » énonce des unités sans grandeur — donc le trait emporte sa queue, jusqu'à la parenthèse suivante, qui porte souvent la seule information de la ligne. L'intitulé, lui, reste toujours : c'est une consigne que l'auditeur peut suivre. Trois livres de la file portaient ces lignes, 68, 11 et 8, et deux sont encore devant nous. Les intervalles chiffrés, 726 dans les vingt et un livres. Le trait d'union se dit « à » ; ne pas le dire ne laisse pas un blanc, il colle les deux nombres et la passe des nombres les fond en un seul. « La pandémie de 2020-2022 » se narrait « deux mille vingt-deux mille vingt-deux ». Les chaînes plus longues sont épargnées : « 4-7-8 » est une respiration, pas un intervalle, et « 5-4-3-2-1 » un exercice d'ancrage. Les minutes abrégées, 106 fois. « 20 h » était déjà lu par la règle des heures ; « 5 min » ne l'était par personne et se disait « min ». Une limite reste, notée dans le code : la passe des nombres ignore le genre du mot qui suit, donc « 51 min » — une occurrence dans tout le catalogue — se dit « cinquante et un minutes ». Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01MErTnGqADwhFXyMoCb4oC4 --- narration/text_fr.py | 120 +++++++++++++++++++++++++++++++- tests/test_narration_text_fr.py | 101 +++++++++++++++++++++++++++ 2 files changed, 220 insertions(+), 1 deletion(-) diff --git a/narration/text_fr.py b/narration/text_fr.py index d8fb76fc..64e24980 100644 --- a/narration/text_fr.py +++ b/narration/text_fr.py @@ -213,6 +213,14 @@ def roman_to_int(s: str) -> Optional[int]: (r"\bp\.(?=\s*\d)", "page"), (r"\bn[°º]\s*(?=\d)", "numéro "), (r"[°º](?=\s|$)", " degrés"), + # « 20 h » est déjà lu par la règle des heures, « 5 min » ne l'était par + # personne : 106 fois dans les vingt et un livres, dit « min ». Le singulier + # s'écrit ici en toutes lettres — « 1 minute » serait rendu « un minute » + # par la passe des nombres, qui ne connaît pas le genre du mot qui suit. + # (Le défaut subsiste au-delà de un : « 51 min », une fois dans le + # catalogue, se dit « cinquante et un minutes ».) + (r"\b1\s*min\b(?!ute)", "une minute"), + (r"(?<=\d)\s*min\b(?!ute)", " minutes"), ) @@ -268,6 +276,100 @@ def _spell_decimal(whole: str, frac: str) -> str: # -------------------------------------------------------------------------- +#: Le blanc d'un formulaire : un trait à remplir au stylo. +_BLANK = re.compile(r"_{2,}") + +#: Ce qu'on efface après le blanc s'arrête à la ponctuation forte : au-delà, +#: ce n'est plus l'unité qui accompagnait le trait, c'est la phrase suivante. +_BLANK_TAIL = re.compile(r"_{2,}[^(.!?;\n]*") + +#: Un blanc long est un trait de formulaire ; une queue longue est de la prose +#: qui se trouvait derrière. Sur les trois livres concernés, la plus longue +#: queue légitime fait 28 caractères (« ___ fois par jour en moyenne »). +_MAX_TAIL = 40 + +#: Séparateurs qu'un champ vidé laisse pendre à ses extrémités. +_ORPHAN_EDGE = re.compile(r"^[\s:;,+/–—-]+|[\s:;,+/–—-]+$") + + +def _form_fields(line: str) -> list[str]: + """Découper une ligne de formulaire sur ses barres obliques de premier rang. + + Une parenthèse en protège une : « Temps réel mesuré (Screen Time / + Bien-être numérique) » est un seul champ, pas deux. + """ + fields: list[str] = [] + depth = 0 + current: list[str] = [] + for ch in line: + if ch == "(": + depth += 1 + elif ch == ")": + depth = max(0, depth - 1) + if ch == "/" and depth == 0: + fields.append("".join(current)) + current = [] + else: + current.append(ch) + fields.append("".join(current)) + return fields + + +def _strip_form_blanks(text: str) -> str: + """Rendre lisible une ligne à remplir, ou la faire taire. + + Effacer le seul trait ne suffisait pas, et c'est ce qui a été livré : + « Jour 5: ___ minutes (objectif: 10 min) / Ressenti: ___ » devenait + « Jour 5 : minutes (objectif : 10 min) / Ressenti : », que le moteur a + narré tel quel — l'audit l'a relu en « Jour 5, minute objectif, 10 mines, + essenci », sept fois de suite, dans un livre déjà livré. + + Ce qui reste après le trait n'a de sens qu'avec lui : « ___ heures ___ + minutes » énonce des unités sans grandeur, « ___ h / ___ h / ___ h » trois + fois rien. On efface donc le trait **et sa queue**, jusqu'à la parenthèse + ou la barre oblique suivante — la parenthèse porte souvent la seule vraie + information de la ligne (« objectif : 10 min ») et doit survivre. + + L'intitulé, lui, reste toujours : c'est une consigne que l'auditeur peut + suivre. « Application la plus consultée : ______ » devient « Application la + plus consultée. » Une ligne qui n'était qu'un trait disparaît, faute + d'avoir jamais rien dit. + + Trois livres de la file portaient ces lignes — 68, 11 et 8 — et aucun + n'aurait dû les faire entendre. + """ + if "__" not in text: + return text + + def deblank(field: str) -> str: + def cut(m: re.Match) -> str: + # Une queue trop longue n'est pas une unité, c'est une phrase : + # on se contente alors d'ôter le trait, sans l'emporter avec lui. + if len(m.group(0)) <= _MAX_TAIL: + return "" + return _BLANK.sub(" ", m.group(0)) + + return _BLANK_TAIL.sub(cut, field) + + def rewrite(line: str) -> str: + if not _BLANK.search(line): + return line + kept = [] + for field in _form_fields(line): + field = _ORPHAN_EDGE.sub("", deblank(field)).strip() + # « Jour 5 : (objectif : 10 min) » — le deux-points a perdu sa + # valeur, la parenthèse la porte désormais seule. + field = re.sub(r"\s*:\s*(?=\()", " ", field) + if re.search(r"[^\W\d_]|\d", field): + kept.append(field) + if not kept: + return "" + rebuilt = ". ".join(kept) + return rebuilt if rebuilt[-1] in ".!?…:;" else rebuilt + "." + + return "\n".join(rewrite(line) for line in text.split("\n")) + + def _clean_symbols(text: str) -> str: """Traduire en mots les signes qu'un manuscrit garde et qu'on ne dit pas. @@ -292,6 +394,22 @@ def _clean_symbols(text: str) -> str: text = re.sub(r"\s*°\s*F(?![a-zà-ÿ])", " degrés Fahrenheit", text) text = re.sub(r"(\d)\s*°", r"\1 degrés", text) + # Un intervalle chiffré. Le trait d'union se dit « à », et ne pas le dire + # ne laisse pas un silence : il colle les deux nombres l'un à l'autre et la + # passe des nombres les fond en un seul. « La pandémie de 2020-2022 » se + # narrait « deux mille vingt-deux mille vingt-deux ». 726 intervalles dans + # les vingt et un livres de la file. + # + # Les bornes acceptent un horaire (« 14h-15h30 »), et les deux gardes + # interdisent qu'une chaîne plus longue soit prise pour un intervalle : + # « 4-7-8 » est une respiration, pas « quatre à sept à huit », et + # « 5-4-3-2-1 » un exercice d'ancrage. + text = re.sub( + r"(? str: ) # Restes de formulaire : lignes à remplir, cases à cocher, appels de note. - text = re.sub(r"_{2,}", " ", text) + text = _strip_form_blanks(text) text = re.sub(r"[☐☑✓✗▢]", " ", text) text = re.sub(r"(?<=[a-zà-ÿ])\*(?=[\s,.;:)])", "", text) diff --git a/tests/test_narration_text_fr.py b/tests/test_narration_text_fr.py index 4192e824..5effa36f 100644 --- a/tests/test_narration_text_fr.py +++ b/tests/test_narration_text_fr.py @@ -439,3 +439,104 @@ def test_the_parenthesis_characters_never_reach_the_model(self): def test_an_empty_parenthesis_disappears(self): assert "(" not in normalize_french("un mot () suivant") + + +class TestFormBlanks: + """Une ligne à remplir se lit des yeux et ne se dit pas. + + Effacer le seul trait laissait un résidu que le moteur a narré tel quel : + « Jour 5 : minutes (objectif : 10 min) / Ressenti : ». L'audit l'a relu en + « Jour 5, minute objectif, 10 mines, essenci » — sept fois de suite, dans + un livre déjà livré. + """ + + def test_the_label_survives_its_blank(self): + assert normalize_french( + "Application la plus consultée : ___________________________" + ) == "Application la plus consultée." + + def test_orphaned_units_go_with_the_blank(self): + # « heures » et « minutes » n'énoncent plus rien sans leur grandeur. + assert normalize_french( + "Estimation de mon temps d'écran : ____ heures ____ minutes" + ) == "Estimation de mon temps d'écran." + + def test_a_line_that_was_only_a_rule_disappears(self): + assert normalize_french("____________________________").strip() == "" + + def test_the_parenthesis_holds_the_only_real_content(self): + # C'est le programme du livre : il doit survivre au nettoyage. + assert normalize_french( + "Jour 5: ___ minutes (objectif: 10 min) / Ressenti: ___" + ) == "Jour cinq (objectif: dix minutes). Ressenti." + + def test_repeated_fields_collapse_instead_of_leaving_slashes(self): + assert normalize_french( + "Mes trois créneaux quotidiens: ___ h / ___ h / ___ h" + ) == "Mes trois créneaux quotidiens." + + def test_a_slash_inside_a_parenthesis_is_not_a_field_separator(self): + # L'incise est ensuite aplatie par la passe des parenthèses ; ce que + # ce test protège, c'est qu'elle soit restée d'un seul tenant. + assert normalize_french( + "Temps réel mesuré (Screen Time / Bien-être numérique) : ____ heures" + ) == "Temps réel mesuré, Screen Time / Bien-être numérique." + + def test_prose_behind_a_blank_is_not_eaten(self): + # Le garde-fou : au-delà d'une queue courte, ce n'est plus une unité, + # c'est la phrase — on ôte le trait et on lui laisse ses mots. + assert normalize_french( + "Elle note ___ dans la marge puis referme le carnet et sort." + ) == "Elle note dans la marge puis referme le carnet et sort." + + def test_a_line_without_a_blank_keeps_its_slashes(self): + assert normalize_french( + "Il gagne trois mille euros / mois." + ) == "Il gagne trois mille euros / mois." + + +class TestMinutesAbbreviation: + """« 20 h » était lu, « 5 min » ne l'était par personne : 106 fois dans les + vingt et un livres de la file, dit « min ».""" + + def test_minutes_are_spoken(self): + assert normalize_french("une séance de 5 min") == "une séance de cinq minutes" + + def test_one_minute_stays_singular(self): + assert normalize_french("après 1 min") == "après une minute" + + def test_an_already_spelled_minute_is_left_alone(self): + assert normalize_french("après 5 minutes") == "après cinq minutes" + + def test_a_word_beginning_with_min_is_untouched(self): + assert normalize_french("le minimum de 3 minutes") == "le minimum de trois minutes" + + +class TestNumericRanges: + """Un trait d'union entre deux nombres se dit « à ». + + Ne pas le dire ne laisse pas un silence : il colle les deux nombres et la + passe des nombres les fond en un seul. « La pandémie de 2020-2022 » se + narrait « deux mille vingt-deux mille vingt-deux ». 726 intervalles dans + les vingt et un livres de la file. + """ + + def test_a_year_range_is_two_years(self): + assert normalize_french("la pandémie de 2020-2022") == ( + "la pandémie de deux mille vingt à deux mille vingt-deux" + ) + + def test_a_quantity_range(self): + assert normalize_french("dormir 7-8 heures") == "dormir sept à huit heures" + + def test_an_hour_range_keeps_its_minutes(self): + assert normalize_french("de 14h-15h30") == "de quatorze heures à quinze heures trente" + + def test_a_breathing_exercise_is_not_a_range(self): + # « 4-7-8 » est une respiration, « 5-4-3-2-1 » un exercice d'ancrage : + # trois nombres à dire l'un après l'autre, pas un intervalle. + assert "à" not in normalize_french("la respiration 4-7-8") + assert "à" not in normalize_french("exercice 5-4-3-2-1") + + def test_a_hyphenated_name_with_a_number_is_untouched(self): + assert normalize_french("le COVID-19") == "le COVID-dix-neuf" From fea027d208c497fd1e08422e5e9e17aff8f4a0ed Mon Sep 17 00:00:00 2001 From: PaxHelios Date: Tue, 11 Aug 2026 19:04:51 +0200 Subject: [PATCH 87/98] =?UTF-8?q?Aplatir=20aussi=20les=20parenth=C3=A8ses?= =?UTF-8?q?=20courtes,=20que=20l'exemption=20avait=20sauv=C3=A9es?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L'aplatissement des incises épargnait les parenthèses de moins de trente caractères sans virgule : elles n'avaient jamais tronqué, disait la mesure faite sur des livres narrés *avant* la règle. Mesuré à nouveau sur un livre narré *avec* la règle en place, l'exemption est ce qui restait du défaut. Soixante-neuf segments du cache ont été relus par reconnaissance vocale et alignés sur le texte demandé : les huit qui s'arrêtent en route portent tous une parenthèse, et tous une parenthèse que l'exemption épargnait — « (REM) », « (N3) », « (chapitre dix) », « (urgences pédiatriques) ». Aucun des cinquante et un segments sans parenthèse ne s'arrête. La longueur n'a jamais été la cause ; le crochet l'est. Deux cent trente-six caractères sortaient en 6,9 s, la transcription s'arrêtant à « Le sommeil paradoxal » — 22 % du texte, le reste perdu sans bruit. Le contrôle qualité ne rattrape pas ces cas : son seuil de troncature est absolu (35 car/s) quand le débit réel d'une voix tourne à 15. Une phrase coupée en deux ressort à 30 car/s et passe. Trois des huit seulement sont signalés, et « suspect » ne déclenche pas de reprise. Une incise qui finissait la ligne laissait sa virgule dans le vide (« le rapport, deux mille huit, ») : c'est précisément la construction inachevée que cette passe existe pour supprimer, elle est retirée aussi. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01WrRvHFRAszv5xnrqHwZHde --- narration/text_fr.py | 19 +++++++++++++------ tests/test_narration_text_fr.py | 22 ++++++++++++++++------ 2 files changed, 29 insertions(+), 12 deletions(-) diff --git a/narration/text_fr.py b/narration/text_fr.py index 64e24980..9f94a499 100644 --- a/narration/text_fr.py +++ b/narration/text_fr.py @@ -668,23 +668,30 @@ def _flatten_parentheses(text: str) -> str: approches alternatives (keynésienne, institutionnaliste, marxiste, écologique)" came out as 2.1 seconds of audio for 260 characters of text. - Commas read aloud the same way. Short asides are left alone — a date, a - radio station, a source — because they never truncated, and every rewrite - is another chance to break something that already worked. + Commas read aloud the same way. Short asides used to be left alone — a + date, a source — on the grounds that they had never truncated. Measured + again on a book narrated *with* this pass in place, that exemption is what + was left of the defect: of sixty-nine segments transcribed back, the eight + that stopped early all carried a parenthesis, and all eight carried one the + exemption had spared — « (REM) », « (N3) », « (chapitre dix) », + « (urgences pédiatriques) ». Not one of the fifty-one segments without a + parenthesis stopped early. Length was never the trigger; the bracket was. """ def replace(match: "re.Match[str]") -> str: inner = match.group(1).strip() if not inner: return " " - if "," not in inner and len(inner) <= 30: - return match.group(0) return f", {inner}, " text = _RE_PARENTHETICAL.sub(replace, text) # The apposition's closing comma lands on whatever punctuation ended the # host sentence: "…marxiste, ." Nothing in French wants a comma there. - return re.sub(r",\s*([.;:!?…])", r"\1", text) + text = re.sub(r",\s*([.;:!?…])", r"\1", text) + # An aside that ended the line has nothing to lean its comma against — + # "le rapport, deux mille huit," — and a trailing comma is exactly the + # unclosed construction this pass exists to remove. + return re.sub(r"(?m),[ \t]*$", "", text) def _tidy_whitespace(text: str) -> str: diff --git a/tests/test_narration_text_fr.py b/tests/test_narration_text_fr.py index 5effa36f..5d944995 100644 --- a/tests/test_narration_text_fr.py +++ b/tests/test_narration_text_fr.py @@ -420,11 +420,21 @@ def test_no_comma_is_left_against_the_full_stop(self): rendu = normalize_french("Ils ont des moyens (financements, accès aux médias).") assert rendu == "Ils ont des moyens, financements, accès aux médias." - def test_a_short_aside_is_left_alone(self): - # Une date ou une source n'a jamais tronqué ; on n'y touche pas. Le - # nombre, lui, est écrit en toutes lettres par la passe précédente. - assert normalize_french("le rapport (2008)") == "le rapport (deux mille huit)" - assert normalize_french("sur France Culture (Paris)") == "sur France Culture (Paris)" + def test_a_short_aside_is_flattened_too(self): + # L'exemption des incises courtes a été mesurée fausse : sur un livre + # narré avec l'aplatissement en place, les huit segments tronqués + # portaient tous une parenthèse, et tous une parenthèse courte. + assert normalize_french("le rapport (2008)") == "le rapport, deux mille huit" + assert normalize_french("sur France Culture (Paris)") == "sur France Culture, Paris" + + def test_the_sigla_that_truncated_a_chapter(self): + # Mesuré : 236 caractères sortis en 6,9 s, la transcription s'arrêtant + # à « Le sommeil paradoxal » — 22 % du texte. + rendu = normalize_french( + "Le sommeil paradoxal (REM) représente cinquante pour cent du temps." + ) + assert "(" not in rendu + assert rendu == "Le sommeil paradoxal, REM, représente cinquante pour cent du temps." def test_a_long_aside_without_a_comma_is_flattened_too(self): rendu = normalize_french( @@ -468,7 +478,7 @@ def test_the_parenthesis_holds_the_only_real_content(self): # C'est le programme du livre : il doit survivre au nettoyage. assert normalize_french( "Jour 5: ___ minutes (objectif: 10 min) / Ressenti: ___" - ) == "Jour cinq (objectif: dix minutes). Ressenti." + ) == "Jour cinq, objectif: dix minutes. Ressenti." def test_repeated_fields_collapse_instead_of_leaving_slashes(self): assert normalize_french( From 187699992405af658c40a7be48dd036e7b808a34 Mon Sep 17 00:00:00 2001 From: PaxHelios Date: Thu, 13 Aug 2026 17:10:55 +0200 Subject: [PATCH 88/98] =?UTF-8?q?Parler=20=C3=A0=20un=20auditeur,=20pas=20?= =?UTF-8?q?=C3=A0=20un=20lecteur?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Un manuscrit dit « comment lire ce livre », « vous lisez ces pages », « cher lecteur ». À l'oreille chacune de ces phrases est fausse, et elle sort l'auditeur du livre au moment précis où on lui demande de s'y installer. 258 changements sur les quarante-six livres : 18 intertitres (« Comment lire ce livre » → « Comment écouter ce livre audio », présent dans 19 livres), 88 verbes quand la phrase nomme *ce* livre, 152 « lecteur » → « auditeur ». Le partage retenu tient à une mesure, pas à une intuition. « lecteur » apparaît 163 fois ; mon classement automatique annonçait 92 faux positifs, et la lecture des 163 phrases a donné l'inverse — 11 exceptions réelles (lecteur d'écran, lecteur NFC, cerveau lecteur, lecteur assidu, lecteurs de Proust…). Un échantillon ne remplace pas la lecture des cas. Ce qui nomme explicitement ce livre est donc converti ; le reste est signalé, jamais deviné. Trois pièges, tous rencontrés : - l'élision. Tous les remplacements commencent par une voyelle là où « lire » et « lecteur » n'en ont pas : « se lit » → « s'écoute », « le lecteur » → « l'auditeur ». La faute avait été figée dans un test avant d'être vue. - le support. Une phrase qui parle de liseuse, de papier, de surligner ou de marge doit bloquer la conversion, pas la commenter : « écouter ce livre sur une liseuse » est pire que l'original. - le sujet du livre. livre-21-podcast est exclu en entier, son propos oppose lire et écouter — « le livre demande à son lecteur ce que le podcast demande à son auditeur ». L'adaptation vient en dernier dans prepare_manuscript.py, sur le texte déjà nettoyé : elle raisonne sur des phrases, et les phrases n'existent qu'une fois les blocs de mise en forme retirés. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01SoLfJRcXeLGs1Q5bdF1RWB --- narration/adresse_audio.py | 308 ++++++++++++++++++++++++++++++++++ scripts/prepare_manuscript.py | 34 ++++ tests/test_adresse_audio.py | 173 +++++++++++++++++++ 3 files changed, 515 insertions(+) create mode 100644 narration/adresse_audio.py create mode 100644 tests/test_adresse_audio.py diff --git a/narration/adresse_audio.py b/narration/adresse_audio.py new file mode 100644 index 00000000..0c773f6e --- /dev/null +++ b/narration/adresse_audio.py @@ -0,0 +1,308 @@ +#!/usr/bin/env python3 +"""Parler à quelqu'un qui écoute, quand le manuscrit s'adresse à quelqu'un qui lit. + +Un manuscrit dit « comment lire ce livre », « vous lisez ces pages », « cher +lecteur ». À l'oreille, chacune de ces phrases est fausse : l'auditeur n'est +pas en train de lire, et l'entendre dire le contraire le sort du livre. + +**Ce module ne réécrit pas le manuscrit.** Il agit sur le texte de narration, +celui qu'on peut relire et comparer avant de payer la synthèse. Le fichier +d'origine reste ce qu'il est. + +Le piège est de tout convertir. Mesuré sur quarante-six livres, « lecteur » +apparaît 163 fois, et une majorité ne désigne pas le public : + + …payer par simple contact de la main avec un lecteur… (une puce NFC) + …l'étude du cerveau lecteur… (un concept) + …lui-même, lecteur assidu depuis l'enfance… (un personnage) + …se sont formées en lisant cet ouvrage… (un AUTRE livre) + +D'où le partage retenu ici. Ce qui nomme explicitement *ce* livre est converti +tout seul, parce que la référence lève l'ambiguïté. Tout le reste est +seulement **signalé**, pour qu'une décision soit prise en la voyant plutôt +qu'en la devinant. Un module qui convertirait les 163 aurait tort 92 fois. + +Certaines phrases enfin ne parlent pas de lire mais du support — « sur une +liseuse », « crayon à la main » — et aucun verbe ne les répare. Elles sont +signalées comme telles : il faut les récrire ou les couper. +""" +from __future__ import annotations + +import dataclasses +import re +from typing import Iterable + +__all__ = ["Changement", "Signalement", "adapter", "rapport_texte"] + + +@dataclasses.dataclass(frozen=True) +class Changement: + """Une substitution faite, avec de quoi la relire.""" + regle: str + avant: str + apres: str + contexte: str + + +@dataclasses.dataclass(frozen=True) +class Signalement: + """Un passage douteux, laissé intact et porté à l'attention.""" + motif: str + extrait: str + pourquoi: str + + +#: Le livre se désigne lui-même : la référence lève l'ambiguïté, on peut convertir. +#: « cet ouvrage » est volontairement absent — il désigne souvent un livre cité. +CE_LIVRE = r"(?:ce livre|ce chapitre|ces pages|ce guide|cette page|ce volume)" + +#: Conjugaisons rencontrées dans le corpus, et leur équivalent à l'écoute. +#: « relire » devient « réécouter » : revenir en arrière existe aussi en audio. +VERBES = { + "lire": "écouter", "relire": "réécouter", + "lis": "écoute", "lit": "écoute", "lisez": "écoutez", "lisons": "écoutons", + "lisent": "écoutent", "lisait": "écoutait", "lisaient": "écoutaient", + "lisant": "écoutant", "relisant": "réécoutant", "relisez": "réécoutez", + "lirez": "écouterez", "lira": "écoutera", "liront": "écouteront", + "lirait": "écouterait", "liriez": "écouteriez", "lirons": "écouterons", + "lu": "écouté", "lue": "écoutée", "lus": "écoutés", "lues": "écoutées", + "relu": "réécouté", "relue": "réécoutée", +} +_VERBE_RX = "|".join(sorted(VERBES, key=len, reverse=True)) + +#: Verbe puis référence, ou référence puis verbe : les deux ordres existent +#: (« lire ce livre », mais aussi « ce livre se lit d'une traite »). +_AVANT = re.compile(rf"\b({_VERBE_RX})\b([^.!?\n]{{0,45}}?\b{CE_LIVRE}\b)", re.IGNORECASE) +_APRES = re.compile(rf"\b({CE_LIVRE}\b[^.!?\n]{{0,45}}?)\b({_VERBE_RX})\b", re.IGNORECASE) + +#: Les intertitres : c'est là que la faute s'entend le plus, parce qu'elle est +#: annoncée. Traités à part pour ajouter « audio », qu'une phrase ordinaire +#: n'a pas besoin de répéter. +_TITRE = re.compile( + rf"^(\s*)Comment\s+(?:lire|aborder|utiliser)\s+({CE_LIVRE})([^\n]*)$", + re.IGNORECASE | re.MULTILINE) + +#: Le support de lecture, pas l'acte : convertir le verbe y produirait une +#: phrase pire que l'originale — « écouter ce livre sur une liseuse ». Une +#: phrase qui porte un de ces mots est donc laissée telle quelle ET signalée. +#: +#: « crayon » et « stylo » n'en sont pas : un auditeur peut très bien écrire +#: en écoutant, et ces mots appartiennent aux exercices, pas au support. +_SUPPORT_LECTURE = re.compile( + r"\b(?:liseuse|kindle|sur papier|version papier|surlign\w+|dans la marge|" + r"note de bas de page|coin de la page|en haut de la page|imprim\w+|" + r"tourner la page|feuillet\w*)\b", re.IGNORECASE) + +#: Signalé plus largement que suppressif : ces mots méritent un regard sans +#: pour autant bloquer une conversion juste. +_SUPPORT_SIGNALE = re.compile( + rf"{_SUPPORT_LECTURE.pattern}|\b(?:stylo|crayon)\b", re.IGNORECASE) + +#: Bornes de phrase, pour savoir de quelle phrase relève une occurrence. +_FIN_PHRASE = re.compile(r"[.!?\n]") + + +def _phrase_autour(texte: str, debut: int, fin: int) -> str: + """La phrase qui contient l'occurrence — l'unité où le sens se décide.""" + d = 0 + for m in _FIN_PHRASE.finditer(texte, 0, debut): + d = m.end() + m = _FIN_PHRASE.search(texte, fin) + return texte[d: m.start() if m else len(texte)] + +#: « lecteur » : converti, mais jamais aveuglément. +#: +#: Le relevé des 163 occurrences du corpus a renversé mon hypothèse de départ. +#: Je pensais la majorité ambiguë ; elle ne l'est pas — la plupart désignent +#: bien le public, et les laisser produirait un livre qui parle à quelqu'un +#: d'absent. Un livre dit même déjà « ce livre peut s'écouter de deux manières, +#: selon le type de lecteur que vous êtes » : ne pas convertir laisserait la +#: phrase se contredire elle-même. +#: +#: Restent des exceptions franches, relevées une à une dans le corpus. Elles +#: ne sont pas devinées : chacune a été lue dans sa phrase. +_LECTEUR = re.compile(r"\blect(?:eur|rice)s?\b", re.IGNORECASE) + +LECTEUR_VERS_AUDITEUR = { + "lecteur": "auditeur", "lecteurs": "auditeurs", + "lectrice": "auditrice", "lectrices": "auditrices", +} + +#: Ce qui porte le mot « lecteur » sans désigner le public. Motifs relevés +#: dans le corpus, avec le livre où ils apparaissent. +_LECTEUR_GARDE = re.compile( + r"lecteurs? d'écran" # accessibilité (livre-06) + r"|cerveau lecteurs?" # concept de Maryanne Wolf (livre-01) + r"|lecteurs? (?:assidus?|de Proust|de romans?|de journaux|de presse)" + r"|lecteurs? numériques" # sujets d'une étude (livre-01) + r"|(?:annonceurs|abonnés) et lecteurs" # économie de la presse (livre-08) + r"|magazines? [^.\n]{0,40}lecteurs" # lectorat d'un magazine (livre-21) + r"|journ\w+ [^.\n]{0,60}lecteurs" # lecteurs d'un journal (livre-09) + r"|auditeur ou (?:un )?lecteur" # la phrase oppose déjà les deux + r"|lecteur (?:et|ou) (?:un )?auditeur" + r"|contact [^.\n]{0,30}avec un lecteur" # une puce NFC (livre-02) + r"|lecteurs? de livres" # les lecteurs d'autres livres + , re.IGNORECASE) + +#: Un livre entier échappe à la règle. « L'EFFET PODCAST » compare page après +#: page ce que la lecture fait et ce que l'écoute fait — « le format du livre +#: demande à son lecteur la même chose que le podcast demande à son auditeur ». +#: Y remplacer lecteur par auditeur détruirait l'argument du livre. +LIVRES_SANS_CONVERSION_LECTEUR = frozenset({"livre-21-podcast"}) + +#: « cet ouvrage » : peut désigner ce livre-ci comme un livre cité. +_OUVRAGE = re.compile(rf"\b(?:{_VERBE_RX})\b[^.!?\n]{{0,45}}?\bcet ouvrage\b", + re.IGNORECASE) + + +#: Tous les remplacements commencent par une voyelle, ce que « lire » ne +#: faisait pas : « se lit » doit devenir « s'écoute », jamais « se écoute ». +_ELIDABLES = ("se", "ne", "je", "me", "te", "le", "la", "de", "que", "ce") +_ELISION = re.compile(rf"\b({'|'.join(_ELIDABLES)})(\s+)$", re.IGNORECASE) + + +def _accorder(source: str, cible: str) -> str: + """Rend la casse du mot d'origine : « Lisez » ne doit pas devenir « écoutez ».""" + if source.isupper(): + return cible.upper() + if source[:1].isupper(): + return cible[:1].upper() + cible[1:] + return cible + + +def _elider(gauche: str) -> str: + """Élide le mot qui précède, s'il le demande. Rend le texte de gauche corrigé. + + Tous les mots concernés finissent par une voyelle — « se », « que », + « la » — donc l'élision consiste à retirer cette dernière lettre et à la + remplacer par l'apostrophe : « se » devient « s' », « que » devient « qu' ». + """ + m = _ELISION.search(gauche) + if not m: + return gauche + return gauche[:m.start()] + m.group(1)[:-1] + "'" + + +def _extrait(texte: str, debut: int, fin: int, marge: int = 70) -> str: + d = max(0, debut - marge) + f = min(len(texte), fin + marge) + return " ".join(texte[d:f].split()) + + +def adapter(texte: str, *, slug: str = "") -> tuple[str, list[Changement], list[Signalement]]: + """Adapte le texte à l'écoute. Rend le texte, ce qui a changé, ce qui inquiète. + + ``slug`` nomme le livre : un livre dont le sujet *est* la différence entre + lire et écouter ne peut pas subir la règle sur « lecteur ». + """ + changements: list[Changement] = [] + signalements: list[Signalement] = [] + + def _titre(m: re.Match) -> str: + blanc, cible, reste = m.group(1), m.group(2), m.group(3) + # « ce chapitre » reste un chapitre ; seul le livre gagne « audio », + # et une seule fois, là où l'auditeur comprend ce qu'il écoute. + neuf = "ce livre audio" if cible.lower() == "ce livre" else cible.lower() + remplacement = f"{blanc}Comment écouter {neuf}{reste}" + changements.append(Changement( + "intertitre", m.group(0).strip(), remplacement.strip(), + _extrait(texte, m.start(), m.end()))) + return remplacement + + texte = _TITRE.sub(_titre, texte) + + # Le remplacement peut réclamer une élision du mot d'avant, qui se trouve + # hors de la correspondance : on reconstruit donc le texte à la main + # plutôt que d'utiliser sub(), qui ne sait pas revenir en arrière. + def _passe(rx: re.Pattern, groupe_verbe: int) -> None: + nonlocal texte + morceaux: list[str] = [] + fin_precedente = 0 + for m in rx.finditer(texte): + # Une phrase qui parle du livre papier ne se répare pas en + # changeant son verbe : « écouter ce livre sur une liseuse » est + # pire que l'original. On la laisse et on la signale. + phrase = _phrase_autour(texte, m.start(), m.end()) + if _SUPPORT_LECTURE.search(phrase): + signalements.append(Signalement( + "support", " ".join(phrase.split())[:180], + "parle du livre papier : conversion refusée, à récrire à la main")) + continue + verbe = m.group(groupe_verbe) + neuf = _accorder(verbe, VERBES[verbe.lower()]) + avant_verbe = texte[fin_precedente:m.start(groupe_verbe)] + morceaux.append(_elider(avant_verbe)) + morceaux.append(neuf) + fin_precedente = m.end(groupe_verbe) + changements.append(Changement( + "verbe", verbe, neuf, _extrait(texte, m.start(), m.end()))) + if morceaux: + morceaux.append(texte[fin_precedente:]) + texte = "".join(morceaux) + + _passe(_AVANT, 1) + _passe(_APRES, 2) + + if slug in LIVRES_SANS_CONVERSION_LECTEUR: + for m in _LECTEUR.finditer(texte): + signalements.append(Signalement( + "lecteur", _extrait(texte, m.start(), m.end()), + f"livre exclu de la règle ({slug}) : son propos oppose lire et écouter")) + else: + morceaux: list[str] = [] + fin_precedente = 0 + for m in _LECTEUR.finditer(texte): + phrase = _phrase_autour(texte, m.start(), m.end()) + if _LECTEUR_GARDE.search(phrase): + signalements.append(Signalement( + "lecteur", " ".join(phrase.split())[:180], + "ne désigne pas le public : laissé intact")) + continue + mot = m.group(0) + neuf = _accorder(mot, LECTEUR_VERS_AUDITEUR[mot.lower()]) + # « auditeur » commence par une voyelle, « lecteur » non : + # « le lecteur » doit donner « l'auditeur », pas « le auditeur ». + morceaux.append(_elider(texte[fin_precedente:m.start()])) + morceaux.append(neuf) + fin_precedente = m.end() + changements.append(Changement( + "lecteur", mot, neuf, _extrait(texte, m.start(), m.end()))) + if morceaux: + morceaux.append(texte[fin_precedente:]) + texte = "".join(morceaux) + for m in _OUVRAGE.finditer(texte): + signalements.append(Signalement( + "cet ouvrage", _extrait(texte, m.start(), m.end()), + "peut désigner ce livre-ci ou un livre cité en référence")) + deja = {s.extrait for s in signalements if s.motif == "support"} + for m in _SUPPORT_SIGNALE.finditer(texte): + extrait = " ".join(_phrase_autour(texte, m.start(), m.end()).split())[:180] + if extrait not in deja: + signalements.append(Signalement( + "support", extrait, + "mentionne le support ou l'écrit : à vérifier à l'oreille")) + + return texte, changements, signalements + + +def rapport_texte(changements: Iterable[Changement], + signalements: Iterable[Signalement]) -> str: + """Un rapport qu'on lit avant de payer la narration, pas après.""" + ch, si = list(changements), list(signalements) + lignes = [f"{len(ch)} adaptation(s), {len(si)} passage(s) à décider à la main", ""] + if ch: + lignes.append("--- adapté ---") + for c in ch: + lignes.append(f" [{c.regle}] {c.avant} → {c.apres}") + lignes.append(f" {c.contexte}") + if si: + lignes.append("") + lignes.append("--- signalé, laissé intact ---") + par_motif: dict[str, list[Signalement]] = {} + for s in si: + par_motif.setdefault(s.motif, []).append(s) + for motif, groupe in par_motif.items(): + lignes.append(f" {motif} ({len(groupe)}) — {groupe[0].pourquoi}") + for s in groupe: + lignes.append(f" {s.extrait}") + return "\n".join(lignes) diff --git a/scripts/prepare_manuscript.py b/scripts/prepare_manuscript.py index d78e0391..f9eca1d0 100644 --- a/scripts/prepare_manuscript.py +++ b/scripts/prepare_manuscript.py @@ -33,6 +33,9 @@ from dataclasses import dataclass, field from typing import List +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent)) +from narration import adresse_audio # noqa: E402 (après l'ajout au chemin) + # A heading that opens something a narrator actually reads. Anything before the # first of these is front matter: title page, copyright, ISBN, contents. CONTENT_HEADING = re.compile( @@ -310,6 +313,11 @@ def main() -> int: ap.add_argument("manuscript", help="fichier Markdown") ap.add_argument("-o", "--output", help="fichier .txt de sortie (défaut : à côté du manuscrit)") ap.add_argument("--report", action="store_true", help="détailler ce qui a été retiré") + ap.add_argument("--no-adresse-audio", action="store_true", + help="ne pas adapter « lire ce livre » en « écouter ce livre audio »") + ap.add_argument("--rapport-adresse", metavar="FICHIER", + help="écrire le détail de l'adaptation à l'écoute, et les " + "passages laissés à décider à la main") args = ap.parse_args() src = pathlib.Path(args.manuscript) @@ -330,6 +338,23 @@ def main() -> int: print("aucun texte narrable trouvé", file=sys.stderr) return 1 + # Le manuscrit s'adresse à un lecteur ; l'audio s'adresse à un auditeur. + # L'adaptation vient en dernier, sur le texte déjà nettoyé : elle raisonne + # sur des phrases, et les phrases n'existent qu'une fois les blocs de mise + # en forme retirés. + adaptations, signalements = [], [] + if not args.no_adresse_audio: + adaptes = [] + # Le nom du dossier identifie le livre : un livre dont le sujet est la + # différence entre lire et écouter échappe à la règle sur « lecteur ». + slug = src.parent.name + for c in chapters: + neuf, ch, sig = adresse_audio.adapter(c, slug=slug) + adaptes.append(neuf) + adaptations.extend(ch) + signalements.extend(sig) + chapters = adaptes + out = pathlib.Path(args.output) if args.output else src.with_suffix(".narration.txt") body = "\n\n---\n\n".join(chapters) # The separator must be unambiguous: it is the one thing narrate_book.py @@ -342,6 +367,15 @@ def main() -> int: for i, c in enumerate(chapters, 1): print(f" {i:>3}. {c.splitlines()[0][:62]:<62} {len(c):>7} car.") + if adaptations or signalements: + print(f"\nAdressé à l'auditeur : {len(adaptations)} adaptation(s), " + f"{len(signalements)} passage(s) à décider à la main") + for c in adaptations: + print(f" [{c.regle}] {c.avant} → {c.apres}") + if args.rapport_adresse: + pathlib.Path(args.rapport_adresse).write_text( + adresse_audio.rapport_texte(adaptations, signalements), encoding="utf-8") + counts: dict[str, int] = {} for r in removed_parse: # Grouper par nature : 969 lignes « pause : [PAUSE] » n'apprennent rien diff --git a/tests/test_adresse_audio.py b/tests/test_adresse_audio.py new file mode 100644 index 00000000..049fea25 --- /dev/null +++ b/tests/test_adresse_audio.py @@ -0,0 +1,173 @@ +"""Ce que l'adaptation à l'écoute doit faire — et surtout ce qu'elle ne doit pas. + +Les cas négatifs comptent plus que les positifs : une conversion manquée +s'entend une fois, une conversion fautive fait dire à l'auteur le contraire de +ce qu'il a écrit, dans quarante-six livres à la fois. +""" +import pytest + +from narration.adresse_audio import adapter, rapport_texte + + +def adapte(texte: str) -> str: + return adapter(texte)[0] + + +class TestIntertitres: + def test_le_titre_le_plus_frequent(self): + # Présent dans dix-neuf livres sur quarante-six. + assert adapte("Comment lire ce livre") == "Comment écouter ce livre audio" + + def test_le_titre_garde_sa_suite(self): + assert adapte("Comment lire ce livre, et comment l'utiliser") == \ + "Comment écouter ce livre audio, et comment l'utiliser" + + def test_un_chapitre_ne_devient_pas_audio(self): + # « audio » se dit du livre, une fois ; le répéter par chapitre lasse. + assert adapte("Comment lire ce chapitre") == "Comment écouter ce chapitre" + + def test_le_titre_doit_etre_seul_sur_sa_ligne(self): + # Au milieu d'un paragraphe, c'est une phrase, pas un intertitre : + # la règle des verbes s'en charge, sans ajouter « audio ». + assert "ce livre audio" not in adapte("Il explique comment lire ce livre en trois jours.") + + +class TestVerbes: + @pytest.mark.parametrize("avant, apres", [ + ("Vous lisez ce livre par curiosité.", "Vous écoutez ce livre par curiosité."), + ("Si vous avez lu ce livre jusqu'ici.", "Si vous avez écouté ce livre jusqu'ici."), + ("Vous pouvez lire ce livre comme une exploration.", + "Vous pouvez écouter ce livre comme une exploration."), + ("En lisant ce livre, vous comprendrez.", "En écoutant ce livre, vous comprendrez."), + ("Les parents qui lisent ce chapitre le savent.", + "Les parents qui écoutent ce chapitre le savent."), + ("Ceux qui liront ces pages y trouveront un appui.", + "Ceux qui écouteront ces pages y trouveront un appui."), + ("Relisez ce chapitre demain.", "Réécoutez ce chapitre demain."), + ]) + def test_conjugaisons(self, avant, apres): + assert adapte(avant) == apres + + def test_la_casse_est_conservee(self): + assert adapte("Lisez ce livre lentement.") == "Écoutez ce livre lentement." + + def test_reference_avant_le_verbe(self): + assert adapte("Ce livre se lit d'une traite.") == "Ce livre s'écoute d'une traite." + + @pytest.mark.parametrize("avant, apres", [ + # Tous les remplacements commencent par une voyelle, ce que « lire » + # ne faisait pas : le mot d'avant doit s'élider. + ("Ce livre se lit vite.", "Ce livre s'écoute vite."), + ("Ce chapitre ne se lit pas seul.", "Ce chapitre ne s'écoute pas seul."), + ("Je lis ce livre le soir.", "J'écoute ce livre le soir."), + ]) + def test_elision(self, avant, apres): + assert adapte(avant) == apres + + def test_la_distance_est_bornee(self): + # Sans borne, un « lire » de la page d'avant s'accrocherait à un + # « ce livre » de la page d'après. + loin = "lire " + "x" * 80 + " ce livre" + assert adapte(loin) == loin + + +class TestCeQuiNeDoitPasBouger: + def test_la_lecture_en_general(self): + t = "Apprendre à lire transforme le cerveau." + assert adapte(t) == t + + def test_un_autre_livre(self): + # Mesuré dans livre-06 : il s'agit d'un manuel de réseau cité. + t = "Des ingénieurs se sont formées en lisant cet ouvrage de référence." + assert adapte(t) == t + + @pytest.mark.parametrize("t", [ + # Chacun relevé dans le corpus, dans sa phrase. + "Des applications non compatibles avec les lecteurs d'écran.", + "Payer par simple contact de la main avec un lecteur.", + "Elle a consacré sa carrière à l'étude du cerveau lecteur.", + "Lui-même, lecteur assidu depuis l'enfance, peine à finir un chapitre.", + "Les lecteurs numériques avaient intériorisé un mode de balayage.", + "Des plateformes qui relient annonceurs et lecteurs pour les journaux.", + "Quand le récit implique un auditeur ou un lecteur, la relation compte.", + "Comme d'autres se présenteraient comme lecteurs de Proust.", + ]) + def test_lecteur_qui_ne_designe_pas_le_public(self, t): + assert adapte(t) == t + + def test_lire_sans_reference_au_livre(self): + t = "Elle a lu trois romans cet été." + assert adapte(t) == t + + +class TestLecteurDevientAuditeur: + @pytest.mark.parametrize("avant, apres", [ + ("À vous, lecteurs, qui avez la curiosité.", "À vous, auditeurs, qui avez la curiosité."), + ("Le lecteur est invité à expérimenter.", "L'auditeur est invité à expérimenter."), + ("Ce livre s'adresse à plusieurs catégories de lecteurs.", + "Ce livre s'adresse à plusieurs catégories d'auditeurs."), + ("Chacune offre de l'inspiration pour les lectrices contemporaines.", + "Chacune offre de l'inspiration pour les auditrices contemporaines."), + ]) + def test_le_public_devient_auditeur(self, avant, apres): + assert adapte(avant) == apres + + def test_un_livre_peut_etre_exclu_en_entier(self): + # « L'EFFET PODCAST » oppose page après page la lecture et l'écoute. + t = "Le format du livre demande à son lecteur ce que le podcast demande à son auditeur." + assert adapter(t, slug="livre-21-podcast")[0] == t + # Le même texte, dans un autre livre, se convertit. + assert "auditeur" in adapter("Le lecteur trouvera ici des pistes.")[0] + + +class TestSignalements: + def test_lecteur_ecarte_est_signale(self): + _, _, sig = adapter("L'étude du cerveau lecteur est ancienne.") + assert [s.motif for s in sig] == ["lecteur"] + + def test_le_support_bloque_la_conversion(self): + # Mesuré dans livre-01. « écouter ce livre sur une liseuse » serait + # pire que la phrase d'origine : la conversion doit être refusée. + texte = "Vous allez peut-être lire ce livre sur une liseuse ou un téléphone." + neuf, ch, sig = adapter(texte) + assert neuf == texte + assert not ch + assert any(s.motif == "support" for s in sig) + + def test_un_crayon_ne_bloque_pas(self): + # Écrire en écoutant est possible : « crayon » n'est pas un support + # de lecture, il ne doit pas empêcher une conversion juste. + neuf, ch, _ = adapter("Écoutez-moi : lisez ce livre, crayon à la main.") + assert "écoutez ce livre" in neuf.lower() + assert ch + + def test_cet_ouvrage_est_signale_sans_etre_touche(self): + texte = "Ils se sont formés en lisant cet ouvrage." + neuf, ch, sig = adapter(texte) + assert neuf == texte + assert not ch + assert any(s.motif == "cet ouvrage" for s in sig) + + def test_le_rapport_dit_les_deux(self): + _, ch, sig = adapter("Comment lire ce livre\nL'étude du cerveau lecteur.") + r = rapport_texte(ch, sig) + assert "1 adaptation(s)" in r + assert "1 passage(s)" in r + assert "cerveau lecteur" in r + + +class TestTexteEntier: + def test_un_passage_reel(self): + # Extrait de livre-01, tel qu'il est dans la file. + source = ( + "Comment lire ce livre\n\n" + "Ce livre a été conçu pour s'adapter à un cerveau qui a perdu " + "l'habitude de la lecture longue. Vous lisez ce livre par curiosité " + "plus que par nécessité." + ) + neuf, ch, _ = adapter(source) + assert neuf.startswith("Comment écouter ce livre audio") + assert "Vous écoutez ce livre" in neuf + # « l'habitude de la lecture longue » parle de lecture en général. + assert "lecture longue" in neuf + assert len(ch) == 2 From c04a158b5cd615262dda79f44686ede9f3d7574d Mon Sep 17 00:00:00 2001 From: PaxHelios Date: Thu, 13 Aug 2026 17:11:07 +0200 Subject: [PATCH 89/98] =?UTF-8?q?Deux=20entr=C3=A9es=20de=20lexique=20vali?= =?UTF-8?q?d=C3=A9es=20=C3=A0=20l'oreille,=20et=20pas=20une=20de=20plus?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit « ACE » et « ce ». ACE : le moteur avale le E final, la relecture ASR entend « AC » — dans les deux voix. C'est le seul sigle du corpus qui soit mal dit. 37 ont été mis à l'épreuve (IA, PIB, TSPT, TCC, EMDR, DSM, SAMU, INSEE, OCDE, MIT, CRISPR, CO2…) et 36 se prononcent juste sans aucune aide : l'option « épeler tous les sigles » aurait ajouté 36 corrections nuisibles. N'ajoutez pas un sigle par précaution, mesurez-le. ce → çe : le c était durci. Edwin a tranché entre çe, sé, se et seu par scripts/try_pronunciation.py, puis sur 19 minutes de narration réelle. C'est l'entrée la plus lourde du dépôt : 12 498 occurrences, deux fois et demie « ces ». Elle s'entend toutes les quinze secondes pendant cinq heures, dans chaque livre. D'où le corollaire, mesuré : toucher un mot aussi fréquent invalide le cache de segments de TOUS les livres — 0 réutilisation sur 105 segments. Un correctif de lexique tardif ne coûte pas un segment, il coûte la totalité de la narration déjà faite. C'est pourquoi celui-ci arrive avant la renarration du catalogue, et pas après. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01SoLfJRcXeLGs1Q5bdF1RWB --- conf/pronunciation_fr.json | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/conf/pronunciation_fr.json b/conf/pronunciation_fr.json index 751e4fe9..b1dc4c80 100644 --- a/conf/pronunciation_fr.json +++ b/conf/pronunciation_fr.json @@ -5,6 +5,8 @@ "RATP": "R A T P", "ONU": "O N U", "URSS": "U R S S", + "_comment_ACE": "Validé à l'oreille par Edwin le 2026-08-12, parmi « A C E », « A. C. E. », « a-cé-eu » et « acé ». Le moteur avale le E final : la relecture ASR entend « AC », et « un score S élevé », dans les DEUX voix. C'est le seul sigle du corpus qui soit mal dit — 37 ont été mis à l'épreuve (IA, PIB, TSPT, TCC, EMDR, DSM, CIM, SAMU, OMS, RGPD, INSEE, OCDE, FMI, MIT, CRISPR, ENIAC, ADN, CO2…) et 36 se prononcent juste sans aucune aide. N'ajoutez donc pas un sigle par précaution : mesurez-le d'abord.", + "ACE": "A C E", "_exemple_etrangers": "--- mots étrangers ---", "Wi-Fi": "wifi", "email": "i-mail", @@ -55,5 +57,7 @@ "_comment_ces": "Validé à l'oreille par Edwin le 2026-08-09 : le moteur avale le s final de « ces » et le dit « ce ». « cés » rétablit le son /se/. Ce n'est pas un homographe — « ces » se dit toujours /se/ — donc aucune règle de contexte n'est nécessaire ici, contrairement à « plus » ou « fils ».", "ces": "cés", "_comment_ses": "Même défaut que « ces », même correction par analogie : l'accent rétablit le /e/ final. Appliqué sans validation directe à l'oreille — le parallèle est exact, mais si « sés » sonne faux, retirez cette ligne.", - "ses": "sés" + "ses": "sés", + "_comment_ce": "Validé à l'oreille par Edwin le 2026-08-12, sur la phrase « Comment écouter ce livre audio », voix Alex Somerset, parmi « çe », « sé », « se » et « seu ». Le moteur durcissait le c ; la cédille impose le /s/. C'EST L'ENTRÉE LA PLUS LOURDE DU LEXIQUE : 12 498 occurrences dans les quarante-six livres, deux fois et demie « ces ». Toute modification ici s'entend toutes les quinze secondes pendant cinq heures, dans chaque livre — ne la changez pas sans réécouter. Corollaire à connaître : changer cette ligne invalide le cache de segments de TOUS les livres, puisque presque chaque segment contient un « ce ».", + "ce": "çe" } \ No newline at end of file From 850d2dbc00ec6f6dc2dd5b33909b2b24046ca590 Mon Sep 17 00:00:00 2001 From: PaxHelios Date: Thu, 13 Aug 2026 17:11:21 +0200 Subject: [PATCH 90/98] De quoi mener une file de quarante-six livres sans la perdre MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit construire_file.py bâtit la file depuis le catalogue : 47 manuscrits exploitables sur 103 dossiers, 46 retenus (livre-49-tdah-feminin écarté, aucune couverture). Titre, sous-titre et auteur viennent de metadata/book_metadata.json ou, à défaut, de la page de titre du manuscrit. Le script propose une voix mais ne l'impose jamais aux livres déjà attribués : confrontée aux 21 choix faits à la main, ma règle en contredisait quatre. Un choix éditorial déjà fait ne se recalcule pas. Les trois scripts d'exploitation répondent à trois situations qu'on confond sous pression : - relancer_renarration.sh efface l'état et les livrables, et refait tout ; - reprendre_file.sh reprend sans rien effacer, et refuse de démarrer si une file tourne déjà ; - rapatrier_livres.sh copie, VÉRIFIE, puis seulement efface. L'ordre n'est pas négociable. La vérification compare le nombre de fichiers ACX et la taille du M4B à l'octet ; un livre dont la copie ne correspond pas reste sur le pod. Sans rapatriement en cours de route, 44 livres à 541 Mo saturent le volume vers le vingt-septième, et tous les suivants échouent pour une cause qui n'a rien à voir avec la narration. Les deux premiers imposent `cd /workspace/voxcpm` avant tout. narrate_queue.py construit ses chemins en relatif alors que ses sous-processus tournent avec cwd=REPO : lancé d'ailleurs, il écrit au bon endroit mais ne retrouve plus rien, annonce « terminé — 0 chapitre(s) » sur un livre complet et valide, et surtout ne purge pas les WAV — 5,5 Go laissés au lieu de 0,54. queue/ et temoin/ passent au .gitignore. Ce sont les livres eux-mêmes, pas le moteur qui les lit, et ce dépôt est public. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01SoLfJRcXeLGs1Q5bdF1RWB --- .gitignore | 7 + scripts/construire_file.py | 257 ++++++++++++++++++++++++++++++++ scripts/rapatrier_livres.sh | 68 +++++++++ scripts/relancer_renarration.sh | 72 +++++++++ scripts/reprendre_file.sh | 36 +++++ 5 files changed, 440 insertions(+) create mode 100644 scripts/construire_file.py create mode 100644 scripts/rapatrier_livres.sh create mode 100644 scripts/relancer_renarration.sh create mode 100644 scripts/reprendre_file.sh diff --git a/.gitignore b/.gitignore index 8d6aeb69..016576a0 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,10 @@ assets/voices/ # Archived generations output/ + +# La file de production : textes narrables des manuscrits, couvertures, état +# d'avancement, extraits d'écoute. Rien de tout cela n'a sa place dans un dépôt +# public — ce sont les livres eux-mêmes, pas le moteur qui les lit. La file se +# reconstruit depuis le catalogue avec scripts/construire_file.py. +queue/ +temoin/ diff --git a/scripts/construire_file.py b/scripts/construire_file.py new file mode 100644 index 00000000..cb768d22 --- /dev/null +++ b/scripts/construire_file.py @@ -0,0 +1,257 @@ +#!/usr/bin/env python3 +"""Construit la file de narration à partir du catalogue de manuscrits. + +Un livre entre dans la file quand quatre choses existent : un manuscrit que +le préparateur accepte, un titre, un auteur, une couverture. Tout le reste a +un défaut raisonnable. Ce qui manque est dit, pas deviné : un livre incomplet +est écarté avec sa raison, parce qu'un livre qui échoue à la minute quatre- +vingt-dix-huit coûte plus cher que celui qu'on n'a pas lancé. + +Les métadonnées ont deux âges dans ce catalogue. Les livres récents portent +un ``metadata/book_metadata.json`` ; les plus anciens n'ont que leur propre +page de titre, en tête du manuscrit — un ``#`` pour le titre, un ``##`` pour +le sous-titre, un nom en gras pour l'auteur. Les deux sont lus. + +La voix se choisit sur le sujet, pas sur l'auteur : c'est la règle que suivent +déjà les vingt et un livres narrés. Un propos intime, parental ou +thérapeutique va à la voix féminine ; un essai, une enquête ou un atlas va à +la voix masculine. Le champ ``why`` garde la raison, pour qu'un choix puisse +être discuté plus tard au lieu d'être subi. +""" +from __future__ import annotations + +import argparse +import json +import pathlib +import re +import subprocess +import sys + +VOIX_FEMININE = "Aurore — livre audio" +VOIX_MASCULINE = "Alex Somerset" + +IMAGES = (".jpg", ".jpeg", ".png", ".webp") + +#: Un sujet intime, parental ou thérapeutique appelle la voix féminine. +#: +#: Attention à la portée de cette règle : confrontée aux vingt et un livres +#: déjà attribués à la main, elle en contredit quatre. Elle ne sert donc +#: qu'aux livres encore sans voix — un choix éditorial déjà fait ne se +#: recalcule pas, il se conserve (cf. ``--file-existante``). +#: +#: Les alternances courtes sont bornées par \b : sans cela « non » se trouve +#: au milieu de n'importe quel mot et emporte le classement. +INTIME = re.compile( + r"m[ée]dit|respir|sommeil|dormir|anxi[ée]t|d[ée]press|trauma|couple|" + r"enfant|parent|maternit|m[ée]nopause|intimit|int[ée]rieur|apais|" + r"calme|matins|habitudes|renouer|estrangement|panique|f[ée]minin|" + r"tdah|th[ée]rap|\bsoi\b|\bflow\b|\bnon\b|\bvoix\b|\bpiliers\b|" + r"\bcorps\b|\bfemmes?\b|\bintention\b", + re.IGNORECASE, +) + + +def couverture(d: pathlib.Path) -> pathlib.Path | None: + """La couverture a changé de dossier au fil des versions du catalogue.""" + for sous in ("couverture", "_covers_v3", "_covers_v2", "_covers", "formats"): + rep = d / sous + if rep.is_dir(): + for f in sorted(rep.rglob("*")): + if f.suffix.lower() in IMAGES and f.stat().st_size > 20_000: + return f + for f in sorted(d.glob("*")): + if f.suffix.lower() in IMAGES and f.stat().st_size > 20_000: + return f + return None + + +def _depuis_json(d: pathlib.Path): + bm = d / "metadata" / "book_metadata.json" + if not bm.is_file(): + return None + try: + m = json.loads(bm.read_text(encoding="utf-8")).get("book_metadata", {}) + except (json.JSONDecodeError, OSError): + return None + if not m.get("titre"): + return None + return m["titre"], m.get("sous_titre", ""), m.get("auteur", "") + + +def _depuis_manuscrit(man: pathlib.Path): + """La page de titre du manuscrit, quand aucun fichier ne la porte. + + On ne lit que la tête : au-delà, un ``#`` est un titre de chapitre et + non le titre du livre. « Front matter » est un intitulé de section, pas + un titre — le vrai suit. + """ + tete = man.read_text(encoding="utf-8", errors="replace")[:4000] + titres = re.findall(r"(?m)^#\s+(.+?)\s*$", tete) + titres = [t for t in titres if t.strip().lower() not in ("front matter", "sommaire")] + if not titres: + return None + sous = re.search(r"(?m)^##\s+(.+?)\s*$", tete) + sous_t = sous.group(1).strip() if sous else "" + if sous_t.lower() in ("sommaire", "table des matières"): + sous_t = "" + auteur = re.search(r"(?m)^\*\*([^*]{3,60})\*\*\s*$", tete) + return titres[0].strip(), sous_t, (auteur.group(1).strip() if auteur else "") + + +def metadonnees(d: pathlib.Path, man: pathlib.Path): + return _depuis_json(d) or _depuis_manuscrit(man) or (None, "", "") + + +def choisir_voix(slug: str, titre: str, sous_titre: str) -> tuple[str, str]: + matiere = f"{slug} {titre} {sous_titre}" + if INTIME.search(matiere): + return VOIX_FEMININE, "sujet intime ou d'accompagnement — voix féminine" + return VOIX_MASCULINE, "essai ou enquête — registre documentaire" + + +def main() -> int: + for flux in (sys.stdout, sys.stderr): + try: + flux.reconfigure(encoding="utf-8", errors="replace") + except (AttributeError, ValueError): + pass + + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("catalogue", help="dossier contenant les livre-*/") + ap.add_argument("-o", "--output", default="queue/queue.json") + ap.add_argument("--txt-dir", default="queue", help="où écrire les textes préparés") + ap.add_argument("--covers-dir", default="queue/covers") + ap.add_argument("--min-chars", type=int, default=50_000) + ap.add_argument("--file-existante", metavar="JSON", + help="file déjà curée : ses voix, titres et auteurs sont " + "conservés tels quels. Un choix éditorial déjà fait " + "ne se recalcule pas.") + ap.add_argument("--dry-run", action="store_true", + help="ne rien écrire : dire seulement ce qui entrerait") + args = ap.parse_args() + + ancienne: dict[str, dict] = {} + if args.file_existante: + p = pathlib.Path(args.file_existante) + if p.is_file(): + q = json.loads(p.read_text(encoding="utf-8")) + for it in (q if isinstance(q, list) else q.get("books", [])): + ancienne[it["slug"]] = it + print(f"file existante : {len(ancienne)} livres déjà curés, conservés\n") + + racine = pathlib.Path(args.catalogue) + if not racine.is_dir(): + print(f"catalogue introuvable : {racine}", file=sys.stderr) + return 1 + + txt_dir = pathlib.Path(args.txt_dir) + cov_dir = pathlib.Path(args.covers_dir) + if not args.dry_run: + txt_dir.mkdir(parents=True, exist_ok=True) + cov_dir.mkdir(parents=True, exist_ok=True) + + prepare = pathlib.Path(__file__).with_name("prepare_manuscript.py") + retenus, ecartes = [], [] + + for d in sorted(racine.iterdir()): + if not d.is_dir(): + continue + man = d / "manuscrit_complet.md" + if not man.is_file(): + continue + if man.stat().st_size < args.min_chars: + ecartes.append((d.name, f"manuscrit trop court ({man.stat().st_size} o)")) + continue + + titre, sous_titre, auteur = metadonnees(d, man) + if not titre: + ecartes.append((d.name, "aucun titre trouvé")) + continue + cov = couverture(d) + if cov is None: + ecartes.append((d.name, "aucune couverture")) + continue + + txt = txt_dir / f"{d.name}.txt" + if not args.dry_run: + r = subprocess.run( + [sys.executable, str(prepare), str(man), "-o", str(txt)], + capture_output=True, text=True, encoding="utf-8", errors="replace") + if r.returncode != 0: + ecartes.append((d.name, f"préparation refusée : {r.stderr.strip()[:60]}")) + continue + corps = txt.read_text(encoding="utf-8") + chapitres = corps.count("\n---\n") + 1 + chars = len(corps) + cible = cov_dir / f"{d.name}{cov.suffix.lower()}" + cible.write_bytes(cov.read_bytes()) + cov_rel = f"{cov_dir.as_posix()}/{cible.name}" + else: + chapitres, chars, cov_rel = 0, man.stat().st_size, str(cov) + + # Un livre déjà curé garde sa voix, son titre et son auteur : ces + # choix ont été faits à l'oreille et à la lecture, la règle ci-dessous + # n'en sait pas autant. + vieux = ancienne.get(d.name) + if vieux: + voix = vieux.get("voice", VOIX_FEMININE) + pourquoi = vieux.get("why", "") + titre = vieux.get("title") or titre + sous_titre = vieux.get("subtitle", sous_titre) + auteur = vieux.get("author") or auteur + else: + voix, pourquoi = choisir_voix(d.name, titre, sous_titre) + + retenus.append({ + "slug": d.name, + "txt": f"{d.name}.txt", + "voice": voix, + "why": pourquoi, + "cloned": True, + "chapters": chapitres, + "chars": chars, + "title": titre, + "subtitle": sous_titre, + "author": auteur or "Edwin Osayamwen", + "renarration": False, + "cover": cov_rel, + "nouveau": vieux is None, + }) + + # Le plus long d'abord : un échec de disque ou de GPU arrive alors sur le + # livre le plus coûteux, quand la marge est encore intacte. + retenus.sort(key=lambda b: -b["chars"]) + + print(f"retenus : {len(retenus)} écartés : {len(ecartes)}\n") + par_voix: dict[str, int] = {} + for b in retenus: + par_voix[b["voice"]] = par_voix.get(b["voice"], 0) + 1 + marque = "NEUF " if b["nouveau"] else " " + print(f" {marque}{b['slug']:<36}{b['chars']:>8,} · " + f"{b['voice'][:20]:<22}{b['title'][:36]}".replace(",", " ")) + if ecartes: + print("\nécartés :") + for slug, raison in ecartes: + print(f" {slug:<36}{raison}") + + total = sum(b["chars"] for b in retenus) + print(f"\nrépartition des voix : {par_voix}") + print(f"total : {total:,} caractères".replace(",", " ")) + print(f"estimation : {total / 250_000 * 2.75:.0f} h de GPU, " + f"{total / 250_000 * 2.75 * 0.34:.0f} $, " + f"{total / 250_000 * 450 / 1024:.1f} Go de livrables") + + if args.dry_run: + print("\n(--dry-run : rien n'a été écrit)") + return 0 + + out = pathlib.Path(args.output) + out.parent.mkdir(parents=True, exist_ok=True) + out.write_text(json.dumps(retenus, ensure_ascii=False, indent=2), encoding="utf-8") + print(f"\nfile écrite : {out}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/rapatrier_livres.sh b/scripts/rapatrier_livres.sh new file mode 100644 index 00000000..af7d313b --- /dev/null +++ b/scripts/rapatrier_livres.sh @@ -0,0 +1,68 @@ +#!/bin/bash +# Rapatrie les livres terminés, vérifie la copie, puis libère le pod. +# +# Le volume loué fait 30 Go dont 15 utilisables, et chaque livre pèse ~540 Mo +# une fois purgé de ses WAV : quarante-six livres réclament 25 Go. Sans +# rapatriement en cours de route, la file meurt d'un disque plein vers le +# vingt-septième — après quoi tous les suivants échouent pour une cause qui +# n'a rien à voir avec la narration. +# +# L'ordre compte, et il n'est pas négociable : copier, VÉRIFIER, puis +# seulement effacer. La vérification compare le nombre de fichiers et la +# taille totale, poste par poste. Un livre dont la copie ne correspond pas +# reste sur le pod — mieux vaut un disque qui se remplit qu'un livre perdu. +set -uo pipefail + +POD="root@194.26.196.166" +PORT=41114 +DIST="/workspace/voxcpm/output" +LOCAL="${1:-/c/Users/PaxHelios/voxcpm-livres}" + +ssh_pod() { ssh -n -o BatchMode=yes -o ConnectTimeout=15 -p "$PORT" "$POD" "$@"; } + +mkdir -p "$LOCAL/acx" + +# Un livre n'est rapatriable qu'une fois purgé : la présence de WAV signifie +# que la chaîne n'a pas fini de le traiter. +livres=$(ssh_pod "cd $DIST 2>/dev/null && for d in book_*/; do d=\${d%/}; \ + [ -f \"\$d\"/*.m4b ] 2>/dev/null || continue; \ + [ -z \"\$(ls \$d/*.wav 2>/dev/null)\" ] && echo \$d; done" 2>/dev/null) + +[ -z "$livres" ] && { echo "aucun livre prêt à rapatrier"; exit 0; } + +rapatries=0 +for b in $livres; do + if [ -f "$LOCAL/${b}_deja" ]; then continue; fi + echo "── $b" + + # On compare ce qui est comparable : le nombre de fichiers ACX d'un côté et + # de l'autre, et la taille du M4B à l'octet. « ls *.m4b acx » comptait aussi + # ses propres en-têtes de section, d'où un décompte faux d'une unité. + n_acx_dist=$(ssh_pod "ls $DIST/$b/acx | wc -l") + o_m4b_dist=$(ssh_pod "stat -c%s $DIST/$b/*.m4b") + + mkdir -p "$LOCAL/acx/$b" + scp -q -P "$PORT" "$POD:$DIST/$b/*.m4b" "$LOCAL/" 2>/dev/null + scp -q -P "$PORT" "$POD:$DIST/$b/acx/*" "$LOCAL/acx/$b/" 2>/dev/null + scp -q -P "$PORT" "$POD:$DIST/$b/qc_report.json" "$LOCAL/acx/$b/qc_report.json" 2>/dev/null + + n_acx_loc=$(ls "$LOCAL/acx/$b" 2>/dev/null | grep -cv "^qc_report.json$") + m4b_loc=$(ls "$LOCAL"/${b}_complet.m4b 2>/dev/null | head -1) + o_m4b_loc=$([ -n "$m4b_loc" ] && stat -c%s "$m4b_loc" || echo 0) + + if [ "$n_acx_loc" -ne "$n_acx_dist" ] || [ "$o_m4b_loc" != "$o_m4b_dist" ]; then + echo " REFUS — rien effacé" + echo " ACX : $n_acx_loc copiés / $n_acx_dist attendus" + echo " M4B : $o_m4b_loc octets / $o_m4b_dist attendus" + continue + fi + + ssh_pod "rm -rf $DIST/$b" + touch "$LOCAL/${b}_deja" + rapatries=$((rapatries + 1)) + echo " $n_acx_loc fichiers ACX + M4B vérifiés à l'octet, libéré sur le pod" +done + +echo +echo "rapatriés : $rapatries" +ssh_pod "df -h /workspace | tail -1" diff --git a/scripts/relancer_renarration.sh b/scripts/relancer_renarration.sh new file mode 100644 index 00000000..ccba50a7 --- /dev/null +++ b/scripts/relancer_renarration.sh @@ -0,0 +1,72 @@ +#!/bin/bash +# Remet la file entière en chantier, après le correctif de prononciation. +# +# Pourquoi tout refaire : sur les vingt livres narrés, six seulement ont +# tourné avec la version corrigée de narration/text_fr.py. Les quatorze +# autres portent la troncature sur parenthèse courte, les lignes à remplir +# non effacées et « min » dit « mines ». À l'échelle du catalogue visé, un +# défaut se corrige dans la chaîne, pas dans le fichier — donc on refait. +# +# Les livrables de la passe précédente sont sauvegardés en local +# (~/voxcpm-livres : 20 M4B + 20 dossiers ACX, vérifiés octet pour octet) +# avant que ce script ne les écrase. +set -euo pipefail + +cd /workspace/voxcpm + +# 1. Archiver l'état avant de le remettre à zéro : sans cette copie, on perd +# la trace de quel livre avait été narré quand, et donc avec quel code. +if [ -f queue/state.json ]; then + cp queue/state.json queue/state.avant-renarration.json + echo "état précédent archivé dans queue/state.avant-renarration.json" +fi +echo '{}' > queue/state.json +echo "état remis à zéro — quarante-six livres, dont vingt et un à refaire" + +# 2. Faire de la place. Le disque est le seul endroit où un lot de quarante-six +# peut échouer sans prévenir : chaque livre monte à ~5 Go de WAV avant que +# la chaîne ne les efface. +# +# Aucun cache n'est épargné, et c'est délibéré. L'entrée « ce » → « çe » +# ajoutée au lexique le 2026-08-12 change le texte de presque tous les +# segments, donc leur clé de cache : mesuré sur l'introduction de +# livre-01, 0 réutilisation sur 105 segments. Garder ces gigaoctets +# reviendrait à conserver un index qui ne pointe plus sur rien. +cd output +rm -rf book_* temoin_* essai_* prononciation +cd .. +echo "libre après purge : $(df -h /workspace | tail -1 | awk '{print $4}')" + +# 3. Relancer, détaché, avec exactement les options de la passe précédente. +# Environnement nu et LANG explicite : un shell ouvert par ssh n'hérite +# d'aucune locale, et ffmpeg renvoie des titres accentués qui font tomber +# l'assemblage APRÈS une narration réussie. +# Le journal porte l'histoire de ce qui a été narré, quand, et en combien de +# temps — le seul endroit où se lit le coût réel. L'écraser à chaque relance +# efface cette mémoire : on l'archive, et le nouveau s'ajoute à la suite. +if [ -s /workspace/queue.log ]; then + cat /workspace/queue.log >> /workspace/queue-historique.log +fi + +# Le répertoire de travail DOIT être le dépôt. +# +# narrate_queue.py construit ses chemins en relatif — « output/book_ ». +# Lancé depuis /workspace, il cherchait donc /workspace/output, qui n'existe +# pas : ses sous-processus, eux, tournent avec cwd=REPO et écrivaient au bon +# endroit. Résultat, la file voyait zéro chapitre là où vingt existaient, ne +# purgeait rien, et annonçait « terminé — 0 chapitre(s) » sur un livre entier +# et valide. Un livre laissait alors 5,5 Go au lieu de 0,45 : le volume +# saturait au troisième. +cd /workspace/voxcpm + +setsid nohup env LANG=C.UTF-8 LC_ALL=C.UTF-8 \ + ./.venv/bin/python \ + scripts/narrate_queue.py \ + queue/queue_46.json \ + --device cuda --keep deliverables --no-synthetic-disclosure --audit 120 \ + > /workspace/queue.log 2>&1 < /dev/null & + +sleep 5 +echo +echo "file relancée — journal : /workspace/queue.log" +head -3 /workspace/queue.log 2>/dev/null || true diff --git a/scripts/reprendre_file.sh b/scripts/reprendre_file.sh new file mode 100644 index 00000000..33f1d42f --- /dev/null +++ b/scripts/reprendre_file.sh @@ -0,0 +1,36 @@ +#!/bin/bash +# Reprend la file là où elle s'est arrêtée, sans rien remettre à zéro. +# +# À distinguer de relancer_renarration.sh, qui efface l'état et les livrables +# pour tout refaire. Ici, un livre marqué « done » dans queue/state.json est +# sauté et son M4B conservé : c'est ce qu'il faut après une interruption — +# panne, correctif déployé à chaud, ou arrêt volontaire. +# +# Le répertoire de travail DOIT être le dépôt. narrate_queue.py construit ses +# chemins en relatif (« output/book_ ») ; lancé d'ailleurs, il ne +# retrouve pas les livres qu'il vient de produire, annonce « 0 chapitre(s) » +# et surtout ne purge plus les WAV — un livre laisse alors 5,5 Go au lieu de +# 0,45 et le volume sature au troisième. +set -euo pipefail + +cd /workspace/voxcpm + +if pgrep -f "narrate_queue.py" > /dev/null; then + echo "une file tourne déjà — rien fait" >&2 + exit 1 +fi + +if [ -s /workspace/queue.log ]; then + cat /workspace/queue.log >> /workspace/queue-historique.log +fi + +setsid nohup env LANG=C.UTF-8 LC_ALL=C.UTF-8 \ + ./.venv/bin/python \ + scripts/narrate_queue.py \ + queue/queue_46.json \ + --device cuda --keep deliverables --no-synthetic-disclosure --audit 120 \ + > /workspace/queue.log 2>&1 < /dev/null & + +sleep 5 +echo "file reprise — journal : /workspace/queue.log" +head -4 /workspace/queue.log 2>/dev/null || true From 80d780390e05b8e3a5365805330f5b5c689f8f76 Mon Sep 17 00:00:00 2001 From: PaxHelios Date: Fri, 14 Aug 2026 03:51:52 +0200 Subject: [PATCH 91/98] =?UTF-8?q?Tenir=20la=20majuscule=20de=20=C2=AB=20Ce?= =?UTF-8?q?=20=C2=BB,=20que=20rien=20ne=20gardait?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le lexique corrige « ce » en « çe » parce que le moteur durcit le c. La substitution est insensible à la casse et rend la majuscule au passage, donc « Ce » en tête de phrase devient « Çe ». Aucun test ne le vérifiait, alors que c'est le cas le plus lourd du dépôt : 2 989 des 12 498 « ce » du corpus ouvrent une phrase, soit 24 %, environ soixante-cinq par livre. Ce que coûterait la régression, mesuré plutôt que supposé. Dix phrases réelles tirées de dix livres, voix Alex Somerset, relues par Whisper : « Ce » brut 0/10 juste — « Point C E geste… », « Ces E-chapitres… » « Çe » 10/10 juste « Se » 10/10 juste Le moteur épelle les lettres au lieu de dire le mot, exactement comme il le faisait sur « ACE ». Et sur 27 segments de production tirés du livre en cours, qui contiennent tous « Çe », zéro fautif : la chaîne d'aujourd'hui est saine. C'est bien la correction qui tient le défaut, et une substitution qui perdrait la majuscule — ou la rendrait en minuscule — le ramènerait toutes les deux minutes d'écoute sans que rien ne le signale. Rien ne pouvait le signaler, d'ailleurs : l'audit de prononciation écarte les mots grammaticaux et les mots de moins de trois lettres, donc « ce » lui est invisible par construction. Ce filtre est justifié — les mots courants noient le rapport — mais il déplace la charge de la preuve sur un test. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01SoLfJRcXeLGs1Q5bdF1RWB --- tests/test_narration_text_fr.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tests/test_narration_text_fr.py b/tests/test_narration_text_fr.py index 5d944995..1045226b 100644 --- a/tests/test_narration_text_fr.py +++ b/tests/test_narration_text_fr.py @@ -303,6 +303,28 @@ def test_a_plain_string_entry_still_works(self): def test_case_does_not_matter(self): assert "èsste" in normalize_french("À L'EST, la mer.", lexicon=self.EAST) + def test_the_capital_of_a_sentence_start_is_kept(self): + """« Ce » en tête de phrase doit devenir « Çe », pas « ce » ni « Ce ». + + C'est le cas le plus lourd du lexique et rien ne le testait. Mesuré sur + dix phrases réelles du corpus, voix Alex Somerset, relues par Whisper : + « Ce » brut est mal dit **10 fois sur 10** — le moteur épelle les + lettres, l'ASR écrit « Point C E » ou « Ces E- ». Avec la cédille, + 10/10 sont justes, et 0 des 27 segments de production contenant « Çe » + n'était fautif. La correction ne vaut donc que si elle survit à la + majuscule : une substitution qui rendrait « ce » en minuscule, ou qui + laisserait « Ce » intact, ramène un défaut audible toutes les deux + minutes — 2 989 des 12 498 « ce » du corpus ouvrent une phrase. + """ + assert normalize_french("Ce livre.", lexicon={"ce": "çe"}) == "Çe livre." + + def test_the_capital_is_kept_after_a_full_stop_too(self): + out = normalize_french("Voici ce livre. Ce chapitre parle.", lexicon={"ce": "çe"}) + assert out == "Voici çe livre. Çe chapitre parle." + + def test_a_capital_is_not_invented_where_there_was_none(self): + assert normalize_french("dans ce livre", lexicon={"ce": "çe"}) == "dans çe livre" + def test_a_malformed_entry_is_ignored_not_fatal(self): lexicon = {"est": {"pas_la_bonne_clef": "x"}, "SNCF": "S N C F"} out = normalize_french("La SNCF est là.", lexicon=lexicon) From 899a9f3c6ad7e550e670d0303c28076bf15d74b8 Mon Sep 17 00:00:00 2001 From: PaxHelios Date: Fri, 14 Aug 2026 13:34:30 +0200 Subject: [PATCH 92/98] Nommer la file par ce qu'elle est, pas par ce qu'elle contenait MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit « queue_46.json » a cessé d'être vrai le jour où un quarante-septième livre est entré dedans. Un nom qui compte ses éléments ment à la première addition, et c'est le genre de mensonge qu'on relit sans le voir : les deux scripts de lancement le codaient en dur. « queue_catalogue.json » dit ce que le fichier est — la file du catalogue — et reste vrai quel que soit le nombre. Le livre ajouté est « Rebâtir l'Intimité Après Divorce ». Il était le seul du catalogue narré à la main depuis un .epub, hors de la file construite depuis OneDrive, donc hors de toutes les passes de correction : sa narration du 11 août épelait « C E » à chaque phrase ouverte par « Ce ». Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01SoLfJRcXeLGs1Q5bdF1RWB --- scripts/relancer_renarration.sh | 2 +- scripts/reprendre_file.sh | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/relancer_renarration.sh b/scripts/relancer_renarration.sh index ccba50a7..38b9336f 100644 --- a/scripts/relancer_renarration.sh +++ b/scripts/relancer_renarration.sh @@ -62,7 +62,7 @@ cd /workspace/voxcpm setsid nohup env LANG=C.UTF-8 LC_ALL=C.UTF-8 \ ./.venv/bin/python \ scripts/narrate_queue.py \ - queue/queue_46.json \ + queue/queue_catalogue.json \ --device cuda --keep deliverables --no-synthetic-disclosure --audit 120 \ > /workspace/queue.log 2>&1 < /dev/null & diff --git a/scripts/reprendre_file.sh b/scripts/reprendre_file.sh index 33f1d42f..d03aa016 100644 --- a/scripts/reprendre_file.sh +++ b/scripts/reprendre_file.sh @@ -27,7 +27,7 @@ fi setsid nohup env LANG=C.UTF-8 LC_ALL=C.UTF-8 \ ./.venv/bin/python \ scripts/narrate_queue.py \ - queue/queue_46.json \ + queue/queue_catalogue.json \ --device cuda --keep deliverables --no-synthetic-disclosure --audit 120 \ > /workspace/queue.log 2>&1 < /dev/null & From 975e48e124a177e0142d5f922b24e4317e66d7fe Mon Sep 17 00:00:00 2001 From: PaxHelios Date: Fri, 14 Aug 2026 14:11:27 +0200 Subject: [PATCH 93/98] Une prise qui en remplace une autre ne l'efface plus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le rapatriement copiait le M4B du pod par-dessus celui déjà présent. Tant qu'un livre n'était narré qu'une fois, personne ne le remarquait ; à la deuxième passe, la version précédente disparaissait sans un mot — treize livres sont passés par là. C'est exactement ce qu'il ne faut pas perdre. Juger un correctif demande deux prises du même chapitre écoutées l'une après l'autre, pas un fichier et un souvenir : la mémoire d'une écoute d'avant-hier ne prouve rien, et c'est en comparant deux fichiers qu'on a établi que le « Ce » épelé venait des versions d'avant le 12 août — 6 occurrences dans l'ancienne, 0 dans la nouvelle. La version en place descend donc dans avant_correctif/, numérotée _v1, _v2 … en cherchant le premier rang libre, son dossier ACX avec elle. Le rang n'est pas calculé à partir d'un compteur mais de ce qui existe déjà, pour qu'un dossier rangé à la main ne fasse pas écraser deux prises l'une sur l'autre. Testé isolément avant d'être branché : trois prises successives donnent _v1, _v2 et le fichier courant ; un livre jamais rapatrié n'archive rien. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01SoLfJRcXeLGs1Q5bdF1RWB --- scripts/rapatrier_livres.sh | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/scripts/rapatrier_livres.sh b/scripts/rapatrier_livres.sh index af7d313b..5035601b 100644 --- a/scripts/rapatrier_livres.sh +++ b/scripts/rapatrier_livres.sh @@ -17,6 +17,8 @@ POD="root@194.26.196.166" PORT=41114 DIST="/workspace/voxcpm/output" LOCAL="${1:-/c/Users/PaxHelios/voxcpm-livres}" +# Où atterrit la prise précédente quand une nouvelle la remplace. +ARCHIVE="$LOCAL/avant_correctif" ssh_pod() { ssh -n -o BatchMode=yes -o ConnectTimeout=15 -p "$PORT" "$POD" "$@"; } @@ -41,6 +43,19 @@ for b in $livres; do n_acx_dist=$(ssh_pod "ls $DIST/$b/acx | wc -l") o_m4b_dist=$(ssh_pod "stat -c%s $DIST/$b/*.m4b") + # Une narration qui en remplace une autre ne doit pas l'effacer. La version + # précédente descend d'un cran, numérotée, et reste écoutable : c'est le seul + # moyen de juger un correctif — on compare deux prises du même chapitre, pas + # un souvenir et un fichier. Le scp d'après écrase sinon sans rien demander. + if [ -f "$LOCAL/${b}_complet.m4b" ]; then + mkdir -p "$ARCHIVE/acx" + n=1 + while [ -e "$ARCHIVE/${b}_complet_v${n}.m4b" ]; do n=$((n + 1)); done + mv "$LOCAL/${b}_complet.m4b" "$ARCHIVE/${b}_complet_v${n}.m4b" + [ -d "$LOCAL/acx/$b" ] && mv "$LOCAL/acx/$b" "$ARCHIVE/acx/${b}_v${n}" + echo " version précédente conservée : $(basename "$ARCHIVE")/${b}_complet_v${n}.m4b" + fi + mkdir -p "$LOCAL/acx/$b" scp -q -P "$PORT" "$POD:$DIST/$b/*.m4b" "$LOCAL/" 2>/dev/null scp -q -P "$PORT" "$POD:$DIST/$b/acx/*" "$LOCAL/acx/$b/" 2>/dev/null From 1e2ddccf327b7f31c27585cb7d8c9a4202be6bc8 Mon Sep 17 00:00:00 2001 From: PaxHelios Date: Sun, 16 Aug 2026 17:16:52 +0200 Subject: [PATCH 94/98] =?UTF-8?q?Une=20couverture=20d'abord=20carr=C3=A9e,?= =?UTF-8?q?=20ensuite=20trouv=C3=A9e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La règle parcourait des dossiers dans un ordre fixe et retenait le premier fichier image de plus de vingt kilo-octets, sans jamais regarder l'image. Un dossier de livre en contient pourtant cinq à dix, qui ne servent pas au même produit : jaquette imprimée, rabat complet, vignette ebook en portrait, couverture audio carrée. Trois livres sur quarante-sept sont ainsi partis en narration avec leur vignette ebook 1600x2560, embarquée dans leur M4B — et ce sont exactement les trois seuls à posséder un sous-dossier « couverture/ », qui passait en tête de l'ordre. Audible ne l'aurait refusée qu'au dépôt, une fois les trois heures de GPU dépensées par livre. La contrainte du distributeur devient donc la règle de choix : carré, au moins 2400 pixels de côté, et un livre qui n'en a pas est écarté avec sa raison plutôt que narré pour rien. Les dimensions se lisent dans l'en-tête PNG, JPEG ou WebP, en trente lignes de bibliothèque standard — le catalogue n'a pas besoin d'une dépendance d'image pour savoir si un carré est un carré. Reste le coût de la mesure, découvert en la faisant : le catalogue vit sur OneDrive, où lire un seul octet fait descendre le fichier entier, et mesurer les dix images de chaque livre portait le balayage à plus de vingt-cinq minutes. D'où l'ordre inverse — classer selon la préférence, mesurer ensuite, s'arrêter à la première conforme. Une image hydratée au lieu de dix, pour le même résultat, et un test qui compte les appels pour que ça le reste. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01XKjC9qbYdccoYcLqzY5Vv1 --- scripts/construire_file.py | 100 +++++++++++++++++++++-- tests/test_construire_file.py | 144 ++++++++++++++++++++++++++++++++++ 2 files changed, 236 insertions(+), 8 deletions(-) create mode 100644 tests/test_construire_file.py diff --git a/scripts/construire_file.py b/scripts/construire_file.py index cb768d22..4a79c63a 100644 --- a/scripts/construire_file.py +++ b/scripts/construire_file.py @@ -24,6 +24,7 @@ import json import pathlib import re +import struct import subprocess import sys @@ -32,6 +33,10 @@ IMAGES = (".jpg", ".jpeg", ".png", ".webp") +#: Côté minimal d'une couverture audio, en pixels : c'est le seuil d'Audible +#: (ACX), et il est repris tel quel par les autres distributeurs. +COTE_MINIMAL = 2400 + #: Un sujet intime, parental ou thérapeutique appelle la voix féminine. #: #: Attention à la portée de cette règle : confrontée aux vingt et un livres @@ -51,16 +56,94 @@ ) +def dimensions(f: pathlib.Path) -> tuple[int, int] | None: + """Largeur et hauteur d'une image, lues dans son en-tête. + + Sans dépendance : le catalogue tient dans trois formats et leurs en-têtes + tiennent en trente lignes. Rien n'est décodé, seuls les premiers octets + sont lus, donc mesurer cent couvertures coûte le prix d'un ``ls``. + """ + try: + with f.open("rb") as fh: + tete = fh.read(32) + if tete[:8] == b"\x89PNG\r\n\x1a\n": + l, h = struct.unpack(">II", tete[16:24]) + return int(l), int(h) + if tete[:4] == b"RIFF" and tete[8:12] == b"WEBP": + fh.seek(0) + d = fh.read(40) + if d[12:16] == b"VP8X": + return (int.from_bytes(d[24:27], "little") + 1, + int.from_bytes(d[27:30], "little") + 1) + if d[12:16] == b"VP8 ": + return (int.from_bytes(d[26:28], "little") & 0x3FFF, + int.from_bytes(d[28:30], "little") & 0x3FFF) + if d[12:16] == b"VP8L": + b = int.from_bytes(d[21:25], "little") + return (b & 0x3FFF) + 1, ((b >> 14) & 0x3FFF) + 1 + return None + if tete[:2] == b"\xff\xd8": + # JPEG : sauter de marqueur en marqueur jusqu'au SOFn, seul + # segment qui porte les dimensions. Les SOF 4, 8 et 12 sont + # des marqueurs de table, pas des cadres — d'où l'exclusion. + fh.seek(2) + while True: + octet = fh.read(1) + if not octet: + return None + if octet != b"\xff": + continue + while octet == b"\xff": + octet = fh.read(1) + marqueur = octet[0] + if 0xC0 <= marqueur <= 0xCF and marqueur not in (0xC4, 0xC8, 0xCC): + fh.read(3) + h, l = struct.unpack(">HH", fh.read(4)) + return int(l), int(h) + taille = struct.unpack(">H", fh.read(2))[0] + fh.seek(taille - 2, 1) + except (OSError, struct.error, IndexError): + return None + return None + + def couverture(d: pathlib.Path) -> pathlib.Path | None: - """La couverture a changé de dossier au fil des versions du catalogue.""" - for sous in ("couverture", "_covers_v3", "_covers_v2", "_covers", "formats"): + """La couverture *audio* : carrée, et d'au moins 2400 pixels de côté. + + Un dossier de livre contient plusieurs couvertures qui ne servent pas au + même produit — la jaquette imprimée, le rabat complet, la vignette ebook + en portrait. Prendre la première venue passe inaperçu jusqu'au dépôt, où + Audible refuse tout ce qui n'est pas carré ; la contrainte du distributeur + est donc devenue la règle de choix, au lieu d'un ordre de dossiers qui ne + la connaissait pas. À égalité, le fichier nommé pour l'audio l'emporte, + puis le plus grand. + + Une couverture ebook 1600×2560 avait ainsi été retenue pour trois livres, + embarquée dans leur M4B, et n'aurait été rejetée qu'au dépôt. + """ + vues: list[pathlib.Path] = [] + for sous in ("_covers_v3", "_covers_v2", "_covers", "couverture", "formats"): rep = d / sous if rep.is_dir(): - for f in sorted(rep.rglob("*")): - if f.suffix.lower() in IMAGES and f.stat().st_size > 20_000: - return f - for f in sorted(d.glob("*")): - if f.suffix.lower() in IMAGES and f.stat().st_size > 20_000: + vues += sorted(rep.rglob("*")) + vues += sorted(d.glob("*")) + + candidates = [f for f in vues + if f.suffix.lower() in IMAGES and f.is_file() + and f.stat().st_size > 20_000] + + # Classer avant de mesurer, et s'arrêter à la première conforme. Le + # catalogue vit sur OneDrive, où lire le moindre octet d'un fichier le + # fait descendre en entier : mesurer les dix images d'un livre pour n'en + # garder qu'une rapatriait des gigaoctets et prenait des dizaines de + # minutes. L'ordre reflète la préférence — le fichier nommé pour l'audio, + # puis le plus grand — donc le résultat est celui du meilleur candidat, + # pas celui du premier rencontré. + candidates.sort(key=lambda f: (0 if "audio" in f.name.lower() else 1, + -f.stat().st_size)) + for f in candidates: + dim = dimensions(f) + if dim is not None and dim[0] == dim[1] and dim[0] >= COTE_MINIMAL: return f return None @@ -170,7 +253,8 @@ def main() -> int: continue cov = couverture(d) if cov is None: - ecartes.append((d.name, "aucune couverture")) + ecartes.append((d.name, f"aucune couverture carrée d'au moins " + f"{COTE_MINIMAL} px")) continue txt = txt_dir / f"{d.name}.txt" diff --git a/tests/test_construire_file.py b/tests/test_construire_file.py new file mode 100644 index 00000000..328e248c --- /dev/null +++ b/tests/test_construire_file.py @@ -0,0 +1,144 @@ +"""Tests du choix de couverture de scripts/construire_file.py. + +Le cas qui a motivé ce fichier : un dossier de livre porte plusieurs +couvertures — jaquette imprimée, rabat complet, vignette ebook en portrait, +couverture audio carrée. La règle d'origine parcourait des dossiers dans un +ordre fixe et retenait le premier fichier assez gros, sans jamais regarder +l'image. Trois livres sont ainsi partis en narration avec une couverture +ebook 1600×2560, embarquée dans leur M4B ; Audible ne l'aurait refusée qu'au +dépôt, une fois les trois heures de GPU dépensées. + +D'où le sens des tests qui suivent : ce n'est pas l'emplacement du fichier +qui décide, c'est sa forme. +""" +from __future__ import annotations + +import importlib.util +import struct +import sys +import zlib +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +spec = importlib.util.spec_from_file_location( + "construire_file", ROOT / "scripts" / "construire_file.py" +) +construire_file = importlib.util.module_from_spec(spec) +sys.modules["construire_file"] = construire_file +spec.loader.exec_module(construire_file) + + +def _png(chemin: Path, largeur: int, hauteur: int, octets: int = 30_000) -> Path: + """Un PNG dont l'en-tête est vrai et le contenu quelconque. + + Les dimensions sont lues dans le IHDR, donc l'image n'a pas besoin d'être + décodable — mais elle doit peser plus que le seuil de vignette, sinon elle + est écartée avant d'être mesurée. + """ + ihdr = struct.pack(">II", largeur, hauteur) + bytes([8, 2, 0, 0, 0]) + bloc = struct.pack(">I", len(ihdr)) + b"IHDR" + ihdr + bloc += struct.pack(">I", zlib.crc32(b"IHDR" + ihdr)) + bourrage = b"\x00" * max(0, octets - len(bloc) - 8) + chemin.parent.mkdir(parents=True, exist_ok=True) + chemin.write_bytes(b"\x89PNG\r\n\x1a\n" + bloc + bourrage) + return chemin + + +def _jpeg(chemin: Path, largeur: int, hauteur: int, octets: int = 30_000) -> Path: + """Un JPEG dont le SOF0 porte les vraies dimensions. + + Un segment APP0 le précède, comme dans tout fichier réel : c'est lui qui + vérifie que le parcours saute bien de marqueur en marqueur au lieu de + lire à un décalage fixe. + """ + app0 = b"\xff\xe0" + struct.pack(">H", 16) + b"JFIF\x00" + b"\x00" * 9 + sof0 = b"\xff\xc0" + struct.pack(">H", 11) + bytes([8]) + struct.pack(">HH", hauteur, largeur) + bytes([1, 1, 17, 0]) + corps = b"\xff\xd8" + app0 + sof0 + b"\xff\xda" + chemin.parent.mkdir(parents=True, exist_ok=True) + chemin.write_bytes(corps + b"\x00" * max(0, octets - len(corps))) + return chemin + + +class TestDimensions: + """Mesurer sans décoder — sinon le catalogue réclame une dépendance.""" + + def test_png(self, tmp_path): + f = _png(tmp_path / "c.png", 3000, 3000) + assert construire_file.dimensions(f) == (3000, 3000) + + def test_jpeg_passe_par_dessus_les_segments_dentete(self, tmp_path): + f = _jpeg(tmp_path / "c.jpg", 1600, 2560) + assert construire_file.dimensions(f) == (1600, 2560) + + def test_un_fichier_qui_nest_pas_une_image_ne_leve_pas(self, tmp_path): + f = tmp_path / "c.png" + f.write_bytes(b"ceci n'est pas une image") + assert construire_file.dimensions(f) is None + + +class TestCouverture: + def test_la_carree_lemporte_sur_la_premiere_venue(self, tmp_path): + """Le cas réel : « couverture/ » vient avant dans l'alphabet et dans + l'ancien ordre des dossiers, mais son contenu est un portrait.""" + _jpeg(tmp_path / "couverture" / "couverture_front.jpg", 1600, 2560) + carree = _jpeg(tmp_path / "_covers_v3" / "audio_cover.jpg", 3000, 3000) + assert construire_file.couverture(tmp_path) == carree + + def test_une_carree_trop_petite_ne_compte_pas(self, tmp_path): + """1600×1600 est carré et refusé quand même : le seuil du distributeur + n'est pas la forme, c'est la forme *et* la taille.""" + _jpeg(tmp_path / "_covers_v3" / "audio_cover.jpg", 1600, 1600) + assert construire_file.couverture(tmp_path) is None + + def test_aucune_carree_vaut_aucune_couverture(self, tmp_path): + """Un livre sans couverture carrée est écarté avec sa raison plutôt + que narré pour rien — trois heures de GPU sont en jeu.""" + _jpeg(tmp_path / "couverture" / "couverture_front.jpg", 1600, 2560) + _png(tmp_path / "couverture" / "couverture_wrap.png", 3200, 2000) + assert construire_file.couverture(tmp_path) is None + + def test_a_forme_egale_le_nom_audio_lemporte(self, tmp_path): + """Deux carrés valides : celui qui se nomme pour l'audio gagne, même + plus léger que la version imprimée.""" + _png(tmp_path / "_covers_v3" / "front_print.png", 3000, 3000, octets=90_000) + audio = _jpeg(tmp_path / "_covers_v3" / "audio_cover.jpg", 3000, 3000, octets=40_000) + assert construire_file.couverture(tmp_path) == audio + + def test_a_defaut_de_nom_la_plus_grande_lemporte(self, tmp_path): + grande = _png(tmp_path / "_covers_v3" / "b.png", 3000, 3000, octets=90_000) + _png(tmp_path / "_covers_v3" / "a.png", 2400, 2400, octets=30_000) + assert construire_file.couverture(tmp_path) == grande + + def test_la_racine_du_dossier_est_lue_en_dernier_recours(self, tmp_path): + seule = _jpeg(tmp_path / "cover.jpg", 2400, 2400) + assert construire_file.couverture(tmp_path) == seule + + def test_on_ne_mesure_pas_ce_quon_ne_gardera_pas(self, tmp_path, monkeypatch): + """Le catalogue vit sur OneDrive : lire un octet d'une image la fait + descendre en entier. Mesurer les dix couvertures d'un livre pour n'en + garder qu'une rapatriait des gigaoctets — la conformité se vérifie + donc dans l'ordre de préférence, et s'arrête au premier succès.""" + _png(tmp_path / "_covers_v3" / "full_wrap.png", 6000, 4000, octets=200_000) + _png(tmp_path / "_covers_v3" / "back_cover.png", 3000, 3000, octets=150_000) + _jpeg(tmp_path / "_covers_v3" / "audio_cover.jpg", 3000, 3000, octets=40_000) + + mesurees = [] + vraie = construire_file.dimensions + + def compter(f): + mesurees.append(f.name) + return vraie(f) + + monkeypatch.setattr(construire_file, "dimensions", compter) + choix = construire_file.couverture(tmp_path) + + assert choix.name == "audio_cover.jpg" + assert mesurees == ["audio_cover.jpg"] + + def test_une_vignette_reste_ignoree(self, tmp_path): + """Le seuil de taille précède la mesure : une miniature carrée de + quelques kilo-octets n'est pas une couverture.""" + _jpeg(tmp_path / "_covers_v3" / "audio_cover.jpg", 3000, 3000, octets=5_000) + assert construire_file.couverture(tmp_path) is None From f10f353a52d6f10a0e336708bee80189b77e14cb Mon Sep 17 00:00:00 2001 From: PaxHelios Date: Sun, 16 Aug 2026 17:17:05 +0200 Subject: [PATCH 95/98] Corriger une couverture ne vaut pas trois heures de GPU MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Les trois livres partis avec la mauvaise couverture étaient déjà narrés, assemblés et rapatriés quand le défaut est apparu. Les renarrer aurait coûté neuf heures de GPU pour remplacer une image. Le remplacement est un remuxage : l'audio est recopié tel quel, seuls l'image et l'index des chapitres sont réécrits. Trois précautions, chacune payée par une erreur possible. On écrit à côté puis on permute, parce qu'un M4B à demi réécrit est indistinguable d'un M4B valide tant qu'on ne l'ouvre pas — et il ne s'ouvre qu'au dépôt. On vérifie avant de permuter que la couverture est bien celle attendue, que le compte de chapitres n'a pas bougé et que la durée est la même à la seconde : la couverture est la raison du remuxage, les chapitres en sont le prix à ne pas payer. Et le fichier de travail garde l'extension .m4b, faute de quoi ffmpeg ne sait pas quel conteneur écrire et refuse le fichier avec une erreur qui ne parle que de nom. Le mode --lot ne touche que les livres dont la couverture embarquée n'est pas un carré conforme, et dit ceux pour lesquels le catalogue n'offre aucun remplacement au lieu de les passer sous silence. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01XKjC9qbYdccoYcLqzY5Vv1 --- scripts/reparer_couverture_m4b.py | 204 ++++++++++++++++++++++++++++++ 1 file changed, 204 insertions(+) create mode 100644 scripts/reparer_couverture_m4b.py diff --git a/scripts/reparer_couverture_m4b.py b/scripts/reparer_couverture_m4b.py new file mode 100644 index 00000000..1126a998 --- /dev/null +++ b/scripts/reparer_couverture_m4b.py @@ -0,0 +1,204 @@ +#!/usr/bin/env python3 +"""Remplace la couverture embarquée d'un M4B déjà assemblé. + +Pourquoi ce script existe. La règle de choix de couverture de +``construire_file.py`` retenait autrefois le premier fichier image assez gros, +sans regarder l'image : trois livres sont partis en narration avec leur +vignette ebook en portrait, et l'ont embarquée dans leur M4B. La règle est +corrigée, mais les livres, eux, sont narrés — et une couverture n'est pas une +raison de redépenser trois heures de GPU par livre. + +Le remplacement est un remuxage : l'audio est recopié tel quel, octet pour +octet, et seuls l'image et l'index des chapitres sont réécrits. Le fichier +d'origine n'est jamais écrasé en place — on écrit à côté, on vérifie, puis on +permute. Un M4B à demi réécrit est indistinguable d'un M4B valide tant qu'on +ne l'ouvre pas, et il ne s'ouvre qu'au dépôt. + + python scripts/reparer_couverture_m4b.py livre.m4b couverture.jpg + python scripts/reparer_couverture_m4b.py --lot dossier_livres --catalogue queue/queue_catalogue.json +""" +from __future__ import annotations + +import argparse +import json +import pathlib +import shutil +import subprocess +import sys + +#: Côté minimal exigé par Audible (ACX). Une couverture qui ne l'atteint pas +#: n'a aucune raison d'entrer dans un M4B : elle serait refusée au dépôt. +COTE_MINIMAL = 2400 + + +def ffoutil(nom: str) -> str: + """ffmpeg est installé mais absent du PATH des shells déjà ouverts.""" + trouve = shutil.which(nom) + if trouve: + return trouve + base = pathlib.Path.home() / "AppData/Local/Microsoft/WinGet/Packages" + for exe in base.rglob(f"{nom}.exe"): + return str(exe) + print(f"{nom} introuvable", file=sys.stderr) + raise SystemExit(2) + + +def dimensions_image(chemin: pathlib.Path) -> tuple[int, int] | None: + r = subprocess.run( + [ffoutil("ffprobe"), "-v", "error", "-select_streams", "v:0", + "-show_entries", "stream=width,height", "-of", "csv=p=0", str(chemin)], + capture_output=True, text=True, encoding="utf-8") + ligne = r.stdout.strip().splitlines() + if not ligne: + return None + try: + l, h = (int(x) for x in ligne[0].split(",")[:2]) + except ValueError: + return None + return l, h + + +def couverture_du_m4b(m4b: pathlib.Path) -> tuple[int, int] | None: + return dimensions_image(m4b) + + +def nb_chapitres(m4b: pathlib.Path) -> int: + r = subprocess.run( + [ffoutil("ffprobe"), "-v", "error", "-show_chapters", "-of", "json", str(m4b)], + capture_output=True, text=True, encoding="utf-8") + try: + return len(json.loads(r.stdout or "{}").get("chapters", [])) + except json.JSONDecodeError: + return -1 + + +def duree(m4b: pathlib.Path) -> float: + r = subprocess.run( + [ffoutil("ffprobe"), "-v", "error", "-show_entries", "format=duration", + "-of", "csv=p=0", str(m4b)], + capture_output=True, text=True, encoding="utf-8") + try: + return float(r.stdout.strip()) + except ValueError: + return -1.0 + + +def remplacer(m4b: pathlib.Path, cover: pathlib.Path, *, verbeux: bool = True) -> bool: + """Vrai si la couverture a été remplacée et le résultat vérifié.""" + dim = dimensions_image(cover) + if dim is None: + print(f" couverture illisible : {cover}", file=sys.stderr) + return False + if dim[0] != dim[1] or dim[0] < COTE_MINIMAL: + print(f" couverture refusée ({dim[0]}x{dim[1]}) : il faut un carré " + f"d'au moins {COTE_MINIMAL} px", file=sys.stderr) + return False + + avant_chap, avant_duree = nb_chapitres(m4b), duree(m4b) + # Le fichier de travail garde l'extension .m4b : ffmpeg choisit son + # conteneur d'après elle, et un « .m4b.nouveau » le laisse sans format. + # Le suffixe reste hors de « *_complet.m4b », pour qu'un fichier oublié + # après un plantage ne soit jamais repris pour un livre par le mode --lot. + tmp = m4b.with_name(f"{m4b.stem}.reparation.m4b") + + # -map_chapters recrée l'index à partir de la source : la piste de données + # d'origine n'est donc pas remappée, elle est régénérée. -map_metadata + # garde titre, auteur et album, que le dépôt lit avant d'ouvrir l'audio. + cmd = [ffoutil("ffmpeg"), "-y", "-loglevel", "error", + "-i", str(m4b), "-i", str(cover), + "-map", "0:a", "-map", "1:v", + "-c", "copy", "-map_metadata", "0", "-map_chapters", "0", + "-disposition:v:0", "attached_pic", + str(tmp)] + r = subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8") + if r.returncode != 0: + print(f" ffmpeg a échoué : {r.stderr.strip()[:400]}", file=sys.stderr) + tmp.unlink(missing_ok=True) + return False + + # Vérifier avant de permuter : la couverture est la raison du remuxage, + # les chapitres et la durée en sont le prix à ne pas payer. + apres_dim = couverture_du_m4b(tmp) + apres_chap, apres_duree = nb_chapitres(tmp), duree(tmp) + if apres_dim != dim: + print(f" la nouvelle couverture ne s'est pas embarquée ({apres_dim})", file=sys.stderr) + tmp.unlink(missing_ok=True) + return False + if apres_chap != avant_chap: + print(f" chapitres perdus : {avant_chap} → {apres_chap}", file=sys.stderr) + tmp.unlink(missing_ok=True) + return False + if abs(apres_duree - avant_duree) > 1.0: + print(f" durée modifiée : {avant_duree:.1f} s → {apres_duree:.1f} s", file=sys.stderr) + tmp.unlink(missing_ok=True) + return False + + tmp.replace(m4b) + if verbeux: + print(f" {apres_dim[0]}x{apres_dim[1]}, {apres_chap} chapitres, " + f"{apres_duree/3600:.2f} h — vérifié") + return True + + +def slug_du_m4b(m4b: pathlib.Path) -> str: + """book_livre_01_titre_complet.m4b → livre-01-titre.""" + return m4b.stem.replace("book_", "").removesuffix("_complet").replace("_", "-") + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("m4b", nargs="?", type=pathlib.Path) + ap.add_argument("couverture", nargs="?", type=pathlib.Path) + ap.add_argument("--lot", type=pathlib.Path, metavar="DOSSIER", + help="parcourt les *_complet.m4b et ne répare que ceux dont " + "la couverture embarquée n'est pas un carré conforme") + ap.add_argument("--catalogue", type=pathlib.Path, default=pathlib.Path("queue/queue_catalogue.json"), + help="où lire la couverture de chaque livre, en mode --lot") + ap.add_argument("--racine", type=pathlib.Path, default=pathlib.Path("."), + help="racine à laquelle les chemins du catalogue sont relatifs") + ap.add_argument("--dry-run", action="store_true") + args = ap.parse_args() + + if args.lot: + catalogue = {e["slug"]: e for e in json.loads( + args.catalogue.read_text(encoding="utf-8"))} + a_reparer, sans_source, ok = [], [], 0 + for m4b in sorted(args.lot.glob("*_complet.m4b")): + dim = couverture_du_m4b(m4b) + if dim and dim[0] == dim[1] and dim[0] >= COTE_MINIMAL: + ok += 1 + continue + slug = slug_du_m4b(m4b) + e = catalogue.get(slug) + cover = (args.racine / e["cover"]) if e else None + if cover is None or not cover.exists(): + sans_source.append((m4b.name, slug)) + continue + a_reparer.append((m4b, cover, dim)) + + print(f"{ok} livre(s) déjà conformes, {len(a_reparer)} à réparer, " + f"{len(sans_source)} sans couverture de rechange") + for nom, slug in sans_source: + print(f" ⚠ {nom} : rien dans le catalogue pour « {slug} »") + + echecs = 0 + for m4b, cover, dim in a_reparer: + print(f"── {m4b.name} ({dim[0]}x{dim[1]} → {cover.name})") + if args.dry_run: + continue + if not remplacer(m4b, cover): + echecs += 1 + return 1 if (echecs or sans_source) else 0 + + if not args.m4b or not args.couverture: + ap.error("donner un M4B et une couverture, ou --lot DOSSIER") + print(f"── {args.m4b.name}") + if args.dry_run: + print(f" {couverture_du_m4b(args.m4b)} → {dimensions_image(args.couverture)}") + return 0 + return 0 if remplacer(args.m4b, args.couverture) else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) From 58fc06e5a656121bfde811474dd3bef9c159146b Mon Sep 17 00:00:00 2001 From: PaxHelios Date: Fri, 21 Aug 2026 13:34:59 +0200 Subject: [PATCH 96/98] =?UTF-8?q?Une=20couverture=20manquante=20ne=20co?= =?UTF-8?q?=C3=BBte=20plus=20un=20livre=20entier?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `livre-rebatir-intimite` est sorti sans aucune couverture. La règle de forme posée la semaine dernière ne pouvait pas l'attraper : elle vit dans le constructeur de file, et un chemin absent ne déclenche aucune des vérifications qu'on écrit pour un chemin présent. Trois endroits laissaient passer : - `narrate_book` ne regardait la couverture qu'à l'assemblage, trois heures de GPU plus tard, et s'en accommodait par un `print` dans un journal détaché. Elle est maintenant vérifiée au pré-vol — donc en `--dry-run`, sans charger le modèle — et un livre destiné au dépôt refuse de partir sans elle. Une absence assumée reste possible : `--no-cover` la dit au lieu de la subir. - Le pré-vol de la file ne volait pas le même plan que la vraie prise : il relançait une commande réduite, sans `--assemble` ni `--cover`, donc il validait un livre que la narration assemblait autrement. Les deux passent désormais par un seul constructeur de commande. - La file entière est examinée avant la première minute de GPU. Découvrir au trente-deuxième livre que le quarantième n'a pas de couverture, c'est l'apprendre quatre jours trop tard ; lire un en-tête d'image coûte le prix d'un `ls`. La règle elle-même passe dans `narration/couverture.py`, appelée aussi bien par le constructeur de file que par le narrateur : elle décrivait ce qu'un distributeur accepte, elle n'avait pas à vivre dans le script qui, ce jour-là, la consultait. `inspecter()` dit pourquoi, pas seulement non. Témoin : `queue/queue.json`, la file telle qu'elle a tourné le 11 août, est refusée par le nouveau contrôle — et le seul livre qu'il nomme est bien celui dont le M4B n'a pas d'image. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Rv8KnvJeYcCPv2XaL1f1JM --- narration/couverture.py | 164 +++++++++++++++++++++ scripts/construire_file.py | 105 +------------ scripts/narrate_book.py | 47 ++++-- scripts/narrate_queue.py | 108 ++++++++++---- tests/test_construire_file.py | 8 +- tests/test_narration_couverture.py | 227 +++++++++++++++++++++++++++++ 6 files changed, 522 insertions(+), 137 deletions(-) create mode 100644 narration/couverture.py create mode 100644 tests/test_narration_couverture.py diff --git a/narration/couverture.py b/narration/couverture.py new file mode 100644 index 00000000..d563abbf --- /dev/null +++ b/narration/couverture.py @@ -0,0 +1,164 @@ +"""La règle de couverture d'un livre audio, en un seul endroit. + +Un distributeur ne regarde pas le nom du fichier ni le dossier d'où il vient : +il regarde l'image. Audible (ACX) exige une couverture **carrée** d'au moins +2400 pixels de côté, et les autres plateformes reprennent ce seuil. Tout le +reste — jaquette imprimée, rabat complet, vignette ebook en portrait — est une +autre image du même livre, pour un autre produit. + +Cette règle vivait dans ``scripts/construire_file.py``, c'est-à-dire au moment +où la file se construit. Elle y arrivait trop tard une fois sur deux : un livre +narré à la main, ou repris par ``relancer_renarration.sh``, ne passe pas par le +constructeur de file et embarquait ce qu'on lui donnait. Elle est ici pour que +le constructeur de file *et* le narrateur posent la même question à la même +image, et pour qu'un M4B sans couverture cesse d'être un fichier qu'on découvre +au dépôt. + +Rien n'est décodé : seuls les premiers octets sont lus, donc mesurer cent +couvertures coûte le prix d'un ``ls`` et n'ajoute aucune dépendance. +""" +from __future__ import annotations + +import pathlib +import struct +from typing import NamedTuple, Optional + +#: Extensions qu'un distributeur accepte comme couverture. +IMAGES = (".jpg", ".jpeg", ".png", ".webp") + +#: Côté minimal d'une couverture audio, en pixels : c'est le seuil d'Audible +#: (ACX), et il est repris tel quel par les autres distributeurs. +COTE_MINIMAL = 2400 + +#: En deçà, un fichier image est une icône ou une vignette, pas une couverture. +TAILLE_MINIMALE = 20_000 + + +def dimensions(f: pathlib.Path) -> Optional[tuple[int, int]]: + """Largeur et hauteur d'une image, lues dans son en-tête. + + Sans dépendance : le catalogue tient dans trois formats et leurs en-têtes + tiennent en trente lignes. Rien n'est décodé, seuls les premiers octets + sont lus. + """ + try: + with f.open("rb") as fh: + tete = fh.read(32) + if tete[:8] == b"\x89PNG\r\n\x1a\n": + l, h = struct.unpack(">II", tete[16:24]) + return int(l), int(h) + if tete[:4] == b"RIFF" and tete[8:12] == b"WEBP": + fh.seek(0) + d = fh.read(40) + if d[12:16] == b"VP8X": + return (int.from_bytes(d[24:27], "little") + 1, + int.from_bytes(d[27:30], "little") + 1) + if d[12:16] == b"VP8 ": + return (int.from_bytes(d[26:28], "little") & 0x3FFF, + int.from_bytes(d[28:30], "little") & 0x3FFF) + if d[12:16] == b"VP8L": + b = int.from_bytes(d[21:25], "little") + return (b & 0x3FFF) + 1, ((b >> 14) & 0x3FFF) + 1 + return None + if tete[:2] == b"\xff\xd8": + # JPEG : sauter de marqueur en marqueur jusqu'au SOFn, seul + # segment qui porte les dimensions. Les SOF 4, 8 et 12 sont + # des marqueurs de table, pas des cadres — d'où l'exclusion. + fh.seek(2) + while True: + octet = fh.read(1) + if not octet: + return None + if octet != b"\xff": + continue + while octet == b"\xff": + octet = fh.read(1) + marqueur = octet[0] + if 0xC0 <= marqueur <= 0xCF and marqueur not in (0xC4, 0xC8, 0xCC): + fh.read(3) + h, l = struct.unpack(">HH", fh.read(4)) + return int(l), int(h) + taille = struct.unpack(">H", fh.read(2))[0] + fh.seek(taille - 2, 1) + except (OSError, struct.error, IndexError): + return None + return None + + +class Verdict(NamedTuple): + """Ce qu'on peut dire d'une couverture avant de lancer trois heures de GPU. + + ``raison`` est vide quand elle est conforme, et rédigée pour être lue dans + un journal détaché — c'est souvent la seule trace qu'il en restera. + """ + + conforme: bool + dimensions: Optional[tuple[int, int]] + raison: str + + def __bool__(self) -> bool: # pragma: no cover - trivial + return self.conforme + + +def inspecter(chemin: pathlib.Path | str | None) -> Verdict: + """Cette image peut-elle être déposée comme couverture de livre audio ? + + Répond aussi — et surtout — quand il n'y a pas d'image du tout : c'est le + cas qui est passé inaperçu, parce qu'un chemin absent ne déclenche aucune + des vérifications qu'on écrit pour un chemin présent. + """ + if chemin is None or str(chemin) == "": + return Verdict(False, None, "aucune couverture n'a été fournie") + f = pathlib.Path(chemin) + if not f.is_file(): + return Verdict(False, None, f"fichier introuvable : {f}") + if f.suffix.lower() not in IMAGES: + return Verdict(False, None, + f"format non accepté : {f.suffix or '(sans extension)'} " + f"(attendu {', '.join(IMAGES)})") + dim = dimensions(f) + if dim is None: + return Verdict(False, None, f"image illisible ou tronquée : {f.name}") + l, h = dim + if l != h: + return Verdict(False, dim, + f"couverture non carrée : {l}×{h} — les distributeurs " + f"exigent un carré") + if l < COTE_MINIMAL: + return Verdict(False, dim, + f"couverture trop petite : {l}×{h} — minimum " + f"{COTE_MINIMAL}×{COTE_MINIMAL}") + return Verdict(True, dim, "") + + +def choisir(d: pathlib.Path) -> Optional[pathlib.Path]: + """La couverture *audio* d'un dossier de livre : carrée, ≥ 2400 px. + + Un dossier de livre contient plusieurs couvertures qui ne servent pas au + même produit. Prendre la première venue passe inaperçu jusqu'au dépôt ; la + contrainte du distributeur est donc la règle de choix, au lieu d'un ordre + de dossiers qui ne la connaissait pas. À égalité, le fichier nommé pour + l'audio l'emporte, puis le plus grand. + + Le classement précède la mesure, et on s'arrête à la première conforme : + le catalogue vit sur OneDrive, où lire le moindre octet d'un fichier le + fait descendre en entier. Mesurer les dix images d'un livre pour n'en + garder qu'une rapatriait des gigaoctets. + """ + vues: list[pathlib.Path] = [] + for sous in ("_covers_v3", "_covers_v2", "_covers", "couverture", "formats"): + rep = d / sous + if rep.is_dir(): + vues += sorted(rep.rglob("*")) + vues += sorted(d.glob("*")) + + candidates = [f for f in vues + if f.suffix.lower() in IMAGES and f.is_file() + and f.stat().st_size > TAILLE_MINIMALE] + candidates.sort(key=lambda f: (0 if "audio" in f.name.lower() else 1, + -f.stat().st_size)) + for f in candidates: + dim = dimensions(f) + if dim is not None and dim[0] == dim[1] and dim[0] >= COTE_MINIMAL: + return f + return None diff --git a/scripts/construire_file.py b/scripts/construire_file.py index 4a79c63a..fe42cfd7 100644 --- a/scripts/construire_file.py +++ b/scripts/construire_file.py @@ -24,18 +24,17 @@ import json import pathlib import re -import struct import subprocess import sys -VOIX_FEMININE = "Aurore — livre audio" -VOIX_MASCULINE = "Alex Somerset" +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent)) -IMAGES = (".jpg", ".jpeg", ".png", ".webp") +from narration.couverture import ( # noqa: E402 + COTE_MINIMAL, IMAGES, choisir as couverture, dimensions, +) -#: Côté minimal d'une couverture audio, en pixels : c'est le seuil d'Audible -#: (ACX), et il est repris tel quel par les autres distributeurs. -COTE_MINIMAL = 2400 +VOIX_FEMININE = "Aurore — livre audio" +VOIX_MASCULINE = "Alex Somerset" #: Un sujet intime, parental ou thérapeutique appelle la voix féminine. #: @@ -56,98 +55,6 @@ ) -def dimensions(f: pathlib.Path) -> tuple[int, int] | None: - """Largeur et hauteur d'une image, lues dans son en-tête. - - Sans dépendance : le catalogue tient dans trois formats et leurs en-têtes - tiennent en trente lignes. Rien n'est décodé, seuls les premiers octets - sont lus, donc mesurer cent couvertures coûte le prix d'un ``ls``. - """ - try: - with f.open("rb") as fh: - tete = fh.read(32) - if tete[:8] == b"\x89PNG\r\n\x1a\n": - l, h = struct.unpack(">II", tete[16:24]) - return int(l), int(h) - if tete[:4] == b"RIFF" and tete[8:12] == b"WEBP": - fh.seek(0) - d = fh.read(40) - if d[12:16] == b"VP8X": - return (int.from_bytes(d[24:27], "little") + 1, - int.from_bytes(d[27:30], "little") + 1) - if d[12:16] == b"VP8 ": - return (int.from_bytes(d[26:28], "little") & 0x3FFF, - int.from_bytes(d[28:30], "little") & 0x3FFF) - if d[12:16] == b"VP8L": - b = int.from_bytes(d[21:25], "little") - return (b & 0x3FFF) + 1, ((b >> 14) & 0x3FFF) + 1 - return None - if tete[:2] == b"\xff\xd8": - # JPEG : sauter de marqueur en marqueur jusqu'au SOFn, seul - # segment qui porte les dimensions. Les SOF 4, 8 et 12 sont - # des marqueurs de table, pas des cadres — d'où l'exclusion. - fh.seek(2) - while True: - octet = fh.read(1) - if not octet: - return None - if octet != b"\xff": - continue - while octet == b"\xff": - octet = fh.read(1) - marqueur = octet[0] - if 0xC0 <= marqueur <= 0xCF and marqueur not in (0xC4, 0xC8, 0xCC): - fh.read(3) - h, l = struct.unpack(">HH", fh.read(4)) - return int(l), int(h) - taille = struct.unpack(">H", fh.read(2))[0] - fh.seek(taille - 2, 1) - except (OSError, struct.error, IndexError): - return None - return None - - -def couverture(d: pathlib.Path) -> pathlib.Path | None: - """La couverture *audio* : carrée, et d'au moins 2400 pixels de côté. - - Un dossier de livre contient plusieurs couvertures qui ne servent pas au - même produit — la jaquette imprimée, le rabat complet, la vignette ebook - en portrait. Prendre la première venue passe inaperçu jusqu'au dépôt, où - Audible refuse tout ce qui n'est pas carré ; la contrainte du distributeur - est donc devenue la règle de choix, au lieu d'un ordre de dossiers qui ne - la connaissait pas. À égalité, le fichier nommé pour l'audio l'emporte, - puis le plus grand. - - Une couverture ebook 1600×2560 avait ainsi été retenue pour trois livres, - embarquée dans leur M4B, et n'aurait été rejetée qu'au dépôt. - """ - vues: list[pathlib.Path] = [] - for sous in ("_covers_v3", "_covers_v2", "_covers", "couverture", "formats"): - rep = d / sous - if rep.is_dir(): - vues += sorted(rep.rglob("*")) - vues += sorted(d.glob("*")) - - candidates = [f for f in vues - if f.suffix.lower() in IMAGES and f.is_file() - and f.stat().st_size > 20_000] - - # Classer avant de mesurer, et s'arrêter à la première conforme. Le - # catalogue vit sur OneDrive, où lire le moindre octet d'un fichier le - # fait descendre en entier : mesurer les dix images d'un livre pour n'en - # garder qu'une rapatriait des gigaoctets et prenait des dizaines de - # minutes. L'ordre reflète la préférence — le fichier nommé pour l'audio, - # puis le plus grand — donc le résultat est celui du meilleur candidat, - # pas celui du premier rencontré. - candidates.sort(key=lambda f: (0 if "audio" in f.name.lower() else 1, - -f.stat().st_size)) - for f in candidates: - dim = dimensions(f) - if dim is not None and dim[0] == dim[1] and dim[0] >= COTE_MINIMAL: - return f - return None - - def _depuis_json(d: pathlib.Path): bm = d / "metadata" / "book_metadata.json" if not bm.is_file(): diff --git a/scripts/narrate_book.py b/scripts/narrate_book.py index 96359c24..921d0f86 100644 --- a/scripts/narrate_book.py +++ b/scripts/narrate_book.py @@ -66,7 +66,7 @@ from narration import assemble as assembly # noqa: E402 from narration import audio as audio_tools # noqa: E402 from narration import cache as cache_tools # noqa: E402 -from narration import chunking, credits, epub, quality, repair, text_en, text_fr # noqa: E402 +from narration import chunking, couverture, credits, epub, quality, repair, text_en, text_fr # noqa: E402 #: Rough characters-per-second of finished narration, used only to estimate how #: long a book will run before committing hours of CPU to it. @@ -353,6 +353,38 @@ def main() -> int: for index, segments in plan: print(f" chapitre {index:03d}: {len(segments)} segment(s) « {titles[index - 1][:50]} »") + # ---- couverture ---------------------------------------------------- + # Vérifiée ici, avant que le modèle ne se charge — et donc aussi en + # --dry-run. Elle ne l'était qu'à l'assemblage, c'est-à-dire trois heures + # de GPU plus tard, où une couverture absente ou non carrée ne produisait + # qu'une ligne de journal que personne ne relit : `livre-rebatir-intimite` + # est ainsi sorti sans aucune couverture, et trois autres livres avec une + # vignette ebook en portrait. Une couverture ne coûte rien à corriger + # avant la narration et coûte la narration entière après. + cover_path = None + if args.assemble: + cover_path = Path(args.cover) if args.cover else None + origine = "fournie" + if cover_path is None and not args.no_cover and epub.is_epub(in_path): + # En dry-run on extrait ailleurs : un plan ne crée pas la sortie. + dest = Path(tempfile.mkdtemp()) if args.dry_run else outdir + dest.mkdir(parents=True, exist_ok=True) + cover_path = epub.extract_cover(in_path, dest) + origine = "tirée de l'EPUB" + if args.no_cover: + print("Couverture : aucune (--no-cover) — les distributeurs en exigent une") + else: + verdict = couverture.inspecter(cover_path) + if verdict.conforme: + largeur, hauteur = verdict.dimensions + print(f"Couverture : {Path(cover_path).name} " + f"({largeur}×{hauteur}, {origine})") + else: + print(f"Couverture : REFUS — {verdict.raison}") + print(" Corrigez-la avant de dépenser la narration, " + "ou assumez l'absence avec --no-cover.") + return 2 + if args.dry_run: if plan and plan[0][1]: print("\nPremier segment après préparation du texte :") @@ -557,15 +589,10 @@ def render(current_seed, _segment=segment): if not chapter_files: print("Rien à assembler.") return 1 if (args.qc_strict and defective) else 0 - # The book carries its own cover; only an explicit --cover beats it. - cover_path = Path(args.cover) if args.cover else None - if cover_path is None and not args.no_cover and epub.is_epub(in_path): - cover_path = epub.extract_cover(in_path, outdir) - if cover_path: - print(f"Couverture : {cover_path.name} (tirée de l'EPUB)") - if cover_path and not cover_path.is_file(): - print(f"Couverture introuvable, ignorée : {cover_path}") - cover_path = None + # La couverture a été choisie et vérifiée au pré-vol : on ne la + # redécide pas ici, sinon la vérification ne porterait pas sur ce qui + # est réellement embarqué. + cover_path = Path(cover_path) if cover_path else None target = outdir / f"{outdir.name}_complet.{args.assemble}" print(f"\nAssemblage de {len(chapter_files)} chapitre(s) -> {target.name}") diff --git a/scripts/narrate_queue.py b/scripts/narrate_queue.py index b06e8faf..54a09a47 100644 --- a/scripts/narrate_queue.py +++ b/scripts/narrate_queue.py @@ -35,6 +35,10 @@ REPO = pathlib.Path(__file__).resolve().parent.parent PYTHON = sys.executable +sys.path.insert(0, str(REPO)) + +from narration import couverture # noqa: E402 + def log(msg: str) -> None: print(f"[{time.strftime('%H:%M:%S')}] {msg}", flush=True) @@ -81,6 +85,64 @@ def run(cmd: list[str], logfile: pathlib.Path | None = None) -> tuple[int, str]: return p.returncode, (p.stdout or "") + (p.stderr or "") +def commande(b: dict, txt: pathlib.Path, outdir: pathlib.Path, args) -> list[str]: + """La commande de narration d'un livre — une seule, pour les deux passages. + + Le pré-vol la relance avec ``--dry-run``. Il faut donc qu'il vole *le même* + plan : quand il se contentait d'une commande réduite (ni ``--assemble``, ni + ``--cover``, ni titre), il validait un livre que la vraie prise assemblait + autrement — et la vérification de couverture, qui ne s'arme qu'à + l'assemblage, ne s'y déclenchait jamais. + """ + cmd = [PYTHON, "scripts/narrate_book.py", str(txt), "--voice", b["voice"], + "--device", args.device, "--outdir", str(outdir), + "--qc-retries", args.qc_retries, + "--assemble", "m4b", "--export-acx"] + nom_voix = b.get("voice_name") or VOICE_NAMES.get(b["voice"], "") + if nom_voix: + cmd += ["--voice-name", nom_voix] + # Choix d'éditeur, porté par la file plutôt que codé ici : la mention + # de voix de synthèse est exigée par les plateformes, et la retirer + # doit rester une décision visible dans les données. + if b.get("no_synthetic_disclosure") or args.no_synthetic_disclosure: + cmd += ["--no-synthetic-disclosure"] + # Un livre peut avoir ses propres abréviations. Le lexique général est + # passé d'abord, le sien ensuite : ils s'empilent, il ne le remplace pas. + lexiques = ["conf/pronunciation_fr.json"] + list(b.get("lexicons") or []) + for lex in lexiques: + cmd += ["--lexicon", lex] + # Sans titre, narrate_book retombe sur le nom du fichier : cinq livres + # se sont annoncés « livre-un-esprits-reprogrammes » avant qu'on le + # remarque. Un .txt ne porte pas de métadonnées, donc la file les porte. + for option, cle in (("--title", "title"), ("--author", "author"), + ("--cover", "cover")): + if b.get(cle): + cmd += [option, b[cle]] + return cmd + + +def couvertures_manquantes(books: list[dict]) -> list[tuple[str, str]]: + """Les livres de la file dont la couverture ne serait pas déposable. + + Posée avant le premier livre, et non au fil de l'eau : découvrir au + trente-deuxième livre que le quarantième n'a pas de couverture, c'est + l'apprendre quatre jours trop tard. Lire un en-tête d'image coûte le prix + d'un ``ls``, donc toute la file y passe. + """ + manquantes = [] + for b in books: + chemin = b.get("cover") + # La file porte des chemins relatifs, et les sous-processus tournent + # avec cwd=REPO : on résout comme eux, pour ne pas refuser une + # couverture que la narration, elle, trouverait. + if chemin and not pathlib.Path(chemin).is_absolute(): + chemin = REPO / chemin + verdict = couverture.inspecter(chemin) + if not verdict.conforme: + manquantes.append((b["slug"], verdict.raison)) + return manquantes + + def main() -> int: ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument("queue", help="queue.json") @@ -95,6 +157,9 @@ def main() -> int: "(0 = ne pas auditer)") ap.add_argument("--no-synthetic-disclosure", action="store_true", help="Retirer la mention « voix de synthèse » de tous les génériques") + ap.add_argument("--sans-couverture", action="store_true", + help="Narrer même les livres dont la couverture serait " + "refusée au dépôt (à n'utiliser que pour un essai)") ap.add_argument("--keep", choices=("all", "deliverables"), default="all", help="all : tout garder. deliverables : ne garder que le M4B, " "l'export ACX et le rapport, et effacer les WAV de chapitre " @@ -117,6 +182,21 @@ def save() -> None: log(f"file de {len(books)} livre(s) — {sum(b['chars'] for b in books)} caractères") + # Les couvertures d'abord, toutes, et avant la première minute de GPU. + # Un livre déjà terminé sera sauté : sa couverture ne peut plus rien + # coûter, et le faire échouer bloquerait une reprise pour rien. + manquantes = couvertures_manquantes( + [b for b in books if state.get(b["slug"], {}).get("status") != "done"] + ) + if manquantes: + for slug, raison in manquantes: + log(f"couverture refusée — {slug} : {raison}") + if not args.sans_couverture: + log(f"{len(manquantes)} livre(s) sans couverture déposable — file non lancée") + log(" corrigez la file, ou assumez-le avec --sans-couverture") + return 2 + log(f"{len(manquantes)} livre(s) narrés sans couverture (--sans-couverture)") + for i, b in enumerate(books, 1): slug = b["slug"] st = state.get(slug, {}) @@ -165,8 +245,7 @@ def save() -> None: # Pré-vol : il ne charge pas le modèle, donc il coûte des secondes et # attrape ce qui ferait échouer trois heures plus tard. - rc, out = run([PYTHON, "scripts/narrate_book.py", str(txt), "--voice", b["voice"], - "--device", args.device, "--outdir", str(outdir), "--dry-run"]) + rc, out = run(commande(b, txt, outdir, args) + ["--dry-run"]) if rc != 0: log(f" pré-vol refusé — livre écarté") state[slug].update(status="failed", stage="dry-run", detail=out[-400:]) @@ -174,30 +253,7 @@ def save() -> None: continue t0 = time.time() - cmd = [PYTHON, "scripts/narrate_book.py", str(txt), "--voice", b["voice"], - "--device", args.device, "--outdir", str(outdir), - "--qc-retries", args.qc_retries, - "--assemble", "m4b", "--export-acx"] - nom_voix = b.get("voice_name") or VOICE_NAMES.get(b["voice"], "") - if nom_voix: - cmd += ["--voice-name", nom_voix] - # Choix d'éditeur, porté par la file plutôt que codé ici : la mention - # de voix de synthèse est exigée par les plateformes, et la retirer - # doit rester une décision visible dans les données. - if b.get("no_synthetic_disclosure") or args.no_synthetic_disclosure: - cmd += ["--no-synthetic-disclosure"] - # Un livre peut avoir ses propres abréviations. Le lexique général est - # passé d'abord, le sien ensuite : ils s'empilent, il ne le remplace pas. - lexiques = ["conf/pronunciation_fr.json"] + list(b.get("lexicons") or []) - for lex in lexiques: - cmd += ["--lexicon", lex] - # Sans titre, narrate_book retombe sur le nom du fichier : cinq livres - # se sont annoncés « livre-un-esprits-reprogrammes » avant qu'on le - # remarque. Un .txt ne porte pas de métadonnées, donc la file les porte. - for option, cle in (("--title", "title"), ("--author", "author"), - ("--cover", "cover")): - if b.get(cle): - cmd += [option, b[cle]] + cmd = commande(b, txt, outdir, args) rc, tail = run(cmd, blog) mins = (time.time() - t0) / 60 if rc != 0: diff --git a/tests/test_construire_file.py b/tests/test_construire_file.py index 328e248c..d42e76f2 100644 --- a/tests/test_construire_file.py +++ b/tests/test_construire_file.py @@ -22,6 +22,8 @@ ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT)) +from narration import couverture as regle_couverture + spec = importlib.util.spec_from_file_location( "construire_file", ROOT / "scripts" / "construire_file.py" ) @@ -125,13 +127,15 @@ def test_on_ne_mesure_pas_ce_quon_ne_gardera_pas(self, tmp_path, monkeypatch): _jpeg(tmp_path / "_covers_v3" / "audio_cover.jpg", 3000, 3000, octets=40_000) mesurees = [] - vraie = construire_file.dimensions + vraie = regle_couverture.dimensions def compter(f): mesurees.append(f.name) return vraie(f) - monkeypatch.setattr(construire_file, "dimensions", compter) + # La règle vit dans narration.couverture ; construire_file n'en est + # plus qu'un appelant, comme narrate_book. + monkeypatch.setattr(regle_couverture, "dimensions", compter) choix = construire_file.couverture(tmp_path) assert choix.name == "audio_cover.jpg" diff --git a/tests/test_narration_couverture.py b/tests/test_narration_couverture.py new file mode 100644 index 00000000..18ba7a86 --- /dev/null +++ b/tests/test_narration_couverture.py @@ -0,0 +1,227 @@ +"""La couverture est vérifiée avant la narration, pas après. + +Deux défauts réels sont derrière ce fichier. Trois livres sont partis avec une +vignette ebook 1600×2560 embarquée dans leur M4B, refusée au dépôt seulement — +c'est la règle de forme, corrigée d'abord dans le constructeur de file. Puis +``livre-rebatir-intimite`` est sorti **sans aucune couverture** : son entrée de +file n'en portait pas, ``narrate_book`` s'en accommodait par un ``print`` à +l'assemblage, et le pré-vol de la file ne pouvait rien voir puisqu'il relançait +une commande réduite, sans ``--assemble`` ni ``--cover``. + +D'où les trois choses tenues ici : le verdict dit *pourquoi*, la narration +refuse de démarrer avant la première minute de GPU, et le pré-vol vole le même +plan que la vraie prise. +""" +from __future__ import annotations + +import importlib.util +import json +import struct +import sys +import types +import zlib +from pathlib import Path + +import numpy as np +import pytest + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +from narration import couverture + +SR = 24000 +BASE_SEED = 4242 + + +def _png(chemin: Path, largeur: int, hauteur: int, octets: int = 30_000) -> Path: + chemin.parent.mkdir(parents=True, exist_ok=True) + ihdr = struct.pack(">II", largeur, hauteur) + b"\x08\x02\x00\x00\x00" + bloc = b"\x00\x00\x00\rIHDR" + ihdr + struct.pack(">I", zlib.crc32(b"IHDR" + ihdr)) + chemin.write_bytes(b"\x89PNG\r\n\x1a\n" + bloc + b"\x00" * octets) + return chemin + + +def _jpeg(chemin: Path, largeur: int, hauteur: int, octets: int = 30_000) -> Path: + chemin.parent.mkdir(parents=True, exist_ok=True) + sof = b"\xff\xc0" + struct.pack(">HBHHB", 17, 8, hauteur, largeur, 3) + b"\x00" * 9 + chemin.write_bytes(b"\xff\xd8" + sof + b"\x00" * octets) + return chemin + + +class TestInspecter: + """Le verdict doit être lisible dans un journal détaché : c'est souvent + tout ce qu'il en restera.""" + + def test_carree_et_grande_est_conforme(self, tmp_path): + v = couverture.inspecter(_jpeg(tmp_path / "audio.jpg", 3000, 3000)) + assert v.conforme and v.dimensions == (3000, 3000) and v.raison == "" + assert bool(v) is True + + def test_absente_est_dite_absente(self): + """Le cas qui a coûté un livre : un chemin vide ne déclenchait aucune + des vérifications écrites pour un chemin présent.""" + v = couverture.inspecter(None) + assert not v.conforme and "aucune couverture" in v.raison + assert not couverture.inspecter("") + + def test_introuvable_nomme_le_fichier(self, tmp_path): + v = couverture.inspecter(tmp_path / "pas_la.jpg") + assert not v.conforme and "pas_la.jpg" in v.raison + + def test_portrait_refuse_avec_ses_dimensions(self, tmp_path): + """1600×2560 : la vignette ebook, exactement celle des trois livres.""" + v = couverture.inspecter(_jpeg(tmp_path / "ebook.jpg", 1600, 2560)) + assert not v.conforme + assert v.dimensions == (1600, 2560) + assert "carrée" in v.raison and "1600" in v.raison + + def test_carree_mais_trop_petite(self, tmp_path): + v = couverture.inspecter(_png(tmp_path / "petite.png", 1400, 1400)) + assert not v.conforme and "trop petite" in v.raison + assert str(couverture.COTE_MINIMAL) in v.raison + + def test_format_non_accepte(self, tmp_path): + f = tmp_path / "couverture.pdf" + f.write_bytes(b"%PDF-1.7" + b"\x00" * 30_000) + assert "format non accept" in couverture.inspecter(f).raison + + def test_illisible(self, tmp_path): + f = tmp_path / "tronquee.jpg" + f.write_bytes(b"\xff\xd8\xff") + assert "illisible" in couverture.inspecter(f).raison + + +# -------------------------------------------------------------------------- +# narrate_book : le refus arrive avant le modèle, donc aussi en --dry-run. +# -------------------------------------------------------------------------- +class StubDemo: + def __init__(self, **_kwargs) -> None: + pass + + def generate_tts_audio(self, *, text_input, seed=None, **_kwargs): + secondes = max(0.5, len((text_input or "").strip()) / 17.0) + n = int(SR * secondes) + rng = np.random.default_rng(1) + return SR, (rng.normal(0, 0.2, n)).astype(np.float32), None + + +app_stub = types.ModuleType("app") +app_stub.PRESET_VOICES = [ + {"name": "Voix de test", "description": "voix française de test", "seed": BASE_SEED} +] +app_stub._PRESET_BY_NAME = {"Voix de test": app_stub.PRESET_VOICES[0]} +app_stub._OUTPUT_DIR = ROOT / "output" +app_stub._sanitize_filename = lambda name: name +app_stub.VoxCPMDemo = StubDemo +sys.modules.setdefault("app", app_stub) + +spec = importlib.util.spec_from_file_location("narrate_book", ROOT / "scripts" / "narrate_book.py") +narrate_book = importlib.util.module_from_spec(spec) +assert spec.loader is not None +spec.loader.exec_module(narrate_book) + + +@pytest.fixture +def livre(tmp_path): + chemin = tmp_path / "livre.txt" + chemin.write_text( + "Chapitre premier. Une phrase de longueur raisonnable pour un segment.\n", + encoding="utf-8", + ) + return chemin + + +def _lancer(monkeypatch, livre, outdir, *extra) -> int: + monkeypatch.setattr(sys, "argv", [ + "narrate_book.py", str(livre), "--voice", "Voix de test", + "--outdir", str(outdir), "--no-credits", "--dry-run", *extra, + ]) + return narrate_book.main() + + +class TestRefusAuPreVol: + def test_sans_couverture_le_livre_ne_part_pas(self, monkeypatch, livre, tmp_path, capsys): + code = _lancer(monkeypatch, livre, tmp_path / "out", "--assemble", "m4b") + assert code == 2 + assert "REFUS" in capsys.readouterr().out + + def test_couverture_portrait_refusee(self, monkeypatch, livre, tmp_path, capsys): + cover = _jpeg(tmp_path / "ebook.jpg", 1600, 2560) + code = _lancer(monkeypatch, livre, tmp_path / "out", "--assemble", "m4b", + "--cover", str(cover)) + assert code == 2 + assert "carr" in capsys.readouterr().out + + def test_couverture_conforme_passe_et_se_dit(self, monkeypatch, livre, tmp_path, capsys): + cover = _jpeg(tmp_path / "audio.jpg", 3000, 3000) + code = _lancer(monkeypatch, livre, tmp_path / "out", "--assemble", "m4b", + "--cover", str(cover)) + assert code == 0 + assert "3000×3000" in capsys.readouterr().out + + def test_absence_assumee_est_permise(self, monkeypatch, livre, tmp_path, capsys): + """``--no-cover`` reste une décision qu'on peut prendre — elle est + seulement dite, au lieu d'être subie.""" + code = _lancer(monkeypatch, livre, tmp_path / "out", "--assemble", "m4b", + "--no-cover") + assert code == 0 + assert "aucune (--no-cover)" in capsys.readouterr().out + + def test_sans_assemblage_rien_nest_exige(self, monkeypatch, livre, tmp_path): + """Une narration qui ne produit pas de fichier à déposer n'a pas de + couverture à porter.""" + assert _lancer(monkeypatch, livre, tmp_path / "out") == 0 + + +# -------------------------------------------------------------------------- +# narrate_queue : toute la file est examinée avant le premier livre. +# -------------------------------------------------------------------------- +spec_q = importlib.util.spec_from_file_location( + "narrate_queue", ROOT / "scripts" / "narrate_queue.py" +) +narrate_queue = importlib.util.module_from_spec(spec_q) +assert spec_q.loader is not None +spec_q.loader.exec_module(narrate_queue) + + +class TestFile: + def test_la_file_nomme_les_livres_sans_couverture(self, tmp_path): + bonne = _jpeg(tmp_path / "audio.jpg", 3000, 3000) + manquantes = narrate_queue.couvertures_manquantes([ + {"slug": "livre-a", "cover": str(bonne)}, + {"slug": "livre-rebatir-intimite"}, + {"slug": "livre-c", "cover": str(_jpeg(tmp_path / "ebook.jpg", 1600, 2560))}, + ]) + assert [slug for slug, _ in manquantes] == ["livre-rebatir-intimite", "livre-c"] + + def test_la_file_reelle_du_onze_aout_aurait_ete_refusee(self): + """Le témoin : ``queue/queue.json`` telle qu'elle a tourné. Un seul + livre y manque de couverture, et c'est celui dont le M4B n'en a + aucune.""" + books = json.loads((ROOT / "queue" / "queue.json").read_text(encoding="utf-8")) + assert [slug for slug, _ in narrate_queue.couvertures_manquantes(books)] == [ + "livre-rebatir-intimite" + ] + + def test_le_catalogue_courant_est_conforme(self): + books = json.loads( + (ROOT / "queue" / "queue_catalogue.json").read_text(encoding="utf-8") + ) + assert narrate_queue.couvertures_manquantes(books) == [] + + def test_le_pre_vol_vole_le_meme_plan(self, tmp_path): + """Le pré-vol relance cette commande avec ``--dry-run``. S'il en + construisait une autre — sans ``--assemble`` ni ``--cover`` — il + validerait un livre que la vraie prise assemble autrement, ce qui est + exactement ce qui a laissé passer le livre sans couverture.""" + args = types.SimpleNamespace(device="cuda", qc_retries="2", + no_synthetic_disclosure=False) + cmd = narrate_queue.commande( + {"slug": "livre-a", "voice": "Alex Somerset", "cover": "queue/covers/x.jpg", + "title": "TITRE", "author": "Auteur"}, + tmp_path / "livre.txt", tmp_path / "out", args, + ) + assert "--assemble" in cmd and "--export-acx" in cmd + assert cmd[cmd.index("--cover") + 1] == "queue/covers/x.jpg" + assert cmd[cmd.index("--title") + 1] == "TITRE" From 3883cbdf818e934e24cff5e5a6025ee54c215523 Mon Sep 17 00:00:00 2001 From: PaxHelios Date: Fri, 21 Aug 2026 13:35:09 +0200 Subject: [PATCH 97/98] Un lot qu'on ne parcourt pas n'est pas un lot sain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le mode `--lot` cherchait `*_complet.m4b` à plat, alors qu'un livre rapatrié est un dossier — le M4B, l'export ACX, le rapport. Il parcourait donc zéro fichier sur les vingt-deux livres produits, et annonçait « 0 à réparer » : le rapport d'un lot vérifié et celui d'un lot jamais regardé étaient le même. Un lot vide est maintenant une erreur, pas un satisfecit. Deux autres choses tombaient sur le cas qui a motivé le script cette fois-ci, un M4B sans aucune image : le format de la ligne supposait des dimensions existantes, et les filets et flèches faisaient tomber le script sur une console cp1252 — après en avoir déjà réparé d'autres. Vérifié sur les vingt-deux livres livrés : vingt et un conformes, un réparé (`livre-rebatir-intimite`, 3000x3000, 16 chapitres et 4,16 h intacts). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Rv8KnvJeYcCPv2XaL1f1JM --- scripts/reparer_couverture_m4b.py | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/scripts/reparer_couverture_m4b.py b/scripts/reparer_couverture_m4b.py index 1126a998..0242b121 100644 --- a/scripts/reparer_couverture_m4b.py +++ b/scripts/reparer_couverture_m4b.py @@ -146,6 +146,14 @@ def slug_du_m4b(m4b: pathlib.Path) -> str: def main() -> int: + # Le script écrit des filets et des flèches : sur une console cp1252 il + # tombait dessus, après avoir déjà remplacé la couverture de deux livres. + for flux in (sys.stdout, sys.stderr): + try: + flux.reconfigure(encoding="utf-8", errors="replace") + except (AttributeError, ValueError): + pass + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument("m4b", nargs="?", type=pathlib.Path) @@ -164,7 +172,16 @@ def main() -> int: catalogue = {e["slug"]: e for e in json.loads( args.catalogue.read_text(encoding="utf-8"))} a_reparer, sans_source, ok = [], [], 0 - for m4b in sorted(args.lot.glob("*_complet.m4b")): + # ``rglob`` et non ``glob`` : un livre rapatrié est un *dossier* + # (M4B + export ACX + rapport), pas un fichier posé à plat. Avec un + # glob plat, ce mode parcourait zéro fichier et annonçait tout de même + # « 0 à réparer » — le rapport d'un lot sain et celui d'un lot jamais + # regardé étaient le même. + m4bs = sorted(args.lot.rglob("*_complet.m4b")) + if not m4bs: + print(f"aucun *_complet.m4b sous {args.lot} — rien n'a été examiné") + return 1 + for m4b in m4bs: dim = couverture_du_m4b(m4b) if dim and dim[0] == dim[1] and dim[0] >= COTE_MINIMAL: ok += 1 @@ -184,7 +201,10 @@ def main() -> int: echecs = 0 for m4b, cover, dim in a_reparer: - print(f"── {m4b.name} ({dim[0]}x{dim[1]} → {cover.name})") + # ``dim`` est None quand le M4B n'a aucune image : c'est le cas de + # `livre-rebatir-intimite`, et le formatage l'ignorait. + actuel = f"{dim[0]}x{dim[1]}" if dim else "sans couverture" + print(f"── {m4b.name} ({actuel} → {cover.name})") if args.dry_run: continue if not remplacer(m4b, cover): From 91e3b5e94d7af2986cef7cc5f847699b57f41f99 Mon Sep 17 00:00:00 2001 From: PaxHelios Date: Fri, 21 Aug 2026 14:03:21 +0200 Subject: [PATCH 98/98] =?UTF-8?q?Sortir=20z=C3=A9ro=20doit=20vouloir=20dir?= =?UTF-8?q?e=20qu'un=20livre=20existe?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La file marque un livre `done` sur le code de sortie de `narrate_book`, et `done` ne se rejoue pas : `state.json` saute ces livres-là pour toujours. Quatre chemins rendaient pourtant zéro sans qu'aucun fichier déposable n'existe. - **Aucun chapitre à assembler** imprimait « Rien à assembler » et sortait zéro. C'est ce qui a laissé un livre complet, lancé depuis le mauvais dossier, s'annoncer « terminé — 0 chapitre(s) » ; la purge des WAV dépend de ce compte, donc elle ne se déclenchait pas, et le volume saturait trois livres plus loin pour une cause étrangère à la narration. - **ffmpeg absent à l'assemblage** écrivait la commande à lancer plus tard. Elle est utile à qui la lit ; elle ne remplace pas le M4B. - **ffmpeg absent à l'export ACX** laissait des WAV et un `encoder.txt` : de quoi encoder, pas de quoi déposer. `export_acx.py` le compte désormais comme un échec — sauf en `--check`, qui n'écrit rien par contrat. - **Des fichiers ACX hors norme** étaient imprimés, puis oubliés. Le code de sortie dit maintenant une seule chose, et il la dit bien : le livrable demandé existe-t-il et peut-il être déposé ? La file, elle, ne s'y fie plus seule — elle regarde les fichiers (`livrables_absents`), parce qu'un livre sans livrable doit rester reprenable. Même motif, côté entrée : `load_lexicon` est tolérant par dessein, mais la file passe le lexique commun à *chaque* livre, et celui-ci porte `ce` → `çe`, 12 498 occurrences, validé à l'oreille. Un chemin faux ou une virgule en trop et trois cents livres se narrent avec la mauvaise prononciation du mot le plus fréquent — sans une ligne pour le dire, et la correction invalide le cache de segments de tous les livres. Un lexique **nommé** qui ne charge rien est maintenant refusé au pré-vol. Le défaut implicite, lui, peut légitimement manquer quand on narre à la main depuis ailleurs : il est dit, pas refusé. 809 tests. Le corpus livré a été revérifié : chacun des vingt-deux livres a autant de MP3 que de chapitres, plus l'extrait commercial — le seul écart est `book_livre_04_revolution_introvertis.prise-du-8`, la prise fautive gardée exprès à côté de la bonne. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Rv8KnvJeYcCPv2XaL1f1JM --- scripts/export_acx.py | 7 +- scripts/narrate_book.py | 65 +++++++- scripts/narrate_queue.py | 37 +++++ tests/test_export_acx.py | 15 ++ tests/test_livrable_ou_rien.py | 242 +++++++++++++++++++++++++++++ tests/test_narrate_book_credits.py | 10 +- 6 files changed, 368 insertions(+), 8 deletions(-) create mode 100644 tests/test_livrable_ou_rien.py diff --git a/scripts/export_acx.py b/scripts/export_acx.py index 9850edf6..cf019ee7 100644 --- a/scripts/export_acx.py +++ b/scripts/export_acx.py @@ -328,7 +328,12 @@ def main() -> int: ) print(f"Toutes les commandes sont dans {script}") - return 1 if failures else 0 + # Des commandes qui restent, ce sont des MP3 qui n'existent pas : le + # dossier ne contient alors que des WAV et un `encoder.txt`, et rien ne + # peut être déposé. Sortir zéro là-dessus, c'est dire « livré » d'un + # dossier vide — sauf en --check, qui n'écrit rien par contrat. + reste_a_encoder = bool(commands) and not args.check + return 1 if (failures or reste_a_encoder) else 0 if __name__ == "__main__": diff --git a/scripts/narrate_book.py b/scripts/narrate_book.py index 921d0f86..12e105a5 100644 --- a/scripts/narrate_book.py +++ b/scripts/narrate_book.py @@ -307,11 +307,34 @@ def main() -> int: titles = [opening_title] + titles + [closing_title] lexicon = {} + #: Les lexiques nommés qui n'ont rien donné. `load_lexicon` est tolérant + #: par dessein — un fichier d'appoint mal formé ne doit pas tuer une + #: narration — mais la tolérance était devenue silence : le lexique commun + #: porte `ce` → `çe`, 12 498 occurrences dans le catalogue, validé à + #: l'oreille. Un chemin faux, une virgule en trop, et trois cents livres se + #: narrent avec la mauvaise prononciation du mot le plus fréquent sans + #: qu'une ligne le dise. La correction, elle, invalide le cache de segments + #: de *tous* les livres : elle coûte le catalogue, pas un fichier. + lexiques_vides: list[str] = [] if not args.no_text_prep: # Empiler plutôt que remplacer : un livre qui définit son abréviation # maison ne doit pas perdre au passage les sigles communs. - for chemin in (args.lexicon or ["conf/pronunciation_fr.json"]): - lexicon.update(text_fr.load_lexicon(chemin)) + demandes = list(args.lexicon or []) + for chemin in (demandes or ["conf/pronunciation_fr.json"]): + entrees = text_fr.load_lexicon(chemin) + lexicon.update(entrees) + if not entrees: + raison = ("introuvable" if not Path(chemin).is_file() + else "illisible ou sans entrée utilisable") + # Un lexique explicitement demandé qui ne donne rien est une + # faute de frappe, pas un choix : c'est ce cas qu'on refuse. + # Le défaut implicite, lui, peut légitimement manquer quand on + # narre à la main depuis ailleurs — on le dit sans refuser. + if demandes: + lexiques_vides.append(f"{chemin} ({raison})") + else: + print(f"Lexique : {chemin} {raison} — aucune " + f"prononciation n'est appliquée") prepare = ( text_en.normalize_english if args.language == "en" else text_fr.normalize_french ) @@ -353,6 +376,14 @@ def main() -> int: for index, segments in plan: print(f" chapitre {index:03d}: {len(segments)} segment(s) « {titles[index - 1][:50]} »") + # ---- lexique ------------------------------------------------------- + if lexiques_vides: + print(f"Lexique : REFUS — {', '.join(lexiques_vides)}") + print(" Un lexique nommé qui ne donne rien est une faute " + "de frappe, pas un choix ; et le corriger après coup invalide " + "le cache de segments de tous les livres.") + return 2 + # ---- couverture ---------------------------------------------------- # Vérifiée ici, avant que le modèle ne se charge — et donc aussi en # --dry-run. Elle ne l'était qu'à l'assemblage, c'est-à-dire trois heures @@ -361,6 +392,9 @@ def main() -> int: # est ainsi sorti sans aucune couverture, et trois autres livres avec une # vignette ebook en portrait. Une couverture ne coûte rien à corriger # avant la narration et coûte la narration entière après. + #: Ce qui a été demandé et n'a pas été produit. Vide vaut zéro en sortie. + manque: list[str] = [] + cover_path = None if args.assemble: cover_path = Path(args.cover) if args.cover else None @@ -587,8 +621,14 @@ def render(current_seed, _segment=segment): if args.assemble: chapter_files = sorted(p for p in outdir.glob("chapitre_*.wav")) if not chapter_files: - print("Rien à assembler.") - return 1 if (args.qc_strict and defective) else 0 + # Sortir zéro ici disait « terminé » d'un livre qui n'existe pas. + # C'est ce qui a laissé la file annoncer « terminé — 0 chapitre(s) » + # sur un livre pourtant complet, lancé depuis le mauvais dossier : + # rien n'était trouvé, tout allait bien, et la purge des WAV — qui + # dépend de ce compte — ne se déclenchait pas. Un volume saturé + # trois livres plus loin, pour une cause étrangère à la narration. + print(f"Rien à assembler : aucun chapitre_*.wav dans {outdir}") + return 1 # La couverture a été choisie et vérifiée au pré-vol : on ne la # redécide pas ici, sinon la vérification ne porterait pas sur ce qui # est réellement embarqué. @@ -610,6 +650,11 @@ def render(current_seed, _segment=segment): if result.pending_command: print("À exécuter une fois ffmpeg installé :") print(" " + subprocess.list2cmdline(result.pending_command)) + # La commande est utile à qui la lit ; elle ne remplace pas le + # fichier. Tant qu'il manque, le livre n'est pas assemblé, et la + # file ne doit pas le marquer terminé — elle ne le reprendrait + # jamais. + manque.append("le M4B (ffmpeg absent ou en échec)") else: print("Astuce : ajoutez --assemble m4b pour produire un fichier unique avec chapitres, " "ou lancez scripts/assemble_audiobook.py plus tard.") @@ -624,11 +669,21 @@ def render(current_seed, _segment=segment): [sys.executable, str(Path(__file__).with_name("export_acx.py")), str(outdir)] ) if result.returncode: - print("Export : des fichiers sont hors norme, voir ci-dessus.") + print("Export : des fichiers sont hors norme ou n'ont pas été " + "encodés, voir ci-dessus.") + manque.append("un export ACX déposable") if args.qc_strict and defective: print(f"--qc-strict : {defective} segment(s) toujours défectueux.") return 1 + + # Le code de sortie dit une seule chose, et il faut qu'il la dise bien : + # le livrable demandé existe-t-il et peut-il être déposé ? La file s'y fie + # pour marquer un livre « terminé », et « terminé » ne se rejoue pas. + if manque: + print() + print(f"Incomplet : il manque {', '.join(manque)}.") + return 1 return 0 diff --git a/scripts/narrate_queue.py b/scripts/narrate_queue.py index 54a09a47..dd628429 100644 --- a/scripts/narrate_queue.py +++ b/scripts/narrate_queue.py @@ -143,6 +143,25 @@ def couvertures_manquantes(books: list[dict]) -> list[tuple[str, str]]: return manquantes +def livrables_absents(outdir: pathlib.Path, wavs: int) -> list[str]: + """Ce qui manque à un livre pour être déposable, nommé. + + On regarde les fichiers, pas le code de sortie de qui les fabrique : un + livre lancé depuis le mauvais dossier s'était déclaré terminé sans qu'un + seul chapitre soit trouvé. Un MP3 dans ``acx/`` plutôt que le dossier seul, + parce que sans ffmpeg l'export y laisse des WAV et un ``encoder.txt`` — + de quoi encoder, pas de quoi déposer. + """ + acx = outdir / "acx" + livrables = { + "M4B": bool(list(outdir.glob("*_complet.m4b")) + + list(outdir.glob("*_complet.m4a"))), + "chapitres narrés": wavs > 0, + "export ACX (MP3)": acx.is_dir() and any(acx.glob("*.mp3")), + } + return [nom for nom, present in livrables.items() if not present] + + def main() -> int: ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument("queue", help="queue.json") @@ -302,6 +321,24 @@ def save() -> None: wavs = len(list(outdir.glob("*.wav"))) if outdir.is_dir() else 0 + # « terminé » doit vouloir dire « déposable », parce que « terminé » ne + # se rejoue pas : `state.json` saute ces livres-là pour toujours. La + # narration a rendu zéro, mais rendre zéro n'a jamais garanti qu'un + # fichier existe — un livre lancé depuis le mauvais dossier s'était + # ainsi déclaré terminé sans un seul chapitre trouvé. On regarde donc + # les livrables eux-mêmes plutôt que le code de sortie de qui les + # fabrique. + absents = livrables_absents(outdir, wavs) + if absents: + log(f" narration rendue sans livrable — manque : {', '.join(absents)}") + log(" livre marqué en échec plutôt que terminé, pour qu'une " + "reprise le retrouve") + state[slug].update(status="failed", stage="livraison", + minutes=round(mins, 1), chapters_wav=wavs, + detail="livrables manquants : " + ", ".join(absents)) + save() + continue + # Un livre laisse ~3 Go de WAV de chapitre derrière lui. Vingt livres # saturent le volume au quatrième, et une file qui meurt d'un disque # plein a produit dix-neuf échecs pour une cause qui n'a rien à voir diff --git a/tests/test_export_acx.py b/tests/test_export_acx.py index 07e5e484..80f106f2 100644 --- a/tests/test_export_acx.py +++ b/tests/test_export_acx.py @@ -140,12 +140,27 @@ def test_it_says_so_rather_than_failing_silently(self, monkeypatch, book, capsys run(monkeypatch, str(book)) assert "ffmpeg absent" in capsys.readouterr().out + def test_the_exit_code_says_nothing_was_delivered(self, monkeypatch, book): + """A directory of WAVs and an ``encoder.txt`` is what to encode, not + what to upload. Returning zero here told the caller the book was + delivered, and the queue marked it done — which it never replays.""" + assert run(monkeypatch, str(book)) == 1 + assert not list((book / "acx").glob("*.mp3")) + class TestCheckOnly: def test_check_writes_nothing(self, monkeypatch, book): run(monkeypatch, str(book), "--check") assert not (book / "acx").exists() + def test_check_is_not_a_delivery_and_does_not_fail_for_not_being_one( + self, monkeypatch, book + ): + """``--check`` reports and writes nothing, by contract: the files it + did not encode are not files it failed to encode.""" + monkeypatch.setattr(export_acx.assembly, "find_ffmpeg", lambda: None) + assert run(monkeypatch, str(book), "--check") == 0 + def test_check_still_reports_every_file(self, monkeypatch, book, capsys): run(monkeypatch, str(book), "--check") out = capsys.readouterr().out diff --git a/tests/test_livrable_ou_rien.py b/tests/test_livrable_ou_rien.py new file mode 100644 index 00000000..3cffd327 --- /dev/null +++ b/tests/test_livrable_ou_rien.py @@ -0,0 +1,242 @@ +"""Le code de sortie doit dire « le livrable existe », pas « rien n'a levé ». + +La file s'y fie pour marquer un livre ``done``, et ``done`` ne se rejoue pas : +``state.json`` saute ces livres-là pour toujours. Quatre chemins rendaient +pourtant zéro sans qu'aucun fichier déposable existe — aucun chapitre à +assembler, ffmpeg absent à l'assemblage, ffmpeg absent à l'export, fichiers +ACX hors norme. C'est le même motif que la couverture manquante, et que le +mode ``--lot`` qui parcourait zéro fichier : **le rapport d'un travail réussi +et celui d'un travail jamais fait étaient le même.** + +Le motif vaut aussi pour ce qu'on consomme, d'où la dernière classe : un +lexique nommé qui ne charge rien se narrait en silence. +""" +from __future__ import annotations + +import importlib.util +import struct +import sys +import types +from pathlib import Path + +import numpy as np +import pytest + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +SR = 24000 +BASE_SEED = 4242 + + +class StubDemo: + def __init__(self, **_kwargs) -> None: + pass + + def generate_tts_audio(self, *, text_input, seed=None, **_kwargs): + secondes = max(0.5, len((text_input or "").strip()) / 17.0) + rng = np.random.default_rng(1) + return SR, rng.normal(0, 0.2, int(SR * secondes)).astype(np.float32), None + + +app_stub = types.ModuleType("app") +app_stub.PRESET_VOICES = [ + {"name": "Voix de test", "description": "voix française de test", "seed": BASE_SEED} +] +app_stub._PRESET_BY_NAME = {"Voix de test": app_stub.PRESET_VOICES[0]} +app_stub._OUTPUT_DIR = ROOT / "output" +app_stub._sanitize_filename = lambda name: name +app_stub.VoxCPMDemo = StubDemo +sys.modules.setdefault("app", app_stub) + + +def _charger(nom: str): + spec = importlib.util.spec_from_file_location(nom, ROOT / "scripts" / f"{nom}.py") + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(module) + return module + + +narrate_book = _charger("narrate_book") +narrate_queue = _charger("narrate_queue") + + +def _cover(chemin: Path, cote: int = 3000) -> Path: + """Une couverture conforme : le pré-vol en exige une pour assembler.""" + chemin.parent.mkdir(parents=True, exist_ok=True) + sof = b"\xff\xc0" + struct.pack(">HBHHB", 17, 8, cote, cote, 3) + b"\x00" * 9 + chemin.write_bytes(b"\xff\xd8" + sof + b"\x00" * 30_000) + return chemin + + +@pytest.fixture +def livre(tmp_path): + chemin = tmp_path / "livre.txt" + chemin.write_text( + "Chapitre premier. Une phrase de longueur raisonnable pour un segment.\n" + "---\n" + "Chapitre second. Une autre phrase, de longueur comparable au premier.\n", + encoding="utf-8", + ) + return chemin + + +def _narrer(monkeypatch, livre, tmp_path, *extra, couverture=True) -> int: + """Une narration complète, assemblage compris. + + La couverture de ces tests n'a qu'un en-tête : elle suffit au pré-vol, qui + ne lit que ça, mais pas à ffmpeg, qui la décode. Les cas qui vont jusqu'au + M4B passent donc ``--no-cover`` — ce qu'ils mesurent est le code de sortie, + pas l'image. + """ + cover = (["--cover", str(_cover(tmp_path / "audio.jpg"))] if couverture + else ["--no-cover"]) + monkeypatch.setattr(sys, "argv", [ + "narrate_book.py", str(livre), "--voice", "Voix de test", + "--outdir", str(tmp_path / "out"), "--no-credits", + "--assemble", "m4b", *cover, *extra, + ]) + return narrate_book.main() + + +class TestNarrateBook: + def test_un_livre_assemble_rend_zero(self, monkeypatch, livre, tmp_path): + assert _narrer(monkeypatch, livre, tmp_path, couverture=False) == 0 + assert list((tmp_path / "out").glob("*_complet.m4b")) + + def test_un_m4b_qui_reste_a_encoder_nest_pas_un_m4b( + self, monkeypatch, livre, tmp_path, capsys + ): + """Sans ffmpeg, l'assemblage écrit la commande à lancer plus tard. Elle + est utile à qui la lit ; elle ne remplace pas le fichier.""" + monkeypatch.setattr(narrate_book.assembly, "find_ffmpeg", lambda: None) + code = _narrer(monkeypatch, livre, tmp_path) + sortie = capsys.readouterr().out + assert code == 1 + assert "Incomplet" in sortie and "M4B" in sortie + + def test_un_export_acx_en_echec_nest_pas_une_remarque( + self, monkeypatch, livre, tmp_path, capsys + ): + """L'export sort non nul quand des fichiers sont hors norme ou n'ont + jamais été encodés. C'était imprimé, puis oublié.""" + vrai_run = narrate_book.subprocess.run + + def run_stub(cmd, *args, **kwargs): + if "export_acx.py" in " ".join(str(c) for c in cmd): + return types.SimpleNamespace(returncode=1) + return vrai_run(cmd, *args, **kwargs) + + monkeypatch.setattr(narrate_book.subprocess, "run", run_stub) + code = _narrer(monkeypatch, livre, tmp_path, "--export-acx", couverture=False) + sortie = capsys.readouterr().out + assert code == 1 + # Le M4B, lui, a bien été écrit : la cause nommée est la bonne. + derniere = sortie.strip().splitlines()[-1] + assert derniere == "Incomplet : il manque un export ACX déposable." + + def test_rien_a_assembler_nest_pas_un_succes(self, monkeypatch, livre, tmp_path, capsys): + """Le cas qui a coûté un volume saturé : lancé depuis le mauvais + dossier, le script ne trouvait aucun chapitre, sortait zéro, et la + purge des WAV — conditionnée à ce compte — ne se déclenchait pas.""" + monkeypatch.setattr( + narrate_book.Path, "glob", + lambda self, motif: iter(()) if motif == "chapitre_*.wav" + else Path.glob(self, motif), + ) + code = _narrer(monkeypatch, livre, tmp_path) + assert code == 1 + assert "Rien à assembler" in capsys.readouterr().out + + +class TestLivrablesDeLaFile: + """La file regarde les fichiers, pas le code de sortie de qui les fabrique.""" + + def _livre_complet(self, d: Path) -> Path: + d.mkdir(parents=True, exist_ok=True) + (d / "chapitre_001.wav").write_bytes(b"RIFF") + (d / f"{d.name}_complet.m4b").write_bytes(b"\x00" * 10) + (d / "acx").mkdir() + (d / "acx" / "001 - Chapitre.mp3").write_bytes(b"\x00" * 10) + return d + + def test_un_livre_complet_ne_manque_de_rien(self, tmp_path): + d = self._livre_complet(tmp_path / "book_livre_x") + assert narrate_queue.livrables_absents(d, wavs=1) == [] + + def test_sans_m4b(self, tmp_path): + d = self._livre_complet(tmp_path / "book_livre_x") + next(d.glob("*_complet.m4b")).unlink() + assert narrate_queue.livrables_absents(d, wavs=1) == ["M4B"] + + def test_un_dossier_acx_sans_mp3_ne_compte_pas(self, tmp_path): + """Sans ffmpeg, l'export laisse des WAV et un `encoder.txt` : de quoi + encoder, pas de quoi déposer. Tester l'existence du dossier aurait + accepté ça.""" + d = self._livre_complet(tmp_path / "book_livre_x") + next((d / "acx").glob("*.mp3")).unlink() + (d / "acx" / "001 - Chapitre.wav").write_bytes(b"RIFF") + (d / "acx" / "encoder.txt").write_text("ffmpeg ...", encoding="utf-8") + assert narrate_queue.livrables_absents(d, wavs=1) == ["export ACX (MP3)"] + + def test_zero_chapitre_narre(self, tmp_path): + d = self._livre_complet(tmp_path / "book_livre_x") + assert "chapitres narrés" in narrate_queue.livrables_absents(d, wavs=0) + + def test_un_dossier_absent_manque_de_tout(self, tmp_path): + absents = narrate_queue.livrables_absents(tmp_path / "jamais_cree", wavs=0) + assert len(absents) == 3 + + +class TestLexique: + """`load_lexicon` est tolérant par dessein : un fichier d'appoint mal formé + ne doit pas tuer neuf heures de narration. Mais la file passe désormais le + lexique commun à *chaque* livre, et celui-ci porte `ce` → `çe` — 12 498 + occurrences dans le catalogue, validé à l'oreille. Un chemin faux et trois + cents livres se narrent avec la mauvaise prononciation du mot le plus + fréquent. La tolérance reste ; c'est le silence qui s'en va.""" + + def _prevol(self, monkeypatch, livre, tmp_path, *extra) -> int: + monkeypatch.setattr(sys, "argv", [ + "narrate_book.py", str(livre), "--voice", "Voix de test", + "--outdir", str(tmp_path / "out"), "--no-credits", "--dry-run", *extra, + ]) + return narrate_book.main() + + def test_un_lexique_nomme_et_introuvable_arrete_tout( + self, monkeypatch, livre, tmp_path, capsys + ): + code = self._prevol(monkeypatch, livre, tmp_path, + "--lexicon", str(tmp_path / "jamais_ecrit.json")) + assert code == 2 + assert "REFUS" in capsys.readouterr().out + + def test_un_lexique_nomme_et_illisible_aussi(self, monkeypatch, livre, tmp_path, capsys): + """Une virgule en trop suffit : le fichier existe, il ne dit rien.""" + casse = tmp_path / "lexique.json" + casse.write_text('{"ce": "çe",}', encoding="utf-8") + assert self._prevol(monkeypatch, livre, tmp_path, "--lexicon", str(casse)) == 2 + assert "illisible" in capsys.readouterr().out + + def test_un_lexique_qui_charge_passe(self, monkeypatch, livre, tmp_path, capsys): + bon = tmp_path / "lexique.json" + bon.write_text('{"ce": "çe", "ACE": "A C E"}', encoding="utf-8") + assert self._prevol(monkeypatch, livre, tmp_path, "--lexicon", str(bon)) == 0 + assert "2 entrée(s) de lexique" in capsys.readouterr().out + + def test_le_lexique_commun_du_depot_charge(self, monkeypatch, livre, tmp_path): + """Le témoin : c'est ce fichier que la file passe à chaque livre.""" + assert self._prevol(monkeypatch, livre, tmp_path, + "--lexicon", "conf/pronunciation_fr.json") == 0 + + def test_le_defaut_implicite_manquant_est_dit_sans_refuser( + self, monkeypatch, livre, tmp_path, capsys + ): + """Narré à la main depuis un autre dossier, le chemin par défaut ne + résout pas. Ce n'est pas une faute de frappe, donc pas un refus — mais + la ligne le dit, au lieu de laisser croire à une préparation.""" + monkeypatch.chdir(tmp_path) + code = self._prevol(monkeypatch, livre, tmp_path) + assert code == 0 + assert "aucune prononciation n'est appliquée" in capsys.readouterr().out diff --git a/tests/test_narrate_book_credits.py b/tests/test_narrate_book_credits.py index f0375b79..734d4455 100644 --- a/tests/test_narrate_book_credits.py +++ b/tests/test_narrate_book_credits.py @@ -103,14 +103,20 @@ def test_export_acx_produces_the_delivery_folder(self, monkeypatch, book, tmp_pa assert (acx / "rapport_acx.json").is_file() def test_a_failed_export_does_not_lose_the_chapters(self, monkeypatch, book, tmp_path): - """Nine hours of narration must survive anything the exporter does.""" + """Nine hours of narration must survive anything the exporter does. + + Surviving is not succeeding, though. The exit code used to be zero + here, which told the queue the book was done — and done is never + replayed. The chapters stay; the code now says the delivery is not + there. + """ outdir = tmp_path / "out" monkeypatch.setattr( narrate_book.subprocess, "run", lambda *a, **k: type("Result", (), {"returncode": 1})(), ) - assert run(monkeypatch, book, outdir, "--title", "Le Livre", "--export-acx") == 0 + assert run(monkeypatch, book, outdir, "--title", "Le Livre", "--export-acx") == 1 assert sorted(outdir.glob("chapitre_*.wav"))