Skip to content

Add REST API server, standalone web interface, and missing pieces - #368

Open
Eddyosas008 wants to merge 86 commits into
OpenBMB:mainfrom
Eddyosas008:claude/repo-analysis-improvement-dg0ies
Open

Add REST API server, standalone web interface, and missing pieces#368
Eddyosas008 wants to merge 86 commits into
OpenBMB:mainfrom
Eddyosas008:claude/repo-analysis-improvement-dg0ies

Conversation

@Eddyosas008

Copy link
Copy Markdown
  • 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)

Claude-Session: https://claude.ai/code/session_01MeWfqpDWJs9veHKHRzQqG9

claude and others added 30 commits July 25, 2026 12:47
- 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MeWfqpDWJs9veHKHRzQqG9
…reviews

- 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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BYwW5KLZYovWstkx9aCwiq
…ename

Each generation is now written to output/ as
narration_<voice>_seed<seed>_<timestamp>.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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BYwW5KLZYovWstkx9aCwiq
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BYwW5KLZYovWstkx9aCwiq
- 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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BYwW5KLZYovWstkx9aCwiq
- 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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BYwW5KLZYovWstkx9aCwiq
… 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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BYwW5KLZYovWstkx9aCwiq
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BYwW5KLZYovWstkx9aCwiq
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UqWxj2j9bdavcLn25ckX8X
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UqWxj2j9bdavcLn25ckX8X
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UqWxj2j9bdavcLn25ckX8X
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UqWxj2j9bdavcLn25ckX8X
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UqWxj2j9bdavcLn25ckX8X
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UqWxj2j9bdavcLn25ckX8X
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UqWxj2j9bdavcLn25ckX8X
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UqWxj2j9bdavcLn25ckX8X
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UqWxj2j9bdavcLn25ckX8X
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UqWxj2j9bdavcLn25ckX8X
…ices

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UqWxj2j9bdavcLn25ckX8X
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UqWxj2j9bdavcLn25ckX8X
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UqWxj2j9bdavcLn25ckX8X
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UqWxj2j9bdavcLn25ckX8X
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UqWxj2j9bdavcLn25ckX8X
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UqWxj2j9bdavcLn25ckX8X
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 <h1> above repeated <h3> 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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fity58qgKttpD1nLheWrzy
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fity58qgKttpD1nLheWrzy
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fity58qgKttpD1nLheWrzy
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fity58qgKttpD1nLheWrzy
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fity58qgKttpD1nLheWrzy
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fity58qgKttpD1nLheWrzy
PaxHelios and others added 30 commits August 8, 2026 12:00
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FBNmTCfFPDth6cWqg69MGu
…rson

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FBNmTCfFPDth6cWqg69MGu
"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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FBNmTCfFPDth6cWqg69MGu
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FBNmTCfFPDth6cWqg69MGu
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FBNmTCfFPDth6cWqg69MGu
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FBNmTCfFPDth6cWqg69MGu
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FBNmTCfFPDth6cWqg69MGu
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FBNmTCfFPDth6cWqg69MGu
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FBNmTCfFPDth6cWqg69MGu
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FBNmTCfFPDth6cWqg69MGu
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FBNmTCfFPDth6cWqg69MGu
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FBNmTCfFPDth6cWqg69MGu
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FBNmTCfFPDth6cWqg69MGu
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FBNmTCfFPDth6cWqg69MGu
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FBNmTCfFPDth6cWqg69MGu
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FBNmTCfFPDth6cWqg69MGu
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FBNmTCfFPDth6cWqg69MGu
"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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FBNmTCfFPDth6cWqg69MGu
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FBNmTCfFPDth6cWqg69MGu
    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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FBNmTCfFPDth6cWqg69MGu
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FBNmTCfFPDth6cWqg69MGu
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FBNmTCfFPDth6cWqg69MGu
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FBNmTCfFPDth6cWqg69MGu
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FBNmTCfFPDth6cWqg69MGu
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FBNmTCfFPDth6cWqg69MGu
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FBNmTCfFPDth6cWqg69MGu
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FBNmTCfFPDth6cWqg69MGu
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FBNmTCfFPDth6cWqg69MGu
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FBNmTCfFPDth6cWqg69MGu
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MErTnGqADwhFXyMoCb4oC4
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants