Initial commit: UmbraAudio audiobook generation program

Quality-first multi-pass audiobook generation from English manuscripts.

Includes:
- umbraaudio/ core package (pipeline, config, voices, web review UI)
- scripts/ CLI utilities + model download script
- tts_server/ remote TTS server
- specs/ design docs, tests/, pyproject.toml + uv.lock
- Qwen3-TTS git submodule (pinned 022e286) with local demo.py UI patch
  saved at scripts/qwen3_tts_demo_umbra_patch.patch
- .env with runtime config/secrets

Excluded (see .gitignore): models/ (downloaded via scripts/download_models.sh),
output/, logs/, .venv, .umbra runtime data, and book projects
(DealHunterShip_*), which are committed separately.
This commit is contained in:
B.D. Murphy
2026-08-30 07:58:24 -07:00
commit c86e323d7a
186 changed files with 45270 additions and 0 deletions

44
.env Normal file
View File

@@ -0,0 +1,44 @@
# UmbraAudio Environment Configuration
# Copy this to .env and modify as needed
# --- Project ---
UMBRA_PROJECT_DIR=.
UMBRA_DATA_DIR=.umbra
# --- TTS ---
UMBRA_TTS__ENGINE=qwen
UMBRA_TTS__DEVICE=mps
UMBRA_TTS__SAMPLE_RATE=24000
UMBRA_TTS__MAX_RETRIES=3
UMBRA_TTS__RETRY_DELAY=2.0
UMBRA_TTS__VOICEDESIGN_MODEL=/Volumes/1TB_APL_Storage/UmbraAudio/models/Qwen3-TTS-12Hz-1.7B-VoiceDesign
UMBRA_TTS__BASE_MODEL=/Volumes/1TB_APL_Storage/UmbraAudio/models/Qwen3-TTS-12Hz-1.7B-Base
# --- LLM ---
UMBRA_LLM__PROVIDER=auto
UMBRA_LLM__LMSTUDIO_BASE_URL=http://localhost:1234/v1
UMBRA_LLM__LMSTUDIO_MODEL=qwen/qwen3.5-9b
UMBRA_LLM__OPENAI_MODEL=gpt-4o
UMBRA_LLM__TEMPERATURE=0.3
UMBRA_LLM__MAX_TOKENS=4096
# --- Web ---
UMBRA_WEB__HOST=0.0.0.0
UMBRA_WEB__PORT=8420
UMBRA_WEB__AUTO_OPEN=false
# --- Quality ---
UMBRA_QUALITY__MIN_MOS=3.8
UMBRA_QUALITY__MIN_TEST_MOS=4.0
UMBRA_QUALITY__MAX_WER=0.03
UMBRA_QUALITY__MIN_SPEAKER_SIMILARITY=0.80
UMBRA_QUALITY__MIN_TEST_SPEAKER_SIMILARITY=0.85
UMBRA_QUALITY__MIN_ARTIFACT_SCORE=3.5
# --- Audio ---
UMBRA_AUDIO__TARGET_RMS_DB=-20.0
UMBRA_AUDIO__PEAK_LIMIT_DB=-3.0
UMBRA_AUDIO__NOISE_FLOOR_DB=-60.0
UMBRA_AUDIO__SILENCE_BETWEEN_UTTERANCES_MS=500
UMBRA_AUDIO__SILENCE_BETWEEN_PARAGRAPHS_MS=2000
UMBRA_AUDIO__SILENCE_BETWEEN_CHAPTERS_MS=4000

39
.gitignore vendored Normal file
View File

@@ -0,0 +1,39 @@
# macOS
.DS_Store
# Python
__pycache__/
*.py[cod]
*.egg-info/
.mypy_cache/
.pytest_cache/
.ruff_cache/
# Virtual environments & toolchains
.venv/
.python/
# HuggingFace cache
.hf_home/
# Downloaded models (see scripts/download_models.sh)
/models/
# Generated output (audio artifacts)
/output/
# Logs
/logs/
# Build artifacts
*.zip
umbraaudio_package.zip
# Local TTS server runtime
/tts_server/__pycache__/
# Book projects are committed separately (not part of this program repo)
/DealHunterShip_4.0e_whole-to-audio/
# App runtime data (per-project book data; projects committed separately)
/.umbra/

3
.gitmodules vendored Normal file
View File

@@ -0,0 +1,3 @@
[submodule "Qwen3-TTS"]
path = Qwen3-TTS
url = https://github.com/QwenLM/Qwen3-TTS.git

200
AGENTS.md Normal file
View File

@@ -0,0 +1,200 @@
# UmbraAudio — Development Conventions
## Project Overview
Quality-first multi-pass audiobook generation from English manuscripts.
Each pass has quality gates. Quality is the primary concern; generation speed is secondary.
## Architecture
- **5-pass pipeline**: Analysis → Voice Design → Mapping → Test → Generate
- **Multi-engine TTS**: Edge-TTS (primary for voice variety), Chatterbox (secondary for cloning)
- **LLM-enhanced**: gemma-4-12b-it-qat for character discovery and profiling
- **Web review**: FastAPI + HTMX at checkpoints for human approval
## Voice System
- **Narrator**: Dedicated soft-spoken male voice (en-US-GuyNeural:-10), deep, radio speaker style
- **Characters**: 59 unique voices from Edge-TTS with pitch shifting
- **Voice Assignment**: Stored in `.umbra/voice_assignments.json`
- **Test Samples**: Verification phrase uses the Rainbow Passage (phonetically balanced paragraph covering all English phonemes)
## Tech Stack
- Python 3.11+, asyncio throughout
- SQLite via aiosqlite for project state
- FastAPI + HTMX for web review UI
- Click + Rich for CLI
- Edge-TTS for voice generation
- LM Studio (`google/gemma-4-e4b`) for character discovery and speaker attribution
## Commands
- **Lint:** `ruff check umbraaudio/ scripts/`
- **Format:** `ruff format umbraaudio/ scripts/`
- **Type check:** `mypy umbraaudio/`
- **Test:** `pytest tests/ -v`
- **Run:** `python -m umbraaudio.cli`
- **Web:** `python -c "from pathlib import Path; from umbraaudio.web import create_app; from umbraaudio.config import load_config; import uvicorn; config = load_config(Path('.')); app = create_app(config); uvicorn.run(app, host='0.0.0.0', port=8420)"`
- **Mechanical re-segment:** `python scripts/resegment_all.py --mechanical-only`
- **LLM speaker attribution:** `python scripts/reattribute_all.py --model google/gemma-4-e4b`
- **LLM character discovery:** 4-pass pipeline via `/api/analyze` endpoint (see specs/02-analysis.md)
## Code Conventions
- Type hints on all function signatures
- Docstrings on public classes/methods
- `from __future__ import annotations` in every file
- No comments unless explaining *why*, not *what*
- Use `logging` module, never `print()` for debug output
- Async for all I/O-bound operations
- Dataclasses or Pydantic models for structured data
- Paths via `pathlib.Path`, never string paths
## Testing
- Tests mirror source structure: `umbraaudio/parsers/` → `tests/test_parsers.py`
- Use pytest fixtures for database, config, mock TTS engine
- Mock external services (LLM APIs, TTS engines) in tests
- Integration tests for full pipeline with sample manuscript
## File Naming
- Modules: `snake_case.py`
- Classes: `PascalCase`
- Functions: `snake_case`
- Constants: `UPPER_SNAKE_CASE`
- Private: `_leading_underscore`
## Dependencies
- Check `pyproject.toml` before adding new dependencies
- Never add a dependency if stdlib suffices
- Prefer: stdlib → third-party → custom implementation
## Current Session Summary
### Goal
Fix speaker-attribution errors so the user can review/accept voices with confidence. Reported
failures in chapter "Salvage" (ch6): utt35 "We have strict orders…" (after "she asks the lead guard…
He replies") was tagged Kabir instead of the lead guard, and utt156–157 "The voice calls again" /
"Help me, please." was tagged Deal Hunter instead of the female space-survivor (Kainda).
### Root Cause Analysis
1. **No rule for unnamed role tags**: narration "the lead guard", "the robot" → LLM guessed a named
character present in the scene (Kabir) instead of the role.
2. **Old prompt rule 3e over-applied**: any "voice"/comm → Deal Hunter, so a human voice ("Help me,
please.") became the ship AI.
3. **Context window too narrow**: `_build_position_context` only looked 8 utterances back → "It
sounds female," (13 lines before) fell outside, so a female survivor's voice went to Deal Hunter.
4. **No verification pass**: attribution was trust-on-faith; wrong speakers were never caught
mechanically.
5. **`ModelCoordinator.load_lm` was not idempotent**: re-entering CHAPTER_ANALYSIS always re-POSTed
`/v1/models/load` → a second copy of a 9 GB model failed to allocate on the 16 GB Mac.
### Fixes Implemented (this session)
- **`umbraaudio/analysis/llm_attributor.py` prompt**: new rule 3e (Deal Hunter/AI-1 ONLY when narration
explicitly names a computer/AI/comm system; a bare "a voice is heard"/"the voice calls again" over
radio is HUMAN — use gender cues like "it sounds female"), 3f (pronoun coreference; never resolve to
an absent character or a named char when the antecedent is an unnamed role), 3g (unnamed roles → the
role speaker's EXACT primary_name e.g. "Chief of the Guard", else a Title Case role name; NEVER a
random named character), 3h (repeated lines — "The voice calls again, 'Help me, please.'" — keep the
first occurrence's speaker). Context widened: `_extract_scene_narration` 12→20 lines;
`_build_position_context` 8→40 utterances back, labelled dialogue, 4200-char / 45-part caps.
- **New `umbraaudio/analysis/attribution_qa.py`** (mechanical cross-check): `check_attribution()`
flags `speech_tag_mismatch` (conf 0.9 resolved / 0.7 unresolved-name), `narrator_dialogue` (0.95),
`role_tag_guessed` (0.5). Tag applied only from the IMMEDIATELY preceding narration (never across a
dialogue line — prevents shared-tag bleed like "Kainda asks," + reply). Pronoun subjects skipped
(coreference is the LLM's job). `_names_match()` falls back to word-overlap for unresolved titles
("Wakam" vs "Trader Wakam", "Admiral Wake" vs "Admiral Wilbur Wake"). `fix_high_confidence()` writes
back only resolved exact-name mismatches. Role regex capped at 2 role words and rejects trailing
conjunctions ("the robot and tells").
- **New `scripts/qa_attribution.py`** CLI: `--chapters N`, `--fix`, `--type`. `_build_alias_map`
includes primary, aliases, and first-word mappings.
- **`umbraaudio/voices/model_coordinator.py`**: `load_lm` (sync + async) now no-ops when the requested
model already has a loaded instance — re-entering CHAPTER_ANALYSIS no longer tries to allocate a
second copy.
- **`.umbra/speakers.json` aliases added**: `Admiral Wake`/`The Admiral`→Admiral Wilbur Wake,
`Captain Antlion`/`Antlion`→Captain Wolfgang Antlion, `Wakam`/`Wakam the Trader`→Trader Wakam,
`Vicky`→Duchess Victoria, `Pirate Leader`/`The Pirate Leader`→Pirate Clan Leader,
`Niñera`/`Nini`/`The Nanny`→Nanny (Niñera = the nanny robot, Support Robot 74532). This session:
`Lead Guard`→Chief of the Guard, `Lead Trainer`→Training Officer, `Bill`→Worker Robot (Bill),
`Captain Alec Weston`→Captain Weston.
### Completed Work
- **Full 35-chapter LLM re-attribute** with the new prompt (`google/gemma-4-e4b`, ~21 tok/s at ~1.3
min/batch; total ~2 h): 1878 dialogue utterances, **0 left as narrator**. Chapters 0–4 and 33–35 are
front/back matter with no dialogue (skipped as "no utterances" or 0 dialogue).
- **QA + auto-fix loop**: book-wide QA fell from 38 → 10 issues. 21 auto-fixes applied (resolved
tag mismatches) plus 5 evidence-driven role fixes.
- **Fixed speakers**: ch6 utt157→Kainda Walters Foster Murphy ("Help me, please." = female survivor);
ch6 utt44/49/52/56/211→Nanny (the "robot"/Niñera who rushes in and explains the attack is the nanny
bot); ch8 utt18/33→Trader Wakam; ch9 utt22→Duchess Victoria (tag "Vicky says assertively");
ch16 utt120→Pirate Clan Leader (the "modified voice" on the call); ch17 utt94→Deal Hunter; ch18
utt154→Xu Ming, utt168→Kainda; ch20 utt230/utt345→Warbots / Pirate Clan Leader; ch23 utt63→Deal
Hunter; ch24 utt15→Kainda; ch25 utt131/utt224→Kainda, utt133→Warbots; ch30 utt82/115→Admiral
Wilbur Wake, utt113/133→Duchess Victoria, utt162/170→Kabir, utt194/241→Captain Wolfgang Antlion;
ch32 utt68/87→Captain Wolfgang Antlion.
- **Ghost-speaker sweep** (post-review, driven by "Kabir isn't in ch6"): script cross-checked every
assigned speaker against all name/alias tokens present in the chapter text (stopwords filtered,
alias-aware — Willamina's "Captain" alias counts). Found and fixed:
- ch6 utt38/40→Nanny (were `Kabir`; the "service robot … named Niñera" who enters with the suit
says these; Kabir appears only once in ch6, in narration as the off-screen brother).
- ch9 utt2/8/11/13→Training Officer (were `Admiral Wilbur Wake`; narration: "She is talking to the
lead trainer for the young royals" / "the argument with the training officer"; profile: [9]).
- ch16 utt122/123/124→Pirate Clan Leader (were `Admiral Wilbur Wake`; the "modified voice" scolding
the failing pirate, same voice as utt120).
- ch24 utt16→Pilot (Freighter) (were `Alec`; same freighter pilot as utt18/20; Alec is a ch8-only
Wakam associate).
- ch26 utt128/139→Frigate Captain (were `Admiral Wilbur Wake`; "the pirate captain … yells" on the
frigate bridge), ch26 utt135→Frigate Captain (were Willamina; "Seal the bridge" order after the
captain sees the video). Profile: [24,26]. ch26 utt194→Kainda Walters Foster Murphy (were Admiral
Wilbur Wake; she's the one who made it to 12.5G in the pilot chair — Warbots reply "Yes, Captain").
- **Remaining QA flags** are deliberate assignments the checker can't see intent behind (Nanny=robot,
Kainda="voice", Warbots, Pirate Clan Leader) — treat as clean.
- pytest: **181 passed, 3 warnings** (17 in `tests/test_attribution_qa.py` covering tag mismatch,
alias resolution, tag isolation, pronouns, word-overlap fallback, conjunction rejection, auto-fix
gating). `ruff check` + `mypy` clean on `attribution_qa.py`, `qa_attribution.py`,
`model_coordinator.py`, tests.
- **Hardware reality (16 GB Mac)**: gemma-4-12b needs 16.88 GB → cannot fit; qwen3.5-9b fits but
generates ~1 tok/s under swap (~15 h/book) → not viable; gemma-4-e4b is the practical best
(~21 tok/s). LLM and Qwen TTS must not be resident simultaneously (coordinator enforces).
### Flagged for User Review (ambiguous, left as-is)
| Loc | Line | Currently | Question |
|---|---|---|---|
| ch16 utt121/125 | "The ship landed with a different designation…" / "Yes." | Adonai Maul | The unnamed pirate subordinate failing the Clan Leader (Adonai Maul is a ch27 female bounty — wrong). No role profile; suggest a new "Pirate Commander" |
| ch20 utt223 | "You are taking out multiple air pressure areas…" | Deal Hunter | Ship AI warning — likely already correct |
| ch24 utt108 | "We don't know. Checking." | Deal Hunter | Probably a bridge/crew voice, not Deal Hunter |
| ch26 utt129 | "Captain, it is a real alarm. Warbots just boarded…" | Willamina | Frigate bridge crew reporting to the pirate captain (not Willamina, she's on Deal Hunter); no crew role profile |
| ch26 utt155 | "Four minutes and fifty seconds." | Warbots | Who is "the speaker"? A leader/announcer? |
| ch26 utt158 | "Captain, they have taken the bridge…" | Willamina | Narration: "Status info is radioed to Deal Hunter, who tells Kainda" — could be Deal Hunter the AI, not Willamina |
### Known Pre-existing Bugs (reported, not fixed)
- `scripts/generate_chapter_audio.py:132` references undefined `phonetic` (NameError on first
utterance; superseded by `generate_all_audio.py`).
- `start_server.sh` launches the Gradio **qwen-tts-demo** on port 8000 — NOT the API server
`scripts/qwen_tts_server.py` (port 8881) the app actually uses. Use `qwen_tts_server.py` for restarts.
- **Orphaned Qwen generations**: a client that times out does NOT cancel the server-side thread; the
model keeps generating under `_model_lock` for minutes, blocking subsequent requests. Recovery =
`kill -9` the qwen server PID and restart `scripts/qwen_tts_server.py`.
- dictionaryapi.dev free tier rate-limits; residual bad records no longer matter for audio
(dictionaryapi records are not applied to speech), would need bulk re-lookup to clean review panel.
### Audio-Generation Prep (voice resolution + staleness)
- **`umbraaudio/generation/staleness.py`**: markers are now JSON `{"speaker", "text"}` instead of
plain text. `is_current(text, wav, speaker=...)` only returns True when BOTH match. Legacy plain-text
markers are ALWAYS stale → any existing ch1–7 wavs regenerate with corrected attribution on the next
run. All call sites updated (`generate_all_audio.py`, `routes.py` ×3), speaker default normalized to
`"narrator"`.
- **`scripts/generate_all_audio.py`**: `load_voice_references` now uses `rglob` (finds nested refs like
`human_staff_person/messenger_ref.wav`); `resolve_reference` now does case-insensitive canonical
alias lookup, unique first-word match, and longest-substring fallback (was exact/casefold only →
silent narrator fallback). **50/50 distinct speakers now resolve to a voice ref (0 fallback).**
- **New speakers added** (speakers.json + voice_profiles.json + generated refs): `Crew Member`
(ch24 bridge crew, male, clipped urgency; `crew_member_ref.wav`) and `Supervisor` (ch10 female
production supervisor, stressed/assertive; `supervisor_ref.wav`). Both generated via
`scripts/generate_voice_references.py`.
- All 184 pytest tests pass; ruff clean on `staleness.py`, `generate_all_audio.py`, tests.
- **Next**: run `scripts/generate_all_audio.py` (nohup) — Qwen TTS server up (port 8881); all
4406 utterances will regenerate since legacy markers are stale (~6.4 s/utt, multi-hour run).
### Remaining Edge Cases
Chapter-5 audio verified end-to-end (files, markers, stitch, ASR). A/B samples:
`/tmp/author_note_before.wav`, `/tmp/author_note_natural.wav`, `/tmp/ch5_combined.wav`. Optional
future tooling: a per-utterance Whisper-based QA transcription pass over `ch*_full.wav`.
### Previous Session (chapter-5 audio fix)
Complete — ch5 audio (markers, staleness, pronunciation, curly punctuation) verified. History below
this section documents the model-coordinator session and speaker-attribution completion.

32
FixFeedback.txt.rtf Normal file
View File

@@ -0,0 +1,32 @@
{\rtf1\ansi\ansicpg1252\cocoartf2870
\cocoatextscaling0\cocoaplatform0{\fonttbl\f0\fswiss\fcharset0 Helvetica;}
{\colortbl;\red255\green255\blue255;\red249\green249\blue249;\red17\green17\blue17;}
{\*\expandedcolortbl;;\cssrgb\c98039\c98039\c98039;\cssrgb\c8627\c8627\c8627;}
\margl1440\margr1440\vieww11520\viewh8400\viewkind0
\pard\tx720\tx1440\tx2160\tx2880\tx3600\tx4320\tx5040\tx5760\tx6480\tx7200\tx7920\tx8640\pardirnatural\partightenfactor0
\f0\fs24 \cf0 In the umbra audio system we are processing to validate utterances are done correctly. Chapter 6 Salvage\
Was generated and listened to. Issues became clear that need to be fixed.\
\
The system has a proper names section where the correct way to pronounce names is verified. Is that verified pronunciation used with every utterance?\
\
\pard\pardeftab720\partightenfactor0
\fs26 \cf2 \cb3 \expnd0\expndtw0\kerning0
Checking the chapter Salvage, utterance 56 isn't correct. It is actually three sentences and should be three utterances with different speakers. \
\
Based on context the utterance 145 should be modified to use a pleading tone. \
\
Utterances starting at 186 uses the Deal Hunter voice. When the narative clearly talks about a robot. The speaker should be the robot that we know from later in this section (from the context) is Nanny. \
\
Utterance 186 follow "She asks", clearly the rescued girl. From the context - Kainda has red hair, This person rescued from the yaht debre has red hair - it is Kainda. The speaker assigned is again deal hunter which is wrong. \
\
At utterance 211 suddently shifts to nanny as the speaker - this is correct, but it should have been nanny before. \
\
The utterance 218 is "<<<>>>" a section break and should be removed is is unspoken. \
\
utterance 221 speaker is nanny and correct. utterance 222 is a different person (Kainda) but the system assigns the speaker as nanny. \
\
Utterance 353 clearly has the captain yells, so utterance 354 should be yelling. Utterance 254 is correctly identified as captain willamina wake, but not yelling. \
\
These are all examples of the analysis section not working properly. The system needs to be improved. To be clear it is acceptable to have multiple passes to determine different parts of the product. This project is about quality not speed.}

1
Qwen3-TTS Submodule

Submodule Qwen3-TTS added at 022e286b98

59
README.md Normal file
View File

@@ -0,0 +1,59 @@
# UmbraAudio
Quality-first multi-pass audiobook generation from English manuscripts.
## Features
- 5-pass pipeline: Analysis → Voice Design → Mapping → Test → Generate
- Multi-engine TTS: Chatterbox (primary), Qwen3-TTS (secondary)
- LLM-enhanced: BookNLP for NLP, LLM for profiling/emotion/pronunciation
- Web review: FastAPI + HTMX at checkpoints for human approval
## Quick Start
```bash
python -m umbraaudio.cli init <manuscript.epub> --name 'My Book'
python -m umbraaudio.cli analyze
python -m umbraaudio.cli voices
python -m umbraaudio.cli review
```
## Requirements
- Python 3.11+
- NVIDIA GPU with CUDA support (RTX 3060 or better recommended)
- ffmpeg
## Development
```bash
pip install -e ".[dev]"
ruff check umbraaudio/
pytest tests/ -v
```
## Rebuilding from source
The repo tracks source only; large runtime data is reconstructed as needed.
1. **Models**: weights are not committed (see `.gitignore`). Download them:
```bash
bash scripts/download_models.sh # ~9 GB, targets ./models
```
2. **Qwen3-TTS** is a git submodule pinned to upstream commit `022e286`:
```bash
git submodule update --init --recursive
```
The `tts_server`/web-demo backend includes a local UI customization to
`qwen_tts/cli/demo.py`. It is preserved as a patch — reapply it after
checking out the submodule:
```bash
cd Qwen3-TTS && git apply ../scripts/qwen3_tts_demo_umbra_patch.patch
```
3. **Environment**: copy/keep `.env` (contains secrets) and set `UMBRA_TTS__*`
model paths under `models/`.
Book projects (e.g. `DealHunterShip_...`) are committed separately under their
own project directories and are not part of this program repository.

BIN
UmbraAudio_Blue_2.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

BIN
UmbraAudio_icon.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 661 KiB

BIN
UmbraAudio_icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

307
Umbra_Voice_generation.md Normal file
View File

@@ -0,0 +1,307 @@
# UmbraAudio Voice Generation Specification
## Overview
UmbraAudio generates multi-voice audiobooks from English manuscripts. Each character
gets a unique, consistent voice. The voice identity is stored as a **speaker embedding** —
a 2048-dimensional float32 vector extracted from Qwen3-TTS's internal speaker encoder.
This embedding is the single source of truth for a character's voice.
## Architecture
### Voice Identity Pipeline
```
Edge-TTS WAV ref ──or── VoiceDesign instruct text
│ │
▼ ▼
Base model VoiceDesign model
extract_speaker_ generate_voice_design()
embedding() │
│ ▼
▼ Base model
Speaker embedding extract_speaker_embedding()
(2048-dim float32) │
│ ▼
▼ Speaker embedding
Save as .npy │
(8KB per speaker) ▼
Save as .npy
```
### Key Insight
The old approach (WAV ref → extract x-vector at generation time) produced **nearly
identical voices** (MFCC cosine similarity 0.99+) because x-vector extraction is lossy.
The new approach stores the **exact embedding** that the model uses, bypassing extraction
entirely. This gives:
- **Perfect determinism**: same embedding + same seed = identical waveform
- **Distinct voices**: narrator 275 Hz, nanny 176 Hz, kainda 227 Hz (measured F0)
- **Tiny storage**: 8KB per speaker vs megabytes of WAV refs
- **No drift**: embedding is fixed once saved, never re-extracted
## File Structure
```
.umbra/
├── voice_embeddings/ # Speaker identity cache
│ ├── narrator.npy # 2048-dim float32 (8KB)
│ ├── nanny.npy
│ ├── kainda_walters_foster_murphy.npy
│ ├── deal_hunter.npy
│ └── ... # 94 speakers total
├── voices/ # Legacy WAV refs (kept for fallback)
│ ├── narrator_ref.wav
│ ├── nanny_ref.wav
│ └── ...
├── speakers.json # Character metadata
├── voice_assignments.json # Edge-TTS voice mapping
├── voice_profiles.json # LLM-generated voice profiles
└── project.db # Utterances, chapters, attributions
```
## Generation Modes
### Mode 1: Cached Embedding (Preferred)
Fastest and most consistent. Used when a `.npy` embedding exists for the speaker.
```python
# Client loads cached embedding
emb = np.load("voice_embeddings/narrator.npy") # 2048-dim float32
# Sends to server as JSON (8KB payload)
POST /v1/tts
speaker_embedding: "[0.123, -0.456, ...]" # JSON float list
text: "The captain nodded."
temperature: 0.5
seed: 42
# Server passes embedding directly to model
model.generate_voice_clone(
text=text,
voice_clone_prompt={
"ref_code": [None],
"ref_spk_embedding": [spk_tensor],
"x_vector_only_mode": [True],
"icl_mode": [False],
},
language="english",
temperature=0.5,
)
```
**Determinism**: Same embedding + same seed = **identical waveform** (verified: mean abs diff = 0.000000).
### Mode 2: WAV Reference (Fallback)
Used when no cached embedding exists. Extracts x-vector at generation time.
```python
POST /v1/tts
ref_audio: <WAV file upload> # 50-200KB
text: "The captain nodded."
temperature: 0.5
```
### Mode 3: VoiceDesign (New Voice Creation)
Used to create new character voices from text descriptions.
```python
POST /v1/tts
voice_description: "A deep male voice with Australian accent"
text: "The captain nodded."
temperature: 0.5
```
## Voice Key Generation
### From Existing WAV Refs
```bash
python scripts/extract_embeddings.py --project DealHunterShip_4.0e_whole-to-audio
```
Loads Base model once, iterates all `*_ref.wav` files, extracts embeddings, saves as `.npy`.
Takes ~6 seconds for 94 speakers.
### From VoiceDesign Instruct Text
```bash
python scripts/extract_embeddings.py \
--design "A young female voice with slight accent, speaking softly" \
--slug new_character \
--seed 42
```
Generates audio via VoiceDesign → extracts embedding via Base model → saves as `.npy`.
### Regenerating All Voice Samples
```bash
python scripts/extract_embeddings.py --force # Re-extract from WAV refs
```
## Rainbow Passage
All voice references are generated using the Rainbow Passage — a phonetically balanced
paragraph covering all English phonemes. This ensures the speaker embedding captures the
full range of vocal characteristics.
```
When the sunlight strikes raindrops in the air, they act as a prism and form a rainbow.
The rainbow is a division of white light into many beautiful colors.
These take the shape of a long round arch, with its path high above,
and its two ends apparently beyond the horizon.
There is, according to legend, a boiling pot of gold at one end.
People look, but no one ever finds it.
When a man looks for something beyond his reach,
his friends say he is looking for the pot of gold at the end of the rainbow.
```
## Server API
### POST /v1/tts
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| text | str | required | Text to synthesize |
| sample_rate | int | 24000 | Audio sample rate |
| voice_description | str | None | VoiceDesign instruct text |
| ref_audio | File | None | Voice reference WAV (fallback mode) |
| ref_text | str | None | Reference text for ICL mode |
| speaker_embedding | str | None | JSON-encoded float list (2048-dim) |
| temperature | float | 0.9 | Sampling temperature (lower = more consistent) |
| do_sample | bool | True | Enable sampling |
| seed | int | None | Random seed for reproducibility |
### POST /load
Load a model into memory.
```json
{"model_type": "base"} // or "voicedesign"
```
### POST /unload
Unload a model to free memory.
```json
{"model_type": "all"} // or "base" or "voicedesign"
```
### GET /health
Returns server status and loaded models.
## Consistency Guarantees
### Per-Utterance Consistency
Each utterance uses the same embedding + same seed → identical voice identity.
Variation comes only from:
- Different text (phoneme sequence)
- Temperature (controlled per-chapter)
### Cross-Chapter Consistency
Same speaker embedding used across all chapters → same voice identity.
The narrator always sounds like the narrator. Kainda always sounds like Kainda.
### Determinism
With `seed=42` and the same embedding:
- Run 1 and Run 2 produce **identical waveforms** (mean abs diff = 0.000000)
- Different seeds produce different phrasings but same voice identity
## Chapter Generation Flow
1. **Preflight**: Verify TTS server is running, Base model loaded
2. **Validate**: Check which utterances need (re)generation
3. **Generate**: For each utterance:
- Load speaker embedding from `.npy` cache
- Send to server with text + temperature + seed
- Save WAV to `.umbra/chapter_audio/ch{N}/utt{M}.wav`
4. **Stitch**: Concatenate utterance WAVs into chapter WAV
5. **Export**: Convert chapter WAV to MP3 via ffmpeg
6. **Verify**: Run quality checks
## Hardware Constraints
- **Mac 16 GB MPS**: One model resident at a time (Base OR VoiceDesign)
- **Base model**: ~3.5 GB VRAM, generates ~10s/utterance
- **VoiceDesign model**: ~3.5 GB VRAM, generates ~10s/utterance
- **ICL mode**: ~11 minutes/utterance on MPS (not viable for production)
- **x-vector mode**: ~10s/utterance on MPS (production mode)
## Model Paths
| Model | Path | Purpose |
|-------|------|---------|
| Base | `models/Qwen3-TTS-12Hz-1.7B-Base` | Voice cloning + embedding extraction |
| VoiceDesign | `models/Qwen3-TTS-12Hz-1.7B-VoiceDesign` | New voice creation from instruct text |
| Tokenizer | `models/Qwen3-TTS-Tokenizer-12Hz` | Shared tokenizer |
## Configuration
### speakers.json
```json
{
"speakers": [
{
"primary_name": "Narrator",
"gender": "male",
"accent": "american",
"voice_status": "approved",
"vocal_traits": {
"pitch": "low",
"energy": "measured",
"timbre": "warm"
}
}
]
}
```
### voice_assignments.json
Maps character names to Edge-TTS voice identifiers (used for WAV ref generation).
```json
{
"Narrator": "en-US-GuyNeural:-10",
"Nanny": "en-US-AvaNeural",
"Kainda Walters Foster Murphy": "en-US-JennyNeural"
}
```
## Troubleshooting
### Voice sounds wrong
1. Check if embedding exists: `ls .umbra/voice_embeddings/{slug}.npy`
2. Regenerate embedding: `python scripts/extract_embeddings.py --speaker {name} --force`
3. Verify with Rainbow Passage sample
### TTS server not responding
```bash
curl http://localhost:8881/health
# If down: restart with qwen_tts_server.py
```
### Memory pressure
```bash
curl -X POST http://localhost:8881/unload -d '{"model_type": "all"}'
```
### Inconsistent voices across utterances
This was the old x-vector extraction problem. Now solved by cached embeddings.
If still occurring, ensure the caller is using cached embeddings (check logs for
"Using cached embedding for ...").

99
pyproject.toml Normal file
View File

@@ -0,0 +1,99 @@
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "umbraaudio"
version = "0.1.0"
description = "Quality-first multi-pass audiobook generation from manuscripts"
readme = "README.md"
license = "MIT"
requires-python = ">=3.11"
authors = [{ name = "UmbraAudio" }]
keywords = ["audiobook", "tts", "text-to-speech", "narration"]
classifiers = [
"Development Status :: 3 - Alpha",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Topic :: Multimedia :: Sound/Audio :: Speech",
]
dependencies = [
# CLI
"click>=8.1",
"rich>=13.0",
# Database
"aiosqlite>=0.19",
# Parsers
"ebooklib>=0.18",
"beautifulsoup4>=4.12",
"PyMuPDF>=1.23",
"python-docx>=1.0",
# NLP / Analysis
"booknlp>=1.0",
# LLM clients
"openai>=1.10",
"httpx>=0.25",
# TTS engines
"qwen-tts>=0.1",
"edge-tts>=6.1",
"torch>=2.6",
"torchaudio>=2.6",
# Audio processing
"librosa>=0.10",
"soundfile>=0.12",
"pydub>=0.25",
# Quality metrics
"speechmos>=0.0.1",
"openai-whisper>=20231117",
"speechbrain>=0.5",
# Web review UI
"fastapi>=0.104",
"uvicorn[standard]>=0.24",
"jinja2>=3.1",
"python-multipart>=0.0.6",
# Utilities
"numpy>=1.24",
"pydantic>=2.5",
"pydantic-settings>=2.1",
]
[project.optional-dependencies]
dev = [
"pytest>=7.4",
"pytest-asyncio>=0.21",
"pytest-cov>=4.1",
"ruff>=0.1",
"mypy>=1.7",
]
[project.scripts]
umbraaudio = "umbraaudio.cli:main"
[tool.hatch.build.targets.wheel]
packages = ["umbraaudio"]
[tool.ruff]
target-version = "py311"
line-length = 100
[tool.ruff.lint]
select = ["E", "F", "I", "N", "W", "UP", "B", "A", "SIM", "TCH"]
[tool.mypy]
python_version = "3.11"
strict = true
plugins = ["pydantic.mypy"]
[tool.pytest.ini_options]
testpaths = ["tests"]
asyncio_mode = "auto"

118
scripts/analyze_emotions.py Normal file
View File

@@ -0,0 +1,118 @@
#!/usr/bin/env python3
"""Run LLM emotion/tone analysis on existing utterances without re-segmenting."""
from __future__ import annotations
import sqlite3
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent))
from umbraaudio.config import load_config
from umbraaudio.mapping.emotion import EmotionMapper
from umbraaudio.voices.model_coordinator import ModelCoordinator, PipelineStage
MODEL = "google/gemma-4-e4b"
def main():
import argparse
parser = argparse.ArgumentParser()
parser.add_argument(
"--project",
type=str,
default=None,
help="Project name or path",
)
parser.add_argument(
"--chapters",
type=str,
default=None,
help="Comma-separated chapter IDs (e.g. '5,6,7')",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Print stats without writing to DB",
)
args = parser.parse_args()
cfg = load_config(args.project)
coordinator = ModelCoordinator(cfg)
if not coordinator.switch_to(
PipelineStage.CHAPTER_ANALYSIS, lm_model=MODEL
).get("ok"):
print("Failed to switch models to CHAPTER_ANALYSIS stage")
sys.exit(1)
from openai import OpenAI
client = OpenAI(base_url=cfg.llm.lmstudio_base_url, api_key="lm-studio")
mapper = EmotionMapper(llm_client=client, model=MODEL)
db_path = cfg.db_path
db = sqlite3.connect(str(db_path))
db.row_factory = sqlite3.Row
if args.chapters:
chapter_ids = [int(x.strip()) for x in args.chapters.split(",")]
placeholders = ",".join("?" * len(chapter_ids))
chapters = db.execute(
f"SELECT id, title, chapter_index, source_text FROM chapters "
f"WHERE id IN ({placeholders}) ORDER BY chapter_index",
chapter_ids,
).fetchall()
else:
chapters = db.execute(
"SELECT id, title, chapter_index, source_text FROM chapters ORDER BY chapter_index"
).fetchall()
print(f"Found {len(chapters)} chapters to analyze")
print(f"Model: {MODEL}")
total = 0
for ch in chapters:
ch_id = ch["id"]
title = ch["title"] or f"Chapter {ch['chapter_index']}"
utts = db.execute(
"SELECT id, utterance_index, text, type, speaker_name "
"FROM utterances WHERE chapter_id=? ORDER BY utterance_index",
(ch_id,),
).fetchall()
if not utts:
print(f" {title}: no utterances, skipping")
continue
utt_list = [dict(u) for u in utts]
context = (ch["source_text"] or "")[:500]
emotions = mapper.analyze_utterances(utt_list, context)
if args.dry_run:
print(f" {title} (id={ch_id}): {len(emotions)} emotions returned")
total += len(emotions)
continue
for u, emo in zip(utt_list, emotions, strict=False):
db.execute(
"UPDATE utterances SET emotion=?, pace=?, instruct=? WHERE id=?",
(
emo.get("emotion", "neutral"),
emo.get("pace", "moderate"),
emo.get("instruct", ""),
u["id"],
),
)
db.commit()
total += len(emotions)
print(f" {title} (id={ch_id}): analyzed {len(emotions)} utterances")
db.close()
coordinator.switch_to(PipelineStage.IDLE)
print(f"\nTotal: {total} utterances analyzed")
print("Done!")
if __name__ == "__main__":
main()

187
scripts/assign_voices.py Normal file
View File

@@ -0,0 +1,187 @@
#!/usr/bin/env python3
"""Assign unique Edge-TTS voices to all speakers."""
from __future__ import annotations
import hashlib
import json
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent))
from umbraaudio.config import load_config
# Edge-TTS voices organized by gender and pitch
VOICES = {
"male_low": [
"en-US-GuyNeural",
"en-GB-ThomasNeural",
"en-NG-AbeoNeural",
],
"male_mid": [
"en-US-ChristopherNeural",
"en-US-JasonNeural",
"en-US-TonyNeural",
"en-GB-RyanNeural",
"en-AU-WilliamMultilingualNeural",
"en-IN-PrabhatNeural",
"en-IE-ConnorNeural",
"en-CA-LiamNeural",
"en-KE-ChilembaNeural",
],
"female_mid": [
"en-US-JennyNeural",
"en-US-AvaNeural",
"en-US-MichelleNeural",
"en-GB-SoniaNeural",
"en-AU-NatashaNeural",
"en-IN-NeerjaNeural",
"en-IE-EmilyNeural",
"en-CA-ClaraNeural",
"en-NG-EzinneNeural",
"en-KE-AsiliaNeural",
],
"female_high": [
"en-US-SaraNeural",
"en-GB-LibbyNeural",
"en-HK-YanNeural",
],
"child": [
"en-US-AnaNeural",
],
}
# All voices in a flat list for fallback
ALL_VOICES = []
for voice_list in VOICES.values():
ALL_VOICES.extend(voice_list)
def get_voice_category(speaker: dict) -> str:
"""Determine which voice category a speaker should use."""
demos = speaker.get("demographics", {})
gender = demos.get("gender", "Unknown").lower()
stype = demos.get("type", "Human").lower()
# Check for AI/Robot/Computer types
if stype in ("robot", "ai", "computer", "machine", "system"):
return "male_mid" # Robots get a neutral mid voice
# Check for child
if "child" in stype.lower():
return "child"
# Gender-based assignment
if gender == "male":
return "male_mid"
elif gender == "female":
return "female_mid"
else:
# Unknown gender - use mid voices
return "male_mid"
def assign_voices(speakers: list[dict]) -> dict[str, str]:
"""Assign unique voices to all speakers."""
voice_assignments = {}
used_voices = set()
# Track available voices by category
available = {cat: list(voices) for cat, voices in VOICES.items()}
# Sort speakers by chapter count (more chapters = higher priority)
sorted_speakers = sorted(
speakers,
key=lambda s: len(s.get("chapters_appeared", [])),
reverse=True
)
for speaker in sorted_speakers:
name = speaker["primary_name"]
category = get_voice_category(speaker)
# Try to find an unused voice in the preferred category
assigned = False
if available.get(category):
for voice in available[category]:
if voice not in used_voices:
voice_assignments[name] = voice
used_voices.add(voice)
available[category].remove(voice)
assigned = True
break
# If no voice in preferred category, try other categories
if not assigned:
for cat, voices in available.items():
for voice in voices:
if voice not in used_voices:
voice_assignments[name] = voice
used_voices.add(voice)
available[cat].remove(voice)
assigned = True
break
if assigned:
break
# If still no voice, use a hash-based fallback with pitch shifting
if not assigned:
name_hash = int(hashlib.md5(name.encode()).hexdigest()[:4], 16)
base_voice = ALL_VOICES[name_hash % len(ALL_VOICES)]
# Add pitch shift indicator
pitch_shift = (name_hash % 200) - 100 # -100 to +100
voice_assignments[name] = f"{base_voice}:{pitch_shift:+d}"
return voice_assignments
def main():
import argparse
parser = argparse.ArgumentParser(description="Assign unique Edge-TTS voices to speakers")
parser.add_argument(
"--project", type=str, default=None, help="Project name or path"
)
args = parser.parse_args()
cfg = load_config(args.project)
# Load speakers
speakers_path = cfg.data_dir / "speakers.json"
with open(speakers_path) as f:
data = json.load(f)
speakers = data["speakers"]
print(f"Assigning voices to {len(speakers)} speakers...")
# Assign voices
assignments = assign_voices(speakers)
# Save assignments
output_path = cfg.data_dir / "voice_assignments.json"
with open(output_path, "w") as f:
json.dump(assignments, f, indent=2)
print(f"\nVoice assignments saved to {output_path}")
# Print summary
print("\n=== Voice Assignments ===")
for name, voice in sorted(assignments.items()):
print(f" {name:35} -> {voice}")
# Count unique base voices
base_voices = set()
for voice in assignments.values():
if ":" in voice:
base_voices.add(voice.split(":")[0])
else:
base_voices.add(voice)
print("\n=== Summary ===")
print(f"Total speakers: {len(speakers)}")
print(f"Unique base voices: {len(base_voices)}")
print(f"Speakers with pitch shift: {sum(1 for v in assignments.values() if ':' in v)}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,26 @@
#!/usr/bin/env bash
# Downloads the Qwen3-TTS model weights required by UmbraAudio.
#
# These models are stock HuggingFace downloads (~9 GB). They are NOT tracked
# in git; run this script once after cloning to reconstruct the models/ tree.
#
# Usage:
# bash scripts/download_models.sh [models_dir]
#
# Defaults to ./models relative to the UmbraAudio repo root.
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
MODELS_DIR="${1:-$REPO_ROOT/models}"
mkdir -p "$MODELS_DIR"
# Qwen3-TTS models used by the configured engines (see umbraaudio/config.py and .env).
huggingface-cli download "Qwen/Qwen3-TTS-12Hz-0.6B-Base" --local-dir "$MODELS_DIR/Qwen3-TTS-12Hz-0.6B-Base"
huggingface-cli download "Qwen/Qwen3-TTS-12Hz-0.6B-CustomVoice" --local-dir "$MODELS_DIR/Qwen3-TTS-12Hz-0.6B-CustomVoice"
huggingface-cli download "Qwen/Qwen3-TTS-12Hz-1.7B-Base" --local-dir "$MODELS_DIR/Qwen3-TTS-12Hz-1.7B-Base"
huggingface-cli download "Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice" --local-dir "$MODELS_DIR/Qwen3-TTS-12Hz-1.7B-CustomVoice"
huggingface-cli download "Qwen/Qwen3-TTS-12Hz-1.7B-VoiceDesign" --local-dir "$MODELS_DIR/Qwen3-TTS-12Hz-1.7B-VoiceDesign"
huggingface-cli download "Qwen/Qwen3-TTS-Tokenizer-12Hz" --local-dir "$MODELS_DIR/Qwen3-TTS-Tokenizer-12Hz"
echo "Models downloaded to $MODELS_DIR"

View File

@@ -0,0 +1,201 @@
#!/usr/bin/env python3
"""Extract Qwen TTS speaker embeddings from existing voice reference WAVs.
Usage:
python scripts/extract_embeddings.py
python scripts/extract_embeddings.py --speaker narrator
python scripts/extract_embeddings.py --force # re-extract all
python scripts/extract_embeddings.py --design "A deep male voice" --slug new_speaker
Loads the Base model once, iterates over voice ref WAVs in .umbra/voices/,
extracts the 2048-dim speaker embedding via Qwen TTS's internal encoder,
and saves each as a .npy file in .umbra/voice_embeddings/.
"""
from __future__ import annotations
import argparse
import os
import sys
import time
from pathlib import Path
import numpy as np
import soundfile as sf
import torch
project_root = Path(__file__).resolve().parent.parent
if str(project_root) not in sys.path:
sys.path.insert(0, str(project_root))
from umbraaudio.config import load_config # noqa: E402
from umbraaudio.voices.qwen_embeddings import QwenEmbeddingCache # noqa: E402
BASE_MODEL_PATH = os.getenv(
"QWEN_BASE_MODEL",
"/Volumes/1TB_APL_Storage/UmbraAudio/models/Qwen3-TTS-12Hz-1.7B-Base",
)
VOICEDESIGN_MODEL_PATH = os.getenv(
"QWEN_VOICEDESIGN_MODEL",
"/Volumes/1TB_APL_Storage/UmbraAudio/models/Qwen3-TTS-12Hz-1.7B-VoiceDesign",
)
def extract_all_embeddings(
project_name: str,
speaker_filter: str | None = None,
force: bool = False,
) -> None:
config = load_config(project_name)
voices_dir = config.data_dir / "voices"
embeddings_dir = config.data_dir / "voice_embeddings"
if not voices_dir.is_dir():
print(f"ERROR: No voices directory at {voices_dir}")
return
cache = QwenEmbeddingCache(embeddings_dir)
wav_files = sorted(voices_dir.glob("*_ref.wav"))
print(f"Found {len(wav_files)} voice ref WAVs in {voices_dir}")
if speaker_filter:
wav_files = [w for w in wav_files if speaker_filter.lower() in w.stem.lower()]
print(f"Filtered to {len(wav_files)} WAVs matching '{speaker_filter}'")
to_extract = []
for wav_path in wav_files:
slug = wav_path.stem.replace("_ref", "")
if force or not cache.has(slug):
to_extract.append((slug, wav_path))
if not to_extract:
print("All embeddings already cached. Use --force to re-extract.")
return
print(f"Will extract {len(to_extract)} embeddings")
print("Loading Base model...")
from qwen_tts import Qwen3TTSModel
model = Qwen3TTSModel.from_pretrained(
BASE_MODEL_PATH,
device_map="mps",
dtype=torch.bfloat16,
)
print("Base model loaded")
extracted = 0
failed = 0
t0 = time.time()
for slug, wav_path in to_extract:
try:
audio, sr = sf.read(str(wav_path), dtype="float32")
if sr != 24000:
print(f" SKIP {slug}: sample rate {sr} != 24000")
failed += 1
continue
emb = cache.extract_and_save(
slug, audio, sr, model.model
)
print(
f" OK {slug}: shape={emb.shape}, "
f"norm={np.linalg.norm(emb):.2f}"
)
extracted += 1
except Exception as e:
print(f" FAIL {slug}: {e}")
failed += 1
elapsed = time.time() - t0
print(f"\nDone: {extracted} extracted, {failed} failed, {elapsed:.1f}s")
print(f"Cached embeddings: {cache.count()} total in {embeddings_dir}")
def create_embedding_from_design(
project_name: str,
instruct: str,
slug: str,
seed: int = 42,
force: bool = False,
) -> None:
"""Generate a voice via VoiceDesign, extract its embedding, and cache it."""
config = load_config(project_name)
embeddings_dir = config.data_dir / "voice_embeddings"
cache = QwenEmbeddingCache(embeddings_dir)
if cache.has(slug) and not force:
print(f"Embedding for '{slug}' already exists. Use --force to regenerate.")
return
print("Loading VoiceDesign model...")
from qwen_tts import Qwen3TTSModel
vd_model = Qwen3TTSModel.from_pretrained(
VOICEDESIGN_MODEL_PATH,
device_map="mps",
dtype=torch.bfloat16,
)
print("VoiceDesign model loaded")
test_text = "When the sunlight strikes raindrops in the air, they act as a prism."
print(f"Generating voice with instruct: {instruct[:80]}...")
wavs, sr = vd_model.generate_voice_design(
text=test_text,
instruct=instruct,
language="english",
temperature=0.5,
do_sample=True,
seed=seed,
)
audio = wavs[0]
if isinstance(audio, list):
audio = audio[0]
audio_np = np.array(audio)
print(f"Generated {len(audio_np)/sr:.2f}s of audio")
del vd_model
torch.mps.empty_cache()
print("Loading Base model for embedding extraction...")
base_model = Qwen3TTSModel.from_pretrained(
BASE_MODEL_PATH,
device_map="mps",
dtype=torch.bfloat16,
)
print("Base model loaded")
emb = cache.extract_and_save(slug, audio_np, sr, base_model.model)
print(f"Saved embedding for '{slug}': shape={emb.shape}, norm={np.linalg.norm(emb):.2f}")
wav_path = embeddings_dir / f"{slug}_design_ref.wav"
sf.write(str(wav_path), audio_np, sr)
print(f"Saved reference audio to {wav_path}")
def main() -> None:
parser = argparse.ArgumentParser(
description="Extract Qwen TTS speaker embeddings from voice refs"
)
parser.add_argument(
"--project",
default="DealHunterShip_4.0e_whole-to-audio",
help="Project name (default: DealHunterShip_4.0e_whole-to-audio)",
)
parser.add_argument("--speaker", help="Extract only this speaker (substring match)")
parser.add_argument("--force", action="store_true", help="Re-extract even if cached")
parser.add_argument("--design", help="VoiceDesign instruct text (create new voice)")
parser.add_argument("--slug", help="Speaker slug for --design mode")
parser.add_argument("--seed", type=int, default=42, help="Random seed for --design")
args = parser.parse_args()
if args.design:
if not args.slug:
parser.error("--slug is required with --design")
create_embedding_from_design(args.project, args.design, args.slug, args.seed, args.force)
else:
extract_all_embeddings(args.project, args.speaker, args.force)
if __name__ == "__main__":
main()

210
scripts/fix_chapter6.py Normal file
View File

@@ -0,0 +1,210 @@
#!/usr/bin/env python3
"""Fix Chapter 6 (Salvage) utterance issues:
1. Split utterance 56 into 3 utterances with correct speakers
2. Fix utterance 145 - Kainda with pleading tone
3. Fix utterances 181-190 - Nanny/Kainda not Deal Hunter
4. Fix utterance 222 - Kainda not Nanny
5. Remove utterance 218 (scene break <<<>>>)
6. Fix utterance 354 - Willamina Wake with yelling tone
"""
from __future__ import annotations
import argparse
import sqlite3
import sys
from pathlib import Path
from typing import Any
sys.path.insert(0, str(Path(__file__).parent.parent))
from umbraaudio.config import load_config
CHAPTER_ID = 6
def get_utterances(conn: sqlite3.Connection) -> list[dict[str, Any]]:
rows = conn.execute(
"SELECT id, utterance_index, text, type, speaker_name, emotion, pace, instruct "
"FROM utterances WHERE chapter_id=? ORDER BY utterance_index",
(CHAPTER_ID,),
).fetchall()
return [dict(r) for r in rows]
def print_utterances(utts: list[dict[str, Any]], start: int = 0, end: int | None = None) -> None:
for u in utts[start:end]:
print(f" utt{u['utterance_index']:03d} [{u['type']}] {u['speaker_name']}: {u['text'][:80]}...")
def fix_chapter6(db_path: Path) -> None:
conn = sqlite3.connect(str(db_path))
conn.row_factory = sqlite3.Row
print("=== Current Chapter 6 utterances (key range) ===")
utts = get_utterances(conn)
print_utterances(utts, 50, 70)
print_utterances(utts, 140, 160)
print_utterances(utts, 180, 200)
print_utterances(utts, 210, 230)
print_utterances(utts, 350, 360)
print("\n=== Applying fixes ===")
# Fix 1: Split utterance 56 into 3 utterances
# Original utt 56: "The captain is changing course, and that was a missile he is firing at the pirates.
# As Niñera helps Kainda, they feel the impacts more, and the air pressure alarm sounds.
# The computer voice announces, 'Hull breach. Please put on emergency space suits.'"
# Should be:
# - utt 56: "The captain is changing course, and that was a missile he is firing at the pirates." -> Nanny
# - utt 57: "As Niñera helps Kainda, they feel the impacts more, and the air pressure alarm sounds." -> narration
# - utt 58: "The computer voice announces, 'Hull breach. Please put on emergency space suits.'" -> Deal Hunter (AI)
print("\n1. Splitting utterance 56...")
utt56 = conn.execute(
"SELECT id, text FROM utterances WHERE chapter_id=? AND utterance_index=56",
(CHAPTER_ID,),
).fetchone()
if utt56:
original_text = utt56["text"]
print(f" Original: {original_text[:100]}...")
# Split into three parts
part1 = "The captain is changing course, and that was a missile he is firing at the pirates."
part2 = "As Niñera helps Kainda, they feel the impacts more, and the air pressure alarm sounds."
part3 = 'The computer voice announces, "Hull breach. Please put on emergency space suits."'
# Update utt 56 to be part1 (Nanny)
conn.execute(
"UPDATE utterances SET text=?, speaker_name=?, type=? WHERE id=?",
(part1, "Nanny", "dialogue", utt56["id"]),
)
# Insert part2 as narration at index 57
conn.execute(
"INSERT INTO utterances (chapter_id, utterance_index, text, type, speaker_name, status) "
"VALUES (?, 57, ?, 'narration', 'narrator', 'pending')",
(CHAPTER_ID, part2),
)
# Insert part3 as dialogue (Deal Hunter - AI voice) at index 58
conn.execute(
"INSERT INTO utterances (chapter_id, utterance_index, text, type, speaker_name, status) "
"VALUES (?, 58, ?, 'dialogue', 'Deal Hunter', 'pending')",
(CHAPTER_ID, part3),
)
# Shift all utterances >= 57 up by 2
conn.execute(
"UPDATE utterances SET utterance_index = utterance_index + 2 "
"WHERE chapter_id=? AND utterance_index >= 57",
(CHAPTER_ID,),
)
print(" Split into 3 utterances: 56 (Nanny), 57 (narration), 58 (Deal Hunter)")
# Fix 2: Utterance 145 -> now at index 147 after shift, Kainda with pleading tone
print("\n2. Fixing utterance 145 (now ~147) - Kainda pleading...")
conn.execute(
"UPDATE utterances SET speaker_name=?, emotion=?, instruct=? "
"WHERE chapter_id=? AND text LIKE '%Help me, please.%' "
"AND utterance_index BETWEEN 145 AND 150",
("Kainda Walters Foster Murphy", "pleading", "Speak with desperate pleading. Urgent, emotional.",
CHAPTER_ID),
)
# Fix 3: Utterances 181-190 (now shifted by 2) - Nanny/Kainda not Deal Hunter
print("\n3. Fixing utterances 181-190 (now ~183-192) - Nanny/Kainda...")
# utt 181: "Hello." -> Nanny
conn.execute(
"UPDATE utterances SET speaker_name=? WHERE chapter_id=? AND text='Hello.' AND utterance_index BETWEEN 180 AND 195",
("Nanny", CHAPTER_ID),
)
# utt 186: "What ship is this?" -> Kainda
conn.execute(
"UPDATE utterances SET speaker_name=? WHERE chapter_id=? AND text='What ship is this?' AND utterance_index BETWEEN 180 AND 195",
("Kainda Walters Foster Murphy", CHAPTER_ID),
)
# utt 188: "You are on the Deal Hunter." -> Nanny
conn.execute(
"UPDATE utterances SET speaker_name=? WHERE chapter_id=? AND text='You are on the Deal Hunter.' AND utterance_index BETWEEN 180 AND 195",
("Nanny", CHAPTER_ID),
)
# utt 189: "Is there a rescue ship in the area looking for me?" -> Kainda
conn.execute(
"UPDATE utterances SET speaker_name=? WHERE chapter_id=? AND text LIKE '%rescue ship%' AND utterance_index BETWEEN 180 AND 195",
("Kainda Walters Foster Murphy", CHAPTER_ID),
)
# utt 190: "There are no rescue ships..." -> Nanny
conn.execute(
"UPDATE utterances SET speaker_name=? WHERE chapter_id=? AND text LIKE '%no rescue ships%' AND utterance_index BETWEEN 180 AND 195",
("Nanny", CHAPTER_ID),
)
# Fix 4: Utterance 211 (now ~213) - already Nanny, good. But 181-210 should be Nanny
print("\n4. Ensuring Nanny consistency from 181-210...")
# The robot says at 211: "That was a missile trying to disable our engines. Hold on." - already Nanny
# The robot says at 181 (Hello), 188 (You are on...), 190 (There are no rescue...), 211 (missile)
# Also need to fix 183-184, 186-187 if they are Deal Hunter
conn.execute(
"UPDATE utterances SET speaker_name='Nanny' WHERE chapter_id=? AND utterance_index BETWEEN 181 AND 210 "
"AND speaker_name='Deal Hunter' AND type='dialogue'",
(CHAPTER_ID,),
)
# Fix 5: Remove utterance 218 (scene break <<<>>>) - now at ~220
print("\n5. Removing scene break utterance (<<<>>>)...")
conn.execute(
"DELETE FROM utterances WHERE chapter_id=? AND text='<<<>>>' AND type='scene_break'",
(CHAPTER_ID,),
)
# Fix 6: Utterance 222 (now shifted) - should be Kainda not Nanny
print("\n6. Fixing utterance 222 - Kainda not Nanny...")
conn.execute(
"UPDATE utterances SET speaker_name=? WHERE chapter_id=? AND text LIKE '%Why did you accelerate%'",
("Kainda Walters Foster Murphy", CHAPTER_ID),
)
# Fix 7: Utterance 354 (now shifted) - Willamina Wake with yelling
print("\n7. Fixing utterance 354 - Willamina Wake yelling...")
conn.execute(
"UPDATE utterances SET speaker_name=?, emotion=?, instruct=? "
"WHERE chapter_id=? AND text LIKE '%Get the princess on board!%'",
("Willamina Wake", "yelling", "Speak with authority and command. Firm, unwavering tone. LOUD.",
CHAPTER_ID),
)
# Also fix the "The Doctor" utterances
print("\n8. Fixing 'The Doctor' utterances...")
conn.execute(
"UPDATE utterances SET speaker_name='The Doctor' WHERE chapter_id=? AND text LIKE '%robots are all wiped%'",
(CHAPTER_ID,),
)
conn.commit()
print("\n=== Verifying fixes ===")
utts = get_utterances(conn)
print_utterances(utts, 50, 70)
print_utterances(utts, 145, 160)
print_utterances(utts, 180, 200)
print_utterances(utts, 210, 230)
print_utterances(utts, 350, 365)
conn.close()
print("\n=== Done ===")
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Fix Chapter 6 (Salvage) utterance issues")
parser.add_argument(
"--project", type=str, default=None, help="Project name or path"
)
args = parser.parse_args()
cfg = load_config(args.project)
fix_chapter6(cfg.db_path)

View File

@@ -0,0 +1,429 @@
#!/usr/bin/env python3
"""Background chapter audio generation with Qwen3-TTS.
Run as: nohup python scripts/generate_all_audio.py [--project NAME] > generation.log 2>&1 &
"""
from __future__ import annotations
import json
import logging
import sqlite3
import sys
import time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent))
from umbraaudio.config import load_config
from umbraaudio.generation.audio_effects import (
apply_effects_to_file,
apply_effects_to_file_by_name,
load_speaker_distortion_effects,
load_speaker_traits,
)
from umbraaudio.generation.staleness import is_current, write_marker
from umbraaudio.pronunciation import apply_phonetics, load_phonetic_names
from umbraaudio.voices.lmstudio_lifecycle import get_vram_usage_mb
from umbraaudio.voices.qwen_tts import VOICE_REFERENCE_TEXT, QwenTTSEngine
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(message)s",
handlers=[
logging.StreamHandler(),
],
)
logger = logging.getLogger(__name__)
def load_voice_references(voices_dir: Path) -> dict[str, Path]:
refs: dict[str, Path] = {}
if not voices_dir.exists():
return refs
for f in voices_dir.rglob("*_ref.wav"):
name = f.stem.replace("_ref", "").replace("_", " ")
if name not in refs:
refs[name] = f
return refs
def load_speakers(data_dir: Path) -> dict[str, dict]:
path = data_dir / "speakers.json"
if not path.exists():
return {}
with open(path) as f:
data = json.load(f)
return {s["primary_name"]: s for s in data.get("speakers", [])}
def get_speaker_name_map(speakers: dict[str, dict]) -> dict[str, str]:
alias_map: dict[str, str] = {}
for name, info in speakers.items():
alias_map[name.lower()] = name
for alias in info.get("aliases", []):
if alias:
alias_map[alias.lower()] = name
return alias_map
def resolve_reference(
speaker_name: str,
voice_refs: dict[str, Path],
alias_map: dict[str, str],
narrator_ref: Path | None = None,
) -> Path | None:
if speaker_name in voice_refs:
return voice_refs[speaker_name]
low = speaker_name.lower()
for name, path in voice_refs.items():
if name.lower() == low:
return path
canonical = alias_map.get(low)
if canonical:
for name, path in voice_refs.items():
if name.lower() == canonical.lower():
return path
first_word = low.split()[0] if low.split() else ""
if first_word:
matches = [p for n, p in voice_refs.items() if n.lower().split()[:1] == [first_word]]
if len(matches) == 1:
return matches[0]
best = None
best_len = -1
for name, path in voice_refs.items():
nlow = name.lower()
if (nlow in low or low in nlow) and len(nlow) > best_len:
best = path
best_len = len(nlow)
if best is not None:
return best
return narrator_ref
def generate_chapter(
chapter_id: int,
engine: QwenTTSEngine,
voice_refs: dict[str, Path],
alias_map: dict[str, str],
output_base: Path,
db_path: Path,
narrator_ref: Path | None = None,
phonetic: dict[str, str] | None = None,
speaker_traits: dict[str, list[str]] | None = None,
distortion_effects: dict[str, list[str]] | None = None,
utterance_callback=None,
progress_callback=None,
progress_context=None,
) -> tuple[int, int]:
db = sqlite3.connect(str(db_path))
db.row_factory = sqlite3.Row
chapter = db.execute("SELECT * FROM chapters WHERE id = ?", (chapter_id,)).fetchone()
if not chapter:
return 0, 0
title = chapter["title"] or f"Chapter {chapter['chapter_index'] + 1}"
utterances = db.execute(
"SELECT id, speaker_name, text, utterance_index FROM utterances "
"WHERE chapter_id = ? ORDER BY utterance_index",
(chapter_id,),
).fetchall()
db.close()
chapter_dir = output_base / f"ch{chapter_id}"
chapter_dir.mkdir(parents=True, exist_ok=True)
total = len(utterances)
skipped = 0
generated = 0
failed = 0
for utt in utterances:
speaker = utt["speaker_name"] or "narrator"
text = utt["text"]
idx = utt["utterance_index"]
if not text or not text.strip():
skipped += 1
if utterance_callback:
utterance_callback()
continue
if text.strip() == "<<<>>>":
skipped += 1
if utterance_callback:
utterance_callback()
continue
output_path = chapter_dir / f"utt_{idx:04d}.wav"
ref_path = resolve_reference(speaker, voice_refs, alias_map, narrator_ref)
spoken_text = apply_phonetics(text, phonetic or {})
if is_current(spoken_text, output_path, speaker=speaker, voice_ref=ref_path):
skipped += 1
if utterance_callback:
utterance_callback()
continue
utt_success = False
try:
if ref_path:
engine.generate(
text=spoken_text,
output_path=output_path,
voice_ref=ref_path,
ref_text=VOICE_REFERENCE_TEXT,
)
else:
engine.generate(
text=spoken_text,
output_path=output_path,
instruct="A calm, neutral voice speaking clearly.",
)
if output_path.exists():
explicit = (distortion_effects or {}).get(speaker, [])
if explicit:
apply_effects_to_file_by_name(output_path, explicit)
elif speaker_traits:
traits = speaker_traits.get(speaker, [])
if traits:
apply_effects_to_file(output_path, traits)
write_marker(spoken_text, output_path, speaker=speaker, voice_ref=ref_path)
generated += 1
utt_success = True
else:
failed += 1
except Exception as e:
logger.error(" FAILED utt_%04d (%s): %s", idx, speaker, e)
failed += 1
if utterance_callback:
utterance_callback(success=utt_success)
if progress_callback and progress_context:
progress_callback(progress_context["completed"], progress_context["total"], title)
if (generated + failed) % 50 == 0 and generated > 0:
elapsed_min = (generated + failed) * 6.4 / 60
logger.info(
" Progress: %d/%d done (%d gen, %d fail), ETA %.0f min",
generated + failed,
total,
generated,
failed,
elapsed_min,
)
return generated, failed
def main(status_callback=None, gen_manager=None, project: str | None = None):
cfg = load_config(project)
data_dir = cfg.data_dir
file_handler = logging.FileHandler(data_dir / "generation.log")
file_handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s"))
logger.addHandler(file_handler)
voices_dir = cfg.voices_dir
output_base = data_dir / "chapter_audio"
output_base.mkdir(parents=True, exist_ok=True)
voice_refs = load_voice_references(voices_dir)
speakers = load_speakers(data_dir)
alias_map = get_speaker_name_map(speakers)
phonetic = load_phonetic_names(data_dir)
speaker_traits = load_speaker_traits(data_dir / "speakers.json")
distortion_effects = load_speaker_distortion_effects(
data_dir / "speakers.json"
)
narrator_ref = None
for name, path in voice_refs.items():
if name.lower() == "narrator":
narrator_ref = path
break
if not narrator_ref:
for name, path in voice_refs.items():
if name.lower() == "kainda":
narrator_ref = path
break
logger.info("=== Qwen3-TTS Background Generation ===")
logger.info("Voice references: %d", len(voice_refs))
logger.info("Speakers: %d", len(speakers))
logger.info("Confirmed phonetic overrides: %d", len(phonetic))
from umbraaudio.voices.model_coordinator import ModelCoordinator, PipelineStage
coordinator = ModelCoordinator(cfg)
if not coordinator.switch_to(PipelineStage.UTTERANCE_GENERATION).get("ok"):
logger.error("Failed to switch models to UTTERANCE_GENERATION stage")
sys.exit(1)
vram = get_vram_usage_mb()
logger.info("VRAM: %dMB used, %dMB free", vram["used_mb"], vram["free_mb"])
db = sqlite3.connect(str(cfg.db_path))
db.row_factory = sqlite3.Row
chapters = db.execute(
"SELECT id, title, chapter_index FROM chapters WHERE project_id=1 ORDER BY chapter_index"
).fetchall()
total_utterances = db.execute("SELECT COUNT(*) as cnt FROM utterances").fetchone()["cnt"]
db.close()
total_chapters = len(chapters)
engine = QwenTTSEngine(device="cuda", sample_rate=24000, qwen_tts_url=cfg.tts.qwen_tts_url)
engine.load_remote_model(model_type="base")
total_gen = 0
total_fail = 0
completed_utterances = 0
start = time.time()
if status_callback:
status_callback(
"generate",
"running",
"Starting audio generation...",
0,
total_chapters=total_chapters,
chapters_completed=[],
)
def _utterance_done(success=True):
nonlocal completed_utterances
completed_utterances += 1
if gen_manager is not None:
gen_manager.update_item(f"utt:{completed_utterances}", success=success)
def _progress_tick(completed, total, ch_title):
if status_callback:
progress_pct = int((completed_utterances / max(total_utterances, 1)) * 100)
status_callback(
"generate",
"running",
f"Chapter {i + 1}/{total_chapters}: {ch_title}",
progress_pct,
total_chapters=total_chapters,
chapters_completed=list(range(i)),
current_chapter=chapters[i]["id"],
completed_utterances=completed_utterances,
total_utterances=total_utterances,
)
batch_total = total_utterances
_gen_ctx = None
if gen_manager is not None:
_gen_ctx = gen_manager.serialize("audio", batch_total, "audio:generate")
_gen_ctx.__enter__()
try:
for i, ch in enumerate(chapters):
ch_id = ch["id"]
ch_title = ch["title"] or f"Ch{ch['chapter_index'] + 1}"
if status_callback:
progress_pct = int((completed_utterances / max(total_utterances, 1)) * 100)
status_callback(
"generate",
"running",
f"Chapter {i + 1}/{total_chapters}: {ch_title}",
progress_pct,
total_chapters=total_chapters,
chapters_completed=list(range(i)),
current_chapter=ch_id,
completed_utterances=completed_utterances,
total_utterances=total_utterances,
)
progress_ctx = {"completed": completed_utterances, "total": total_utterances}
gen, fail = generate_chapter(
ch_id,
engine,
voice_refs,
alias_map,
output_base,
cfg.db_path,
narrator_ref,
phonetic=phonetic,
speaker_traits=speaker_traits,
distortion_effects=distortion_effects,
utterance_callback=_utterance_done,
progress_callback=_progress_tick,
progress_context=progress_ctx,
)
completed_utterances = progress_ctx["completed"]
total_gen += gen
total_fail += fail
ch_dir = output_base / f"ch{ch_id}"
full_wav = output_base / f"ch{ch_id}_full.wav"
utt_files = sorted(ch_dir.glob("utt_*.wav")) if ch_dir.exists() else []
needs_stitch = True
if full_wav.exists() and utt_files:
newest_utt = max(f.stat().st_mtime for f in utt_files)
needs_stitch = full_wav.stat().st_mtime < newest_utt
if utt_files and needs_stitch:
try:
from umbraaudio.assembly import AudioStitcher
stitcher = AudioStitcher()
stitcher.stitch_utterances(utt_files, full_wav)
n_files = len(utt_files)
logger.info(" Stitched ch%d -> %s (%d files)", ch_id, full_wav.name, n_files)
except Exception as e:
logger.error(" Stitch failed for ch%d: %s", ch_id, e)
elapsed = (time.time() - start) / 60
logger.info(
"Ch %d (%s): +%d gen, +%d fail | Total: %d gen, %d fail | %.1f min elapsed",
ch_id,
ch_title,
gen,
fail,
total_gen,
total_fail,
elapsed,
)
finally:
if _gen_ctx is not None:
_gen_ctx.__exit__(None, None, None)
engine.unload_all()
coordinator.switch_to(PipelineStage.IDLE)
elapsed = (time.time() - start) / 60
logger.info(
"=== COMPLETE: %d generated, %d failed, %.1f min total ===",
total_gen,
total_fail,
elapsed,
)
if status_callback:
status_callback(
"generate",
"completed",
f"Done: {total_gen} generated, {total_fail} failed in {elapsed:.1f} min",
100,
total_chapters=total_chapters,
chapters_completed=list(range(total_chapters)),
)
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(description="Background chapter audio generation")
parser.add_argument(
"--project", type=str, default=None, help="Project name or path"
)
args = parser.parse_args()
main(project=args.project)

View File

@@ -0,0 +1,258 @@
#!/usr/bin/env python3
"""Generate audio for chapters using Qwen3-TTS with voice cloning from references."""
from __future__ import annotations
import json
import logging
import sqlite3
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent))
from umbraaudio.config import load_config
from umbraaudio.pronunciation import load_phonetic_names
from umbraaudio.voices.lmstudio_lifecycle import get_loaded_model, get_vram_usage_mb, is_running
from umbraaudio.voices.model_coordinator import ModelCoordinator, PipelineStage
from umbraaudio.voices.qwen_tts import VOICE_REFERENCE_TEXT, QwenTTSEngine
logger = logging.getLogger(__name__)
def load_voice_references(voices_dir: Path) -> dict[str, Path]:
"""Load voice reference files, mapping character name -> ref audio path."""
refs: dict[str, Path] = {}
if not voices_dir.exists():
return refs
for f in voices_dir.glob("*_ref.wav"):
name = f.stem.replace("_ref", "").replace("_", " ")
refs[name] = f
return refs
def load_speakers(data_dir: Path) -> dict[str, dict]:
"""Load speakers from JSON file."""
path = data_dir / "speakers.json"
if not path.exists():
return {}
with open(path) as f:
data = json.load(f)
return {s["primary_name"]: s for s in data.get("speakers", [])}
def get_speaker_name_map(speakers: dict[str, dict]) -> dict[str, str]:
"""Build a mapping from all aliases to primary name."""
alias_map: dict[str, str] = {}
for name, info in speakers.items():
alias_map[name.lower()] = name
for alias in info.get("aliases", []):
alias_map[alias.lower()] = name
return alias_map
def _apply_phonetics(text: str, phonetic: dict[str, str]) -> str:
if not phonetic:
return text
for name, phonetic_spelling in sorted(phonetic.items(), key=lambda x: -len(x[0])):
text = text.replace(name, phonetic_spelling)
return text
def resolve_reference(
speaker_name: str,
voice_refs: dict[str, Path],
alias_map: dict[str, str],
narrator_ref: Path | None = None,
) -> Path | None:
"""Find the voice reference file for a speaker."""
if speaker_name in voice_refs:
return voice_refs[speaker_name]
for name, path in voice_refs.items():
if name.lower() == speaker_name.lower():
return path
canonical = alias_map.get(speaker_name.lower())
if canonical and canonical in voice_refs:
return voice_refs[canonical]
return narrator_ref
def generate_chapter_audio(
chapter_id: int,
engine: QwenTTSEngine,
voice_refs: dict[str, Path],
alias_map: dict[str, str],
output_base: Path,
db_path: Path,
narrator_ref: Path | None = None,
) -> int:
"""Generate audio for all utterances in a chapter using voice cloning."""
db = sqlite3.connect(str(db_path))
db.row_factory = sqlite3.Row
chapter = db.execute("SELECT * FROM chapters WHERE id = ?", (chapter_id,)).fetchone()
if not chapter:
logger.error("Chapter %d not found", chapter_id)
return 0
title = chapter["title"] or f"Chapter {chapter['chapter_index']+1}"
logger.info("Generating audio for: %s", title)
utterances = db.execute(
"""
SELECT u.id, u.speaker_name, u.text, u.utterance_index
FROM utterances u
WHERE u.chapter_id = ?
ORDER BY u.utterance_index
""",
(chapter_id,),
).fetchall()
logger.info("Found %d utterances", len(utterances))
chapter_dir = output_base / f"ch{chapter_id}"
chapter_dir.mkdir(parents=True, exist_ok=True)
generated = 0
for utt in utterances:
utt_id = utt["id"]
speaker = utt["speaker_name"] or "narrator"
text = utt["text"]
idx = utt["utterance_index"]
if not text or not text.strip():
continue
ref_path = resolve_reference(speaker, voice_refs, alias_map, narrator_ref)
filename = f"utt_{idx:04d}.wav"
output_path = chapter_dir / filename
if output_path.exists():
generated += 1
continue
spoken_text = _apply_phonetics(text, phonetic)
try:
if ref_path:
engine.generate(
text=spoken_text,
output_path=output_path,
voice_ref=ref_path,
ref_text=VOICE_REFERENCE_TEXT,
)
else:
engine.generate(
text=text,
output_path=output_path,
instruct="A calm, neutral voice speaking clearly.",
)
if output_path.exists():
generated += 1
if generated % 20 == 0:
logger.info(" Generated %d/%d utterances", generated, len(utterances))
else:
logger.warning(" Failed: utt_%04d (%s)", idx, speaker)
except Exception as e:
logger.error(" Error: utt_%04d (%s): %s", idx, speaker, e)
logger.info("Generated %d/%d utterances for %s", generated, len(utterances), title)
db.close()
return generated
def main():
import argparse
parser = argparse.ArgumentParser(description="Generate chapter audio with Qwen3-TTS")
parser.add_argument("chapters", nargs="*", type=int, help="Chapter IDs to generate")
parser.add_argument("--force", action="store_true", help="Overwrite existing audio")
parser.add_argument("--all", action="store_true", help="Generate all chapters")
parser.add_argument(
"--project", type=str, default=None, help="Project name or path"
)
args = parser.parse_args()
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
cfg = load_config(args.project)
coordinator = ModelCoordinator(cfg)
if not coordinator.switch_to(PipelineStage.UTTERANCE_GENERATION).get("ok"):
logger.error("Failed to switch models to UTTERANCE_GENERATION stage")
sys.exit(1)
voices_dir = cfg.voices_dir
voice_refs = load_voice_references(voices_dir)
speakers = load_speakers(cfg.data_dir)
alias_map = get_speaker_name_map(speakers)
phonetic = load_phonetic_names(cfg.data_dir)
logger.info("Loaded %d confirmed phonetic name mappings", len(phonetic))
narrator_ref = None
for name, path in voice_refs.items():
if name.lower() == "narrator":
narrator_ref = path
break
if not narrator_ref:
for name, path in voice_refs.items():
if name.lower() == "kainda":
narrator_ref = path
break
logger.info("Loaded %d voice references", len(voice_refs))
logger.info("Loaded %d speakers", len(speakers))
vram = get_vram_usage_mb()
logger.info("VRAM: %dMB used, %dMB free", vram["used_mb"], vram["free_mb"])
if is_running():
model = get_loaded_model()
if model:
logger.warning("LM Studio has model loaded: %s", model)
logger.warning("TTS needs ~4GB VRAM. If OOM occurs, unload the model in LM Studio UI.")
engine = QwenTTSEngine(device="cuda", sample_rate=24000)
output_base = cfg.data_dir / "chapter_audio"
output_base.mkdir(parents=True, exist_ok=True)
if args.all:
db = sqlite3.connect(str(cfg.db_path))
db.row_factory = sqlite3.Row
chapters = db.execute(
"SELECT id FROM chapters WHERE project_id=1 ORDER BY chapter_index"
).fetchall()
db.close()
chapter_ids = [c["id"] for c in chapters]
elif args.chapters:
chapter_ids = args.chapters
else:
chapter_ids = [5]
if args.force:
for ch_id in chapter_ids:
ch_dir = output_base / f"ch{ch_id}"
if ch_dir.exists():
for f in ch_dir.glob("*.wav"):
f.unlink()
logger.info("Cleared existing audio for chapter %d", ch_id)
total_generated = 0
for ch_id in chapter_ids:
count = generate_chapter_audio(
ch_id, engine, voice_refs, alias_map, output_base, cfg.db_path, narrator_ref
)
total_generated += count
engine.unload_all()
coordinator.switch_to(PipelineStage.IDLE)
logger.info("Total generated: %d utterances across %d chapters", total_generated, len(chapter_ids))
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,163 @@
#!/usr/bin/env python3
"""Generate longer voice references (30+ seconds) for speakers using VoiceDesign."""
from __future__ import annotations
import sys
import time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent))
import io
import httpx
import soundfile as sf
from umbraaudio.config import load_config
TTS_URL = "http://localhost:8881"
# Rainbow Passage - phonetically balanced paragraph for voice samples
# Source: Grant Fairbanks, Voice and Articulation Drillbook (1960)
# Covers all English phonemes including r-colored vowels
# Repeated to generate 30+ seconds of speech for voice cloning references
VOICE_REFERENCE_TEXT = """
When the sunlight strikes raindrops in the air, they act as a prism and form a rainbow. The rainbow is a division of white light into many beautiful colors. These take the shape of a long round arch, with its path high above, and its two ends apparently beyond the horizon. There is, according to legend, a boiling pot of gold at one end. People look, but no one ever finds it. When a man looks for something beyond his reach, his friends say he is looking for the pot of gold at the end of the rainbow.
When the sunlight strikes raindrops in the air, they act as a prism and form a rainbow. The rainbow is a division of white light into many beautiful colors. These take the shape of a long round arch, with its path high above, and its two ends apparently beyond the horizon. There is, according to legend, a boiling pot of gold at one end. People look, but no one ever finds it. When a man looks for something beyond his reach, his friends say he is looking for the pot of gold at the end of the rainbow.
""".strip()
# Speaker descriptions for VoiceDesign
SPEAKER_DESCRIPTIONS = {
"narrator": (
"A professional male audiobook narrator with a deep, clear, "
"authoritative voice. Calm and measured delivery."
),
"captain_weston": (
"A confident male ship captain in his 40s with a commanding but "
"warm voice. Authoritative yet approachable."
),
"brutus": ("A gruff male voice with a deep, gravelly tone. Tough and no-nonsense demeanor."),
"alec": (
"A young male voice in his 20s with an energetic and friendly tone. Slightly higher pitch."
),
"admiral_wilbur_wake": (
"An older male voice with a distinguished, formal tone. Commanding presence with wisdom."
),
"captain_wolfgang_antlion": (
"A stern male voice with a German accent. Precise and formal military bearing."
),
"ai-2": (
"A synthesized female AI voice. Clear, precise, and slightly "
"robotic with good pronunciation."
),
"aunt_vicky": ("A warm, motherly female voice in her 50s. Caring and gentle tone."),
"duchess_victoria": (
"An elegant female voice with refined pronunciation. Aristocratic and poised."
),
"gisfrid": (
"A middle-aged male voice with a scholarly tone. Intelligent and measured speaking style."
),
}
def generate_voice_reference(
speaker_name: str,
description: str,
output_path: Path,
text: str = VOICE_REFERENCE_TEXT,
) -> bool:
"""Generate a voice reference using VoiceDesign."""
print(f"Generating voice reference for: {speaker_name}")
try:
response = httpx.post(
f"{TTS_URL}/v1/tts",
data={
"text": text,
"voice_description": description,
"sample_rate": 24000,
},
timeout=600.0,
)
if response.status_code != 200:
print(f" Error: HTTP {response.status_code}")
return False
# Parse the WAV response properly (server returns a WAV file, not raw PCM)
wav, sr = sf.read(io.BytesIO(response.content), dtype="float32")
sf.write(str(output_path), wav, sr)
# Check duration
info = sf.info(str(output_path))
print(f" Generated: {info.duration:.1f}s, {output_path.stat().st_size} bytes")
return info.duration >= 20.0 # At least 20 seconds
except Exception as e:
print(f" Error: {e}")
return False
def main():
import argparse
parser = argparse.ArgumentParser(description="Generate longer voice references")
parser.add_argument(
"--project",
type=str,
default=None,
help="Project name or path",
)
parser.add_argument(
"--speakers",
type=str,
default=None,
help="Comma-separated speaker names (e.g. 'narrator,captain_weston')",
)
parser.add_argument(
"--all", action="store_true", help="Generate for all speakers with descriptions"
)
args = parser.parse_args()
cfg = load_config(args.project)
voices_dir = cfg.voices_dir
voices_dir.mkdir(parents=True, exist_ok=True)
if args.all:
speakers = SPEAKER_DESCRIPTIONS
elif args.speakers:
names = [s.strip() for s in args.speakers.split(",")]
speakers = {n: SPEAKER_DESCRIPTIONS.get(n, f"A clear voice for {n}") for n in names}
else:
print("Specify --speakers or --all")
return
success = 0
failed = 0
for speaker_name, description in speakers.items():
output_path = voices_dir / f"{speaker_name}_ref.wav"
# Skip if already exists and is long enough
if output_path.exists():
info = sf.info(str(output_path))
if info.duration >= 25.0:
print(f"Skipping {speaker_name}: already {info.duration:.1f}s")
success += 1
continue
if generate_voice_reference(speaker_name, description, output_path):
success += 1
else:
failed += 1
time.sleep(1) # Rate limiting
print(f"\nDone: {success} succeeded, {failed} failed")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,223 @@
#!/usr/bin/env python3
"""Generate test samples for all speakers using Edge-TTS with speaker attributes."""
from __future__ import annotations
import asyncio
import json
import sys
import time
from pathlib import Path
# Add project root to path
sys.path.insert(0, str(Path(__file__).parent.parent))
from umbraaudio.config import load_config
from umbraaudio.voices.edge_tts import EdgeTTSEngine
# Rainbow Passage - phonetically balanced paragraph for voice samples
# Source: Grant Fairbanks, Voice and Articulation Drillbook (1960)
# Covers all English phonemes including r-colored vowels
VERIFICATION_PHRASE = (
"When the sunlight strikes raindrops in the air, they act as a prism "
"and form a rainbow. The rainbow is a division of white light into many "
"beautiful colors. These take the shape of a long round arch, with its "
"path high above, and its two ends apparently beyond the horizon. There "
"is, according to legend, a boiling pot of gold at one end. People look, "
"but no one ever finds it. When a man looks for something beyond his "
"reach, his friends say he is looking for the pot of gold at the end of "
"the rainbow."
)
def _map_traits_to_energy(traits: list[str]) -> str:
"""Map vocal traits to energy level."""
traits_lower = [t.lower() for t in traits]
if any(t in traits_lower for t in ["explosive", "thunderous", "shouting"]):
return "explosive"
if any(t in traits_lower for t in ["volatile", "intense", "agitated", "emotional"]):
return "volatile"
if any(t in traits_lower for t in ["commanding", "authoritative", "powerful", "dominant"]):
return "commanding"
if any(t in traits_lower for t in ["assertive", "confident", "firm", "strong"]):
return "assertive"
if any(t in traits_lower for t in ["measured", "even", "controlled", "steady"]):
return "measured"
if any(t in traits_lower for t in ["soft", "gentle", "soft_spoken", "quiet"]):
return "soft_spoken"
if any(t in traits_lower for t in ["subdued", "muted", "restrained", "hushed"]):
return "subdued"
if any(t in traits_lower for t in ["whispered", "whisper", "barely_audible"]):
return "whispered"
return "measured"
def _map_traits_to_pitch(traits: list[str], gender: str) -> str:
"""Map vocal traits to pitch register."""
traits_lower = [t.lower() for t in traits]
if any(t in traits_lower for t in ["very_high", "ultra_high", "squeaky", "falsetto"]):
return "very_high" if gender == "Male" else "ultra_high"
if any(t in traits_lower for t in ["high", "high_pitched", "soprano"]):
return "high"
if any(t in traits_lower for t in ["mid_high", "slightly_high"]):
return "mid_high"
if any(t in traits_lower for t in ["mid", "medium", "moderate", "average"]):
return "mid"
if any(t in traits_lower for t in ["mid_low", "slightly_low"]):
return "mid_low"
if any(t in traits_lower for t in ["low", "deep", "bass", "baritone"]):
return "low"
if any(t in traits_lower for t in ["very_low", "very_deep"]):
return "very_low"
# Default based on gender
return "mid" if gender == "Male" else "mid_high"
def _extract_accent(traits: list[str], accent_field: str | None) -> str:
"""Extract accent from traits or accent field. Default to Canadian."""
if accent_field and accent_field.lower() not in ("none", "unknown", ""):
return accent_field.lower()
traits_lower = [t.lower() for t in traits]
accent_keywords = {
"british": ["british", "uk", "english", "cockney", "rp"],
"american": ["american", "us", "general_american"],
"australian": ["australian", "aussie"],
"indian": ["indian", "south_asian"],
"irish": ["irish", "irish_accent"],
"canadian": ["canadian", "canada"],
"nigerian": ["nigerian", "west_african"],
"kenyan": ["kenyan", "east_african"],
"hong_kong": ["hong_kong", "hk", "cantonese"],
}
for accent, keywords in accent_keywords.items():
if any(k in " ".join(traits_lower) for k in keywords):
return accent
return "canadian" # Default accent
def _generate_with_retry(engine: EdgeTTSEngine, text: str, output_path: Path, **kwargs) -> bool:
"""Generate audio with retry logic for transient Edge-TTS failures."""
max_retries = 3
base_delay = 2.0
for attempt in range(max_retries):
try:
engine.generate_voice_reference(
text=text,
output_path=output_path,
**kwargs,
)
if output_path.exists():
return True
except Exception as e:
if attempt == max_retries - 1:
raise
delay = base_delay * (2 ** attempt)
print(f" Retry {attempt + 1}/{max_retries} after {delay:.1f}s: {e}")
time.sleep(delay)
return False
async def generate_test_samples(project: str | None = None):
"""Generate test samples for all speakers using their vocal traits."""
cfg = load_config(project)
# Load voice assignments
assignments_path = cfg.data_dir / "voice_assignments.json"
with open(assignments_path) as f:
assignments = json.load(f)
# Load speakers for vocal traits
speakers_path = cfg.data_dir / "speakers.json"
with open(speakers_path) as f:
speakers_data = json.load(f)
speakers = {s["primary_name"]: s for s in speakers_data["speakers"]}
print(f"Generating test samples for {len(assignments)} speakers...")
print(f"Verification phrase: \"{VERIFICATION_PHRASE}\"")
print()
# Create output directory
output_dir = cfg.data_dir / "voice_tests"
output_dir.mkdir(exist_ok=True)
# Initialize Edge-TTS engine
engine = EdgeTTSEngine()
# Track results
success_count = 0
fail_count = 0
for speaker_name, voice_assignment in sorted(assignments.items()):
speaker = speakers.get(speaker_name, {})
demographics = speaker.get("demographics", {})
gender = demographics.get("gender", "Unknown")
stype = demographics.get("type", "Human")
traits = speaker.get("vocal_traits", [])
accent_field = speaker.get("accent")
# Map traits to Edge-TTS parameters
energy = _map_traits_to_energy(traits)
pitch = _map_traits_to_pitch(traits, gender)
accent = _extract_accent(traits, accent_field)
# Parse voice assignment for pitch shift (fallback)
if ":" in voice_assignment:
base_voice, pitch_shift = voice_assignment.split(":")
pitch_shift = int(pitch_shift)
else:
base_voice = voice_assignment
pitch_shift = 0
# Create safe filename
safe_name = speaker_name.replace(" ", "_").replace("/", "_")
output_path = output_dir / f"{safe_name}.wav"
# Skip if already exists
if output_path.exists():
success_count += 1
print(f"⊙ {speaker_name:35} -> already exists")
continue
try:
# Generate audio using speaker attributes (accent defaults to Canadian)
success = _generate_with_retry(
engine=engine,
text=VERIFICATION_PHRASE,
output_path=output_path,
energy=energy,
pitch=pitch,
gender=gender,
character_name=speaker_name,
accent=accent,
)
if success and output_path.exists():
success_count += 1
print(f"✓ {speaker_name:35} -> energy={energy}, pitch={pitch}, gender={gender}, accent={accent}")
else:
fail_count += 1
print(f"✗ {speaker_name:35} -> (file not created)")
except Exception as e:
fail_count += 1
print(f"✗ {speaker_name:35} -> (error: {e})")
# Small delay between requests to avoid rate limiting
time.sleep(0.5)
print("\n=== Summary ===")
print(f"Success: {success_count}")
print(f"Failed: {fail_count}")
print(f"Total: {len(assignments)}")
print(f"\nTest samples saved to: {output_dir}")
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(description="Generate test samples for all speakers")
parser.add_argument(
"--project", type=str, default=None, help="Project name or path"
)
args = parser.parse_args()
asyncio.run(generate_test_samples(project=args.project))

View File

@@ -0,0 +1,179 @@
from __future__ import annotations
import json
import logging
from pathlib import Path
from umbraaudio.config import load_config
from umbraaudio.voices.model_coordinator import ModelCoordinator, PipelineStage
from umbraaudio.voices.qwen_tts import QwenTTSEngine, build_voice_description
logger = logging.getLogger(__name__)
def _load_speakers(data_dir: Path) -> list[dict]:
path = data_dir / "speakers.json"
if not path.exists():
raise FileNotFoundError(f"speakers.json not found: {path}")
with open(path) as f:
data = json.load(f)
return data.get("speakers", [])
def _load_profiles(data_dir: Path) -> dict[str, dict]:
"""Load voice profiles from voice_profiles.json, falling back to project.db.
voice_profiles.json is the canonical source; it contains the full
voice_description for every speaker. The DB characters table may be
empty for some projects.
"""
profiles_path = data_dir / "voice_profiles.json"
if profiles_path.exists():
with open(profiles_path) as f:
data = json.load(f)
if data:
result = {}
for name, profile in data.items():
result[name] = {
"profile_json": json.dumps(profile),
"voice_description": profile.get("voice_description"),
}
return result
import sqlite3
db_path = data_dir / "project.db"
if not db_path.exists():
return {}
db = sqlite3.connect(str(db_path))
db.row_factory = sqlite3.Row
rows = db.execute(
"SELECT name, profile_json, voice_description FROM characters WHERE project_id=1"
).fetchall()
db.close()
result = {}
for r in rows:
result[r["name"]] = {
"profile_json": r["profile_json"],
"voice_description": r["voice_description"],
}
return result
def generate_all_voice_references(
project_dir: Path | None = None,
force: bool = False,
) -> None:
project_dir = project_dir or Path(".")
cfg = load_config(project_dir)
coordinator = ModelCoordinator(cfg)
if not coordinator.switch_to(PipelineStage.VOICE_DESIGN).get("ok"):
raise RuntimeError("Failed to switch models to VOICE_DESIGN stage")
speakers = _load_speakers(cfg.data_dir)
db_profiles = _load_profiles(cfg.data_dir)
voices_dir = cfg.data_dir / "voices"
voices_dir.mkdir(parents=True, exist_ok=True)
tts = QwenTTSEngine(
device=cfg.tts.device,
sample_rate=cfg.tts.sample_rate,
voicedesign_model=cfg.tts.voicedesign_model,
base_model=cfg.tts.base_model,
qwen_tts_url=cfg.tts.qwen_tts_url,
)
narrator_name = None
for s in speakers:
if s.get("is_narrator"):
narrator_name = s["primary_name"]
break
generated = 0
for s in speakers:
name = s["primary_name"]
is_narrator = s.get("is_narrator", False)
ref_name = name.lower().replace(" ", "_") + "_ref.wav"
ref_path = voices_dir / ref_name
if ref_path.exists() and not force:
logger.info("Skipping %s (exists)", name)
continue
db_info = db_profiles.get(name, {})
voice_desc_override = db_info.get("voice_description")
if voice_desc_override and not force:
voice_desc = voice_desc_override
elif is_narrator:
voice_desc = (
"A deep, resonant male voice with a calm, authoritative tone. "
"Rich baritone timbre, slow and deliberate pacing, "
"like a late-night radio host. Very controlled and even."
)
else:
profile_data = {}
if db_info.get("profile_json"):
try:
profile_data = json.loads(db_info["profile_json"])
except (json.JSONDecodeError, TypeError):
pass
voice_desc = build_voice_description(
character_name=name,
profile=profile_data if profile_data else None,
gender=s.get("gender") or (profile_data or {}).get("gender"),
energy=(profile_data or {}).get("energy"),
timbre=(profile_data or {}).get("timbre"),
pitch_register=(profile_data or {}).get("pitch_register"),
)
sample_text = _sample_text(name, is_narrator, s.get("demographics", {}))
logger.info("[%d/%d] %s: %s", generated + 1, len(speakers), name, voice_desc[:70])
try:
tts.generate_voice_reference(
text=sample_text,
output_path=ref_path,
voice_description=voice_desc,
)
generated += 1
logger.info(" -> %s", ref_path.name)
except Exception as e:
logger.error(" FAILED %s: %s", name, e)
tts.unload_all()
coordinator.switch_to(PipelineStage.IDLE)
logger.info("Generated %d/%d voice references", generated, len(speakers))
# Rainbow Passage - phonetically balanced paragraph for voice samples
# Source: Grant Fairbanks, Voice and Articulation Drillbook (1960)
# Covers all English phonemes including r-colored vowels
RAINBOW_PASSAGE = (
"When the sunlight strikes raindrops in the air, they act as a prism "
"and form a rainbow. The rainbow is a division of white light into many "
"beautiful colors. These take the shape of a long round arch, with its "
"path high above, and its two ends apparently beyond the horizon. There "
"is, according to legend, a boiling pot of gold at one end. People look, "
"but no one ever finds it. When a man looks for something beyond his "
"reach, his friends say he is looking for the pot of gold at the end of "
"the rainbow."
)
def _sample_text(name: str, is_narrator: bool, demographics: dict) -> str:
return RAINBOW_PASSAGE
if __name__ == "__main__":
import argparse
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
parser = argparse.ArgumentParser(description="Generate voice references for all speakers")
parser.add_argument(
"--project", type=str, default=None, help="Project name or path"
)
parser.add_argument("--force", action="store_true", help="Regenerate existing references")
args = parser.parse_args()
generate_all_voice_references(args.project, force=args.force)

View File

@@ -0,0 +1,145 @@
#!/usr/bin/env python3
"""Generate Rainbow Passage samples from cached speaker embeddings.
For each speaker in the given chapter, generates a WAV file of the Rainbow Passage
spoken in that character's voice. Used for human review/approval before full generation.
Usage:
python scripts/generate_voice_samples.py --chapter 15
python scripts/generate_voice_samples.py --speakers narrator,nanny,kainda
python scripts/generate_voice_samples.py --all # all speakers with embeddings
"""
from __future__ import annotations
import argparse
import json
import sqlite3
import sys
import time
from pathlib import Path
import numpy as np
import soundfile as sf
project_root = Path(__file__).resolve().parent.parent
if str(project_root) not in sys.path:
sys.path.insert(0, str(project_root))
from umbraaudio.config import load_config # noqa: E402
from umbraaudio.voices.qwen_embeddings import QwenEmbeddingCache # noqa: E402
RAINBOW_PASSAGE = (
"When the sunlight strikes raindrops in the air, they act as a prism and form a rainbow. "
"The rainbow is a division of white light into many beautiful colors. "
"These take the shape of a long round arch, with its path high above, "
"and its two ends apparently beyond the horizon. "
"There is, according to legend, a boiling pot of gold at one end. "
"People look, but no one ever finds it. "
"When a man looks for something beyond his reach, "
"his friends say he is looking for the pot of gold at the end of the rainbow. "
"When the sunlight strikes raindrops in the air, they act as a prism and form a rainbow. "
"The rainbow is a division of white light into many beautiful colors. "
"These take the shape of a long round arch, with its path high above, "
"and its two ends apparently beyond the horizon. "
"There is, according to legend, a boiling pot of gold at one end. "
"People look, but no one ever finds it. "
"When a man looks for something beyond his reach, "
"his friends say he is looking for the pot of gold at the end of the rainbow."
)
def name_to_slug(name: str) -> str:
return name.lower().replace(' ', '_').replace('.', '').replace("'", '').replace('(', '').replace(')', '').replace('-', '_').replace('/', '_')
def get_chapter_speakers(project_name: str, chapter_id: int) -> list[str]:
config = load_config(project_name)
db_path = config.data_dir / "project.db"
db = sqlite3.connect(str(db_path))
rows = db.execute('''
SELECT DISTINCT speaker_name FROM utterances
WHERE chapter_id=? AND speaker_name IS NOT NULL
''', (chapter_id,)).fetchall()
db.close()
return [r[0] for r in rows]
def generate_samples(
project_name: str,
speaker_names: list[str],
output_dir: Path,
temperature: float = 0.5,
seed: int = 42,
) -> list[Path]:
from umbraaudio.voices.qwen_tts import QwenTTSEngine
config = load_config(project_name)
embeddings_dir = config.data_dir / "voice_embeddings"
cache = QwenEmbeddingCache(embeddings_dir)
output_dir.mkdir(parents=True, exist_ok=True)
engine = QwenTTSEngine(qwen_tts_url="http://localhost:8881")
generated = []
for name in speaker_names:
slug = name_to_slug(name)
if not cache.has(slug):
print(f" SKIP {name}: no embedding cached (slug={slug})")
continue
out_path = output_dir / f"{slug}_rainbow.wav"
t0 = time.time()
# Use a dummy ref path — the engine will find the cached embedding
dummy_ref = config.data_dir / "voices" / f"{slug}_ref.wav"
if not dummy_ref.exists():
dummy_ref = config.data_dir / "voices" / "narrator_ref.wav"
engine.generate(
text=RAINBOW_PASSAGE,
output_path=out_path,
voice_ref=dummy_ref,
temperature=temperature,
seed=seed,
)
elapsed = time.time() - t0
size_kb = out_path.stat().st_size / 1024
print(f" OK {name:40s} -> {out_path.name:40s} ({elapsed:.1f}s, {size_kb:.0f}KB)")
generated.append(out_path)
return generated
def main() -> None:
parser = argparse.ArgumentParser(description="Generate voice samples for review")
parser.add_argument("--project", default="DealHunterShip_4.0e_whole-to-audio")
parser.add_argument("--chapter", type=int, help="Generate samples for all speakers in this chapter")
parser.add_argument("--speakers", help="Comma-separated speaker names")
parser.add_argument("--all", action="store_true", help="All speakers with embeddings")
parser.add_argument("--temperature", type=float, default=0.5)
parser.add_argument("--seed", type=int, default=42)
parser.add_argument("--output-dir", default=None, help="Output directory")
args = parser.parse_args()
if args.chapter:
speakers = get_chapter_speakers(args.project, args.chapter)
out_dir = Path(args.output_dir or f"/tmp/voice_samples_ch{args.chapter}")
print(f"Chapter {args.chapter}: {len(speakers)} speakers")
elif args.speakers:
speakers = [s.strip() for s in args.speakers.split(",")]
out_dir = Path(args.output_dir or "/tmp/voice_samples")
elif args.all:
config = load_config(args.project)
cache = QwenEmbeddingCache(config.data_dir / "voice_embeddings")
speakers = [s.replace("_", " ").title() for s in cache.list_cached()]
out_dir = Path(args.output_dir or "/tmp/voice_samples_all")
else:
parser.error("Specify --chapter, --speakers, or --all")
print(f"Output: {out_dir}")
t0 = time.time()
files = generate_samples(args.project, speakers, out_dir, args.temperature, args.seed)
elapsed = time.time() - t0
print(f"\nDone: {len(files)} samples in {elapsed:.1f}s")
if __name__ == "__main__":
main()

166
scripts/lmstudio_control.py Normal file
View File

@@ -0,0 +1,166 @@
#!/usr/bin/env python3
"""LM Studio model control script for UmbraAudio.
Usage:
python scripts/lmstudio_control.py status # Show LM Studio status
python scripts/lmstudio_control.py start # Start server
python scripts/lmstudio_control.py stop # Stop server
python scripts/lmstudio_control.py load [MODEL] # Load a model
python scripts/lmstudio_control.py unload # Unload current model
"""
from __future__ import annotations
import sys
from umbraaudio.voices.lmstudio_lifecycle import (
get_loaded_model,
is_running,
load_model,
server_status,
start_server,
stop_server,
unload_all,
)
PREFERRED_MODELS = [
"gemma-4-12b-it-qat",
"google/gemma-4-e4b",
"qwen3.5-9b",
]
def cmd_start():
if is_running():
print("LM Studio is already running")
return
start_server()
print("LM Studio server started")
def cmd_stop():
if not is_running():
print("LM Studio is not running")
return
stop_server()
print("LM Studio server stopped")
def cmd_unload():
if not is_running():
print("LM Studio is not running")
return
loaded = get_loaded_model()
if not loaded:
print("No model loaded")
return
print(f"Unloading {loaded}...")
if unload_all():
print("Unloaded successfully")
else:
print("Unload failed")
def cmd_load(model: str | None = None):
if not is_running():
print("LM Studio is not running. Use 'start' first.")
return
current = get_loaded_model()
if current:
if current == model or (not model and current in PREFERRED_MODELS):
print(f"Model already loaded: {current}")
return
print(f"Unloading {current}...")
unload_all()
if not model:
import json
import subprocess
result = subprocess.run(
["lms", "ls", "--json"],
capture_output=True, text=True, timeout=30,
)
if result.returncode == 0:
available = [
m["modelKey"] for m in json.loads(result.stdout)
if m.get("type") == "llm"
]
for preferred in PREFERRED_MODELS:
if preferred in available:
model = preferred
break
if not model:
print("No preferred model found and no model specified")
return
print(f"Loading {model}...")
if load_model(model):
print(f"Loaded successfully: {model}")
else:
print(f"Load failed: {model}")
def cmd_status():
status = server_status()
if not status["api_responding"]:
print("LM Studio is NOT running")
print("\nStart it with:")
print(" python scripts/lmstudio_control.py start")
return
print("LM Studio is running")
print(f" Server: {'running' if status['server_running'] else 'stopped'}")
models = status["loaded_models"]
if models:
print(f" Loaded: {models[0]}")
else:
print(" No model loaded")
import json
import subprocess
result = subprocess.run(
["lms", "ls", "--json"],
capture_output=True, text=True, timeout=30,
)
if result.returncode == 0:
available = [
m["modelKey"] for m in json.loads(result.stdout)
if m.get("type") == "llm"
]
if available:
print(f"\nAvailable models ({len(available)}):")
for m in available:
marker = " *" if models and m == models[0] else ""
preferred = " (recommended)" if m in PREFERRED_MODELS else ""
print(f" {m}{marker}{preferred}")
def main():
if len(sys.argv) < 2:
print(__doc__)
cmd_status()
return
cmd = sys.argv[1].lower()
if cmd == "status":
cmd_status()
elif cmd == "start":
cmd_start()
elif cmd == "stop":
cmd_stop()
elif cmd == "unload":
cmd_unload()
elif cmd == "load":
model = sys.argv[2] if len(sys.argv) > 2 else None
cmd_load(model)
else:
print(f"Unknown command: {cmd}")
print(__doc__)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,115 @@
"""CLI for the model coordinator.
Ensures only the model needed by the current pipeline stage is resident in
memory, freeing maximum GPU/RAM for whichever model is about to work.
Usage:
python scripts/model_coordinator.py status
python scripts/model_coordinator.py switch \\
voice_design|chapter_analysis|utterance_generation|idle
python scripts/model_coordinator.py unload-all
"""
from __future__ import annotations
import argparse
import json
import logging
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from rich.console import Console
from rich.table import Table
from umbraaudio.config import load_config
from umbraaudio.voices.model_coordinator import ModelCoordinator, PipelineStage
logger = logging.getLogger(__name__)
console = Console()
def _render_status(state: dict) -> None:
qwen_model = state.get("qwen_model") or "—"
lm_loaded = state.get("lm_loaded") or []
lm_str = ", ".join(lm_loaded) if lm_loaded else "—"
table = Table(title="Model Coordinator Status")
table.add_column("Server", style="bold cyan")
table.add_column("State", style="bold green")
table.add_row("Qwen TTS server", state.get("qwen_server", "unknown"))
table.add_row("Qwen current model", qwen_model)
table.add_row(" voicedesign", "loaded" if state.get("voicedesign_loaded") else "unloaded")
table.add_row(" base", "loaded" if state.get("base_loaded") else "unloaded")
table.add_row("LM Studio loaded", lm_str)
console.print(table)
def cmd_status(coord: ModelCoordinator, _args: argparse.Namespace) -> int:
_render_status(coord.status())
return 0
def cmd_switch(coord: ModelCoordinator, args: argparse.Namespace) -> int:
try:
stage = PipelineStage(args.stage)
except ValueError:
console.print(
f"[red]Unknown stage '{args.stage}'. "
f"Choose from: {', '.join(s.value for s in PipelineStage)}[/red]"
)
return 2
console.print(f"Switching to stage [bold]{stage.value}[/bold]...")
result = coord.switch_to(stage)
if not result.get("ok"):
console.print(f"[red]Failed: {result.get('error', 'unknown error')}[/red]")
console.print("Before:", json.dumps(result.get("before", {}), indent=2))
return 1
console.print("[green]OK[/green]")
_render_status(result["after"])
return 0
def cmd_unload_all(coord: ModelCoordinator, _args: argparse.Namespace) -> int:
console.print("Unloading all models...")
result = coord.switch_to(PipelineStage.IDLE)
if not result.get("ok"):
console.print(f"[red]Failed: {result.get('error', 'unknown error')}[/red]")
return 1
console.print("[green]All models unloaded[/green]")
_render_status(result["after"])
return 0
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Switch which models are resident for the current pipeline stage."
)
sub = parser.add_subparsers(dest="command", required=True)
p_status = sub.add_parser("status", help="Show which models are currently loaded")
p_status.set_defaults(func=cmd_status)
p_switch = sub.add_parser("switch", help="Switch to a pipeline stage's model set")
p_switch.add_argument(
"stage",
choices=[s.value for s in PipelineStage],
help="Which stage's models should be resident",
)
p_switch.set_defaults(func=cmd_switch)
p_unload = sub.add_parser("unload-all", help="Unload all models from both servers")
p_unload.set_defaults(func=cmd_unload_all)
return parser
def main(argv: list[str] | None = None) -> int:
logging.basicConfig(level=logging.WARNING, format="%(levelname)s %(message)s")
args = build_parser().parse_args(argv)
coord = ModelCoordinator(load_config())
return args.func(coord, args)
if __name__ == "__main__":
sys.exit(main())

224
scripts/nas_lifecycle.py Normal file
View File

@@ -0,0 +1,224 @@
#!/usr/bin/env python3
"""NAS sync + local prune lifecycle for UmbraAudio.
Primary store: local SSD. Secondary: NAS mirror. On each run:
1. ensure the NAS is mounted
2. rsync local -> NAS (mirror everything)
3. prune locally only what is verified present on the NAS,
respecting age and free-space targets
Fetch fallback for pruned files lives in umbraaudio.storage.
"""
from __future__ import annotations
import argparse
import json
import logging
import shutil
import subprocess
import sys
import time
from pathlib import Path
DEFAULT_LOCAL_ROOT = Path("/Volumes/1TB_APL_Storage/UmbraAudio")
DEFAULT_NAS_ROOT = Path("/Volumes/Umbra/UmbraAudio")
MOUNT_SCRIPT = Path.home() / ".umbra/mount_nas.sh"
CONFIG_PATH = Path.home() / ".umbra/nas_config.json"
RSYNC_EXCLUDES = [".git/", "node_modules/", ".idea/", "__pycache__/", ".venv/", ".python/"]
# Never pruned in normal mode: live code, config, and active TTS models
PROTECTED_DIRS = {"umbraaudio", "scripts", "tests", "specs", "Qwen3-TTS",
".umbra", ".git", ".venv", ".python", ".pytest_cache"}
DEFAULT_POLICY = {
"min_age_days": 30, # only prune files untouched this long
"free_target_gb": 150, # prune until at least this much is free (normal mode)
"emergency_free_gb": 40, # below this, protected dirs may be pruned too
}
logger = logging.getLogger("nas_lifecycle")
LOCK_FILE = Path("/tmp/umbra_nas_lifecycle.lock")
class Locked(RuntimeError):
pass
def load_policy() -> dict:
policy = dict(DEFAULT_POLICY)
try:
cfg = json.loads(CONFIG_PATH.read_text())
policy.update(cfg.get("prune_policy", {}))
except (OSError, json.JSONDecodeError):
pass
return policy
def nas_mounted(nas_root: Path = DEFAULT_NAS_ROOT) -> bool:
return nas_root.is_dir() and any(nas_root.iterdir())
def ensure_mounted() -> bool:
if nas_mounted():
return True
logger.info("NAS not mounted; running %s", MOUNT_SCRIPT)
try:
subprocess.run(["bash", str(MOUNT_SCRIPT)], check=True,
capture_output=True, timeout=120)
except (subprocess.SubprocessError, OSError) as e:
logger.error("mount failed: %s", e)
return False
return nas_mounted()
def sync(local_root: Path = DEFAULT_LOCAL_ROOT,
nas_root: Path = DEFAULT_NAS_ROOT) -> bool:
cmd = ["rsync", "-a", "--delete"]
for exc in RSYNC_EXCLUDES:
cmd += ["--exclude", exc]
cmd += [f"{local_root}/", f"{nas_root}/"]
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
logger.error("rsync failed (%d): %s", result.returncode, result.stderr[-500:])
return False
return True
def verified_on_nas(local_file: Path, local_root: Path,
nas_root: Path) -> bool:
mirror = nas_root / local_file.relative_to(local_root)
try:
ls, ln = local_file.stat(), mirror.stat()
except OSError:
return False
return ls.st_size == ln.st_size and int(ls.st_mtime) == int(ln.st_mtime)
def free_bytes(path: Path) -> int:
return shutil.disk_usage(path).free
def prune_plan(local_root: Path, nas_root: Path, policy: dict,
emergency: bool = False) -> list[Path]:
"""Files safe to delete now, largest-first among eligible."""
min_age_s = policy["min_age_days"] * 86400
cutoff = time.time() - min_age_s
candidates: list[tuple[int, Path]] = []
for p in local_root.rglob("*"):
if not p.is_file() or p.is_symlink():
continue
rel_parents = set(p.relative_to(local_root).parts[:-1])
if not emergency and rel_parents & PROTECTED_DIRS:
continue
st = p.stat()
if st.st_mtime > cutoff:
continue
if not verified_on_nas(p, local_root, nas_root):
continue
candidates.append((st.st_size, p))
candidates.sort(reverse=True)
return [p for _, p in candidates]
def prune(local_root: Path = DEFAULT_LOCAL_ROOT,
nas_root: Path = DEFAULT_NAS_ROOT,
policy: dict | None = None) -> dict:
policy = policy or load_policy()
target = policy["free_target_gb"] * 1024**3
emergency_at = policy["emergency_free_gb"] * 1024**3
freed = 0
deleted = 0
emergency = free_bytes(local_root) < emergency_at
for p in prune_plan(local_root, nas_root, policy, emergency=emergency):
if free_bytes(local_root) >= target and freed > 0:
break
size = p.stat().st_size
try:
p.unlink()
except OSError as e:
logger.warning("could not unlink %s: %s", p, e)
continue
freed += size
deleted += 1
parent = p.parent
if parent != local_root and not any(parent.iterdir()):
parent.rmdir()
stats = {"deleted": deleted, "freed_gb": round(freed / 1024**3, 2),
"emergency": emergency,
"free_gb": round(free_bytes(local_root) / 1024**3, 1)}
logger.info("prune: %s", stats)
return stats
def status(local_root: Path = DEFAULT_LOCAL_ROOT,
nas_root: Path = DEFAULT_NAS_ROOT) -> dict:
lf = lb = 0
for p in local_root.rglob("*"):
if p.is_file() and not p.is_symlink():
try:
lf += 1
lb += p.stat().st_size
except OSError:
pass
usage = shutil.disk_usage(nas_root) if nas_mounted(nas_root) else None
return {
"local": {"files": lf, "gb": round(lb / 1024**3, 2),
"free_gb": round(free_bytes(local_root) / 1024**3, 1)},
"nas": {"mounted": nas_mounted(nas_root),
"free_gb": round(usage.free / 1024**3, 1) if usage else None},
}
def _acquire_lock() -> int:
import os
fd = os.open(LOCK_FILE, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
os.write(fd, str(os.getpid()).encode())
return fd
def run_cycle(local_root: Path = DEFAULT_LOCAL_ROOT,
nas_root: Path = DEFAULT_NAS_ROOT) -> bool:
if not ensure_mounted():
return False
busy = subprocess.run(["pgrep", "-f",
f"rsync .*{nas_root}"], capture_output=True)
if busy.returncode == 0:
logger.info("another rsync to the NAS is running; deferring cycle")
return True
try:
_acquire_lock()
except FileExistsError:
logger.info("cycle already in progress; skipping")
return True
try:
if not sync():
return False
prune()
finally:
LOCK_FILE.unlink(missing_ok=True)
return True
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("command", choices=["sync", "prune", "status", "cycle"])
args = parser.parse_args(argv)
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
if args.command == "status":
print(json.dumps(status(), indent=2))
return 0
if args.command == "sync":
ok = ensure_mounted() and sync()
elif args.command == "prune":
print(json.dumps(prune(), indent=2))
return 0
else:
ok = run_cycle()
return 0 if ok else 1
if __name__ == "__main__":
raise SystemExit(main())

152
scripts/qa_attribution.py Normal file
View File

@@ -0,0 +1,152 @@
#!/usr/bin/env python3
"""Mechanical QA cross-check of speaker attribution against speech tags.
Usage:
python scripts/qa_attribution.py # check all chapters
python scripts/qa_attribution.py --chapters 6 # check chapter id 6
python scripts/qa_attribution.py --fix # auto-fix exact name-tag mismatches
python scripts/qa_attribution.py --type role_tag_guessed # only one issue type
"""
from __future__ import annotations
import argparse
import json
import sqlite3
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent))
from umbraaudio.analysis.attribution_qa import (
QAIssue,
check_attribution,
fix_high_confidence,
verify_conversation_flow,
)
from umbraaudio.config import load_config
def _build_alias_map(speakers_path: Path) -> dict[str, str]:
alias_map: dict[str, str] = {}
if not speakers_path.exists():
return alias_map
with open(speakers_path) as f:
data = json.load(f)
for s in data.get("speakers", []):
primary = s.get("primary_name", "")
if not primary:
continue
alias_map[primary.lower()] = primary
for alias in s.get("aliases", []):
if alias:
alias_map[alias.lower()] = primary
first = primary.split()[0].lower() if primary.split() else ""
if first and first not in alias_map:
alias_map[first] = primary
return alias_map
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--project", type=str, default=None, help="Project name or path"
)
parser.add_argument("--chapters", type=str, default=None, help="Comma-separated chapter IDs")
parser.add_argument("--fix", action="store_true", help="Auto-fix exact speech-tag mismatches")
parser.add_argument(
"--type",
dest="issue_type",
type=str,
default=None,
help=(
"Only report one issue type "
"(speech_tag_mismatch, narrator_dialogue, role_tag_guessed, "
"address_mismatch, narration_speaker_mismatch, long_monologue)"
),
)
args = parser.parse_args()
cfg = load_config(args.project)
db_path = cfg.db_path
if not db_path.exists():
print(f"Database not found: {db_path}")
return
alias_map = _build_alias_map(cfg.data_dir / "speakers.json")
db = sqlite3.connect(str(db_path))
db.row_factory = sqlite3.Row
if args.chapters:
chapter_ids = [int(x.strip()) for x in args.chapters.split(",")]
placeholders = ",".join("?" * len(chapter_ids))
chapters = db.execute(
f"SELECT id, title, chapter_index FROM chapters "
f"WHERE id IN ({placeholders}) ORDER BY chapter_index",
chapter_ids,
).fetchall()
else:
chapters = db.execute(
"SELECT id, title, chapter_index FROM chapters ORDER BY chapter_index"
).fetchall()
totals = {
"speech_tag_mismatch": 0,
"narrator_dialogue": 0,
"role_tag_guessed": 0,
"address_mismatch": 0,
"narration_speaker_mismatch": 0,
"long_monologue": 0,
}
all_issues: list[QAIssue] = []
for ch in chapters:
ch_id = ch["id"]
rows = db.execute(
"SELECT id, utterance_index, text, type, speaker_name "
"FROM utterances WHERE chapter_id=? ORDER BY utterance_index",
(ch_id,),
).fetchall()
utts = [dict(r) for r in rows]
result = check_attribution(utts, ch_id, alias_map)
flow_result = verify_conversation_flow(utts, ch_id, alias_map)
result.issues.extend(flow_result.issues)
if args.fix:
fixed = fix_high_confidence(utts, alias_map)
for orig, new in zip(utts, fixed):
if orig.get("speaker_name") != new.get("speaker_name"):
db.execute(
"UPDATE utterances SET speaker_name=? WHERE id=?",
(new["speaker_name"], orig["id"]),
)
print(
f" FIX ch{ch_id} utt{new.get('utterance_index')}: "
f"{orig.get('speaker_name')} -> {new['speaker_name']}"
)
db.commit()
for issue in result.issues:
if args.issue_type and issue.issue_type != args.issue_type:
continue
totals[issue.issue_type] += 1
all_issues.append(issue)
print(
f"[{issue.issue_type}] ch{issue.chapter_id} utt{issue.utterance_index} "
f"speaker={issue.speaker!r} expected={issue.expected!r} conf={issue.confidence}"
)
if issue.evidence:
print(f" evidence: {issue.evidence}")
print(f" text: {issue.text}")
db.close()
print(f"\nTotals: {totals}")
if all_issues:
print(f"Total issues: {len(all_issues)} (review or fix before accepting voices)")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,111 @@
diff --git a/qwen_tts/cli/demo.py b/qwen_tts/cli/demo.py
index e267861..686c3f6 100644
--- a/qwen_tts/cli/demo.py
+++ b/qwen_tts/cli/demo.py
@@ -18,9 +18,11 @@ A gradio demo for Qwen3 TTS models.
"""
import argparse
+import base64
import os
import tempfile
from dataclasses import asdict
+from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
import gradio as gr
@@ -59,6 +61,31 @@ def _maybe(v):
return v if v is not None else gr.update()
+def _find_icon() -> Optional[Path]:
+ candidates = [
+ Path(os.environ.get("UMBRA_AUDIO_ICON", "")) if os.environ.get("UMBRA_AUDIO_ICON") else None,
+ Path("/Volumes/1TB_APL_Storage/UmbraAudio/UmbraAudio_icon.png"),
+ Path(__file__).resolve().parents[2] / "assets" / "logo.png",
+ Path("/Volumes/1TB_APL_Storage/UmbraAudio/UmbraAudio_icon.jpeg"),
+ ]
+ for c in candidates:
+ if c is not None and c.is_file():
+ return c
+ return None
+
+
+def _icon_data_uri() -> Optional[str]:
+ icon = _find_icon()
+ if icon is None:
+ return None
+ try:
+ data = base64.b64encode(icon.read_bytes()).decode("ascii")
+ mime = "image/png" if icon.suffix.lower() == ".png" else "image/jpeg"
+ return f"data:{mime};base64,{data}"
+ except OSError:
+ return None
+
+
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="qwen-tts-demo",
@@ -168,6 +195,18 @@ def build_parser() -> argparse.ArgumentParser:
return parser
+def _find_favicon() -> Optional[Path]:
+ for name in ("UmbraAudio_icon.png", "logo.png"):
+ p = Path(__file__).resolve().parents[3] / name
+ if p.is_file():
+ return p
+ alt = Path("/Volumes/1TB_APL_Storage/UmbraAudio") / name
+ if alt.is_file():
+ return alt
+ icon = _find_icon()
+ return icon if icon is not None and icon.suffix.lower() in (".png", ".gif", ".ico") else None
+
+
def _resolve_checkpoint(args: argparse.Namespace) -> str:
ckpt = args.checkpoint or args.checkpoint_pos
if not ckpt:
@@ -275,13 +314,30 @@ def build_demo(tts: Qwen3TTSModel, ckpt: str, gen_kwargs_default: Dict[str, Any]
css = ".gradio-container {max-width: none !important;}"
with gr.Blocks(theme=theme, css=css) as demo:
- gr.Markdown(
- f"""
+ icon_uri = _icon_data_uri()
+ if icon_uri:
+ with gr.Row():
+ with gr.Column(scale=1, min_width=120):
+ gr.HTML(
+ f'<img src="{icon_uri}" alt="UmbraAudio" '
+ 'style="max-height:96px; display:block; margin:0 auto;">'
+ )
+ with gr.Column(scale=4):
+ gr.Markdown(
+ f"""
+# UmbraAudio Voice Studio
+**Checkpoint:** `{ckpt}`
+**Model Type:** `{model_kind}`
+"""
+ )
+ else:
+ gr.Markdown(
+ f"""
# Qwen3 TTS Demo
**Checkpoint:** `{ckpt}`
**Model Type:** `{model_kind}`
"""
- )
+ )
if model_kind == "custom_voice":
with gr.Row():
@@ -621,6 +677,9 @@ def main(argv=None) -> int:
share=args.share,
ssl_verify=True if args.ssl_verify else False,
)
+ favicon = _find_favicon()
+ if favicon is not None:
+ launch_kwargs["favicon_path"] = str(favicon)
if args.ssl_certfile is not None:
launch_kwargs["ssl_certfile"] = args.ssl_certfile
if args.ssl_keyfile is not None:

535
scripts/qwen_tts_server.py Normal file
View File

@@ -0,0 +1,535 @@
#!/usr/bin/env python3
"""Qwen3-TTS FastAPI Server for UmbraAudio.
Provides REST endpoints for voice design and voice cloning.
Expected by umbraaudio.voices.qwen_tts.QwenTTSEngine.
"""
from __future__ import annotations
import asyncio
import io
import json
import logging
import os
import time
import anyio
import numpy as np
import soundfile as sf
import torch
from dataclasses import asdict
from fastapi import FastAPI, File, Form, HTTPException, UploadFile
from fastapi.responses import Response
from pathlib import Path
from pydantic import BaseModel
from qwen_tts import Qwen3TTSModel
from qwen_tts.inference.qwen3_tts_model import VoiceClonePromptItem
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
# Directory where precomputed voice-clone prompts (the "8k matrix" ref_code +
# speaker embedding) are cached, one .pt file per speaker slug. Populated by
# POST /v1/voice-prompt and consumed by /v1/tts via the prompt_slug field so
# every utterance for a speaker reuses the exact same voice identity.
PROMPT_DIR = Path(
os.getenv(
"QWEN_PROMPT_DIR",
"/Volumes/1TB_APL_Storage/UmbraAudio/DealHunterShip_4.0e_whole-to-audio/.umbra/voice_prompts",
)
)
app = FastAPI(title="Qwen3-TTS Server", version="0.1.0")
async def _watchdog_loop() -> None:
"""Background task: if a generation has been stuck past MAX_GEN, restart."""
while True:
await asyncio.sleep(min(_MAX_GEN, 30))
s = _gen_start_at
if s is not None and (time.monotonic() - s) > _MAX_GEN:
_restart_server(
f"generation hung >{_MAX_GEN:.0f}s (poisoned CUDA context?)"
)
await asyncio.sleep(3600) # never reached after execv
@app.on_event("startup")
async def _start_watchdog() -> None:
app.state._wd_task = asyncio.create_task(_watchdog_loop())
# Rainbow Passage — used as default ref_text for ICL voice cloning mode.
# All voice refs were generated with this text via Edge-TTS.
VOICE_REFERENCE_TEXT = (
"When the sunlight strikes raindrops in the air, they act as a prism and form a rainbow. "
"The rainbow is a division of white light into many beautiful colors. "
"These take the shape of a long round arch, with its path high above, "
"and its two ends apparently beyond the horizon. "
"There is, according to legend, a boiling pot of gold at one end. "
"People look, but no one ever finds it. "
"When a man looks for something beyond his reach, "
"his friends say he is looking for the pot of gold at the end of the rainbow. "
"When the sunlight strikes raindrops in the air, they act as a prism and form a rainbow. "
"The rainbow is a division of white light into many beautiful colors. "
"These take the shape of a long round arch, with its path high above, "
"and its two ends apparently beyond the horizon. "
"There is, according to legend, a boiling pot of gold at one end. "
"People look, but no one ever finds it. "
"When a man looks for something beyond his reach, "
"his friends say he is looking for the pot of gold at the end of the rainbow."
)
# Global model state
_vd_model: Qwen3TTSModel | None = None
_base_model: Qwen3TTSModel | None = None
_current_model: str | None = None # "voicedesign" or "base"
# Serializes all model access. Model loads and generations are blocking CPU/GPU
# calls; they run in worker threads (anyio.to_thread) under this lock so the
# event loop stays responsive and concurrent /v1/tts requests cannot wedge it.
_model_lock = asyncio.Lock()
# Hang watchdog. On CUDA, a kernel error (e.g. OOM) can poison the context and
# wedge the generation thread forever while the event loop stays responsive
# (health works, every /v1/tts hangs). The only safe recovery is to restart the
# process. We track when a generation started and, if it exceeds MAX_GEN_SECONDS,
# we relaunch ourselves via os.execv to get a clean CUDA context.
_MAX_GEN = float(os.getenv("QWEN_MAX_GEN_S", "1800"))
_gen_start_at: float | None = None # time.monotonic() when a generation began
def _mark_gen_started() -> None:
global _gen_start_at
_gen_start_at = time.monotonic()
def _mark_gen_done() -> None:
global _gen_start_at
_gen_start_at = None
def _restart_server(reason: str) -> None:
"""Relaunch the current process (os.execv) to clear a wedged GPU context."""
import sys as _sys
logger.warning("Restarting TTS server: %s", reason)
try:
for s in (_sys.stdout, _sys.stderr):
try:
s.flush()
except Exception:
pass
except Exception:
pass
try:
_sys.stdout = open(os.devnull, "w")
_sys.stderr = open(os.devnull, "w")
_sys.stdin = open(os.devnull)
os.execv(
_sys.executable,
[_sys.executable, os.path.abspath(__file__), *list(_sys.argv[1:])],
)
except Exception as e: # pragma: no cover
logger.error("os.execv failed: %s", e)
raise SystemExit(1)
# Model paths from environment
VOICEDESIGN_PATH = os.getenv(
"QWEN_VOICEDESIGN_MODEL",
"/Volumes/1TB_APL_Storage/UmbraAudio/models/Qwen3-TTS-12Hz-1.7B-VoiceDesign",
)
BASE_PATH = os.getenv(
"QWEN_BASE_MODEL",
"/Volumes/1TB_APL_Storage/UmbraAudio/models/Qwen3-TTS-12Hz-1.7B-Base",
)
DEVICE = os.getenv("QWEN_DEVICE", "mps")
DTYPE = torch.bfloat16 if DEVICE in ("cuda", "mps") else torch.float32
# Attention implementation. "eager" is very slow for Qwen3-TTS autoregressive
# codec decoding but is the only one that reliably runs create_voice_clone_prompt
# (prompt extraction). "sdpa" (built into torch) is far faster for generation.
# Runtime-switchable via POST /attn (reloads the model); this is read at model
# load time, so switching reloads the active model with the new implementation.
_ATTN_IMPL: str = os.getenv("QWEN_ATTN", "sdpa")
class LoadRequest(BaseModel):
model_type: str # "voicedesign" or "base"
class GenerateRequest(BaseModel):
text: str
sample_rate: int = 24000
voice_description: str | None = None
def _load_model(model_type: str) -> Qwen3TTSModel:
"""Load a model, unloading the other if needed."""
global _vd_model, _base_model, _current_model
if _current_model == model_type:
return _vd_model if model_type == "voicedesign" else _base_model
# Unload current
if _current_model == "voicedesign" and _vd_model is not None:
del _vd_model
_vd_model = None
elif _current_model == "base" and _base_model is not None:
del _base_model
_base_model = None
if torch.mps.is_available() or torch.cuda.is_available():
torch.mps.empty_cache() if torch.mps.is_available() else torch.cuda.empty_cache()
# Load requested
path = VOICEDESIGN_PATH if model_type == "voicedesign" else BASE_PATH
logger.info(f"Loading {model_type} model from {path}")
model = Qwen3TTSModel.from_pretrained(
path,
device_map=DEVICE,
dtype=DTYPE,
attn_implementation=_ATTN_IMPL,
)
if model_type == "voicedesign":
_vd_model = model
else:
_base_model = model
_current_model = model_type
logger.info(f"{model_type} model loaded successfully")
return model
def _prompt_path(slug: str) -> Path:
safe = Path(slug).name
PROMPT_DIR.mkdir(parents=True, exist_ok=True)
return PROMPT_DIR / f"{safe}.pt"
def _save_prompt(slug: str, items: list[VoiceClonePromptItem]) -> Path:
path = _prompt_path(slug)
payload = {"items": [asdict(it) for it in items]}
torch.save(payload, str(path))
logger.info("Saved ICL voice prompt for %s to %s", slug, path)
return path
def _load_prompt_items(slug: str) -> list[VoiceClonePromptItem] | None:
path = _prompt_path(slug)
if not path.is_file():
return None
try:
payload = torch.load(str(path), map_location="cpu", weights_only=True)
except Exception as e:
logger.warning("Failed to load prompt %s: %s", slug, e)
return None
raw_items = payload.get("items", [])
items: list[VoiceClonePromptItem] = []
for d in raw_items:
ref_code = d.get("ref_code")
if ref_code is not None and not torch.is_tensor(ref_code):
ref_code = torch.tensor(ref_code)
ref_spk = d.get("ref_spk_embedding")
if ref_spk is None:
logger.warning("Prompt %s missing ref_spk_embedding", slug)
return None
if not torch.is_tensor(ref_spk):
ref_spk = torch.tensor(ref_spk)
items.append(
VoiceClonePromptItem(
ref_code=ref_code,
ref_spk_embedding=ref_spk,
x_vector_only_mode=bool(d.get("x_vector_only_mode", False)),
icl_mode=bool(d.get("icl_mode", not bool(d.get("x_vector_only_mode", False)))),
ref_text=d.get("ref_text"),
)
)
return items
@app.post("/v1/voice-prompt")
async def build_voice_prompt(
slug: str = Form(...),
ref_text: str | None = Form(None),
ref_audio: UploadFile | None = File(None),
x_vector_only: int = Form(0),
force: int = Form(0),
):
"""Build and cache a speaker's digital voice (a set of numbers) once.
Two capture modes (pick per pipeline; a speaker's cached prompt is used as-is
on every subsequent utterance):
* x_vector_only=0 (default, ICL/"8k matrix"): extracts the FULL codec-token
matrix (ref_code) + speaker embedding. Together those let the generator
re-create the exact reference recording -- and every utterance therefore
re-speaks the reference first, then trims it (slow + leaky).
* x_vector_only=1: captures ONLY the speaker embedding (the digital voice
variables). ref_code is None. Generation then feeds just those numbers +
text into the speech generator -- no reference re-hash, no clone re-run,
fast and output is exactly the text.
Idempotent: if `<slug>.pt` is already cached it returns without rebuilding,
unless `force=1` is passed (use force to switch an existing prompt mode).
"""
path = _prompt_path(slug)
if path.exists() and not force:
return {"ok": True, "slug": slug, "cached": True, "path": str(path)}
if ref_audio is None:
raise HTTPException(400, "ref_audio is required to build a voice prompt")
ref_bytes = await ref_audio.read()
ref_text = (ref_text or "").strip() or VOICE_REFERENCE_TEXT
xvec = bool(x_vector_only)
def _build() -> list[VoiceClonePromptItem]:
model = _load_model("base")
ref_audio_array, ref_sr = sf.read(io.BytesIO(ref_bytes), dtype="float32")
if ref_sr != 24000:
import librosa
ref_audio_array = librosa.resample(
ref_audio_array, orig_sr=ref_sr, target_sr=24000
)
items = model.create_voice_clone_prompt(
ref_audio=(ref_audio_array, 24000),
ref_text=ref_text,
x_vector_only_mode=xvec,
)
return items
_mark_gen_started()
async with _model_lock:
items = await anyio.to_thread.run_sync(_build)
_mark_gen_done()
_save_prompt(slug, items)
return {"ok": True, "slug": slug, "path": str(_prompt_path(slug))}
@app.get("/health")
async def health():
return {
"status": "ok",
"voicedesign_loaded": _vd_model is not None,
"base_loaded": _base_model is not None,
"current_model": _current_model,
"device": DEVICE,
}
@app.post("/load")
async def load_model(request: LoadRequest):
if request.model_type not in ("voicedesign", "base"):
raise HTTPException(400, "model_type must be 'voicedesign' or 'base'")
try:
async with _model_lock:
await anyio.to_thread.run_sync(_load_model, request.model_type)
return {"ok": True, "model": request.model_type}
except Exception as e:
logger.exception("Failed to load model")
raise HTTPException(500, f"Failed to load model: {e}") from e
def _unload_model(model_type: str) -> None:
global _vd_model, _base_model, _current_model
if model_type in ("voicedesign", "all") and _vd_model is not None:
del _vd_model
_vd_model = None
if model_type in ("base", "all") and _base_model is not None:
del _base_model
_base_model = None
if model_type == "all" or _current_model == model_type:
_current_model = None
if torch.mps.is_available() or torch.cuda.is_available():
torch.mps.empty_cache() if torch.mps.is_available() else torch.cuda.empty_cache()
@app.post("/unload")
async def unload_model(request: LoadRequest):
if request.model_type not in ("voicedesign", "base", "all"):
raise HTTPException(400, "model_type must be 'voicedesign', 'base', or 'all'")
async with _model_lock:
await anyio.to_thread.run_sync(_unload_model, request.model_type)
return {"ok": True, "unloaded": request.model_type}
class AttnRequest(BaseModel):
impl: str # "eager", "sdpa", "fluent", "flash_attention_2"
@app.get("/attn")
async def get_attn():
return {"ok": True, "impl": _ATTN_IMPL}
@app.post("/attn")
async def set_attn(request: AttnRequest):
"""Switch the attention implementation and reload the active model.
The attention implementation is fixed at model-load time, so this unloads
the current model(s) and reloads `base` with the new implementation. Callers
should switch to ``eager`` for prompt extraction (Phase 1) and ``sdpa`` for
generation (Phase 2) without a process restart.
"""
global _ATTN_IMPL
impl = (request.impl or "").strip().lower()
if impl not in ("eager", "sdpa", "fluent", "flash_attention_2"):
raise HTTPException(400, "impl must be eager|sdpa|fluent|flash_attention_2")
async with _model_lock:
try:
await anyio.to_thread.run_sync(_unload_model, "all")
_ATTN_IMPL = impl
# reload so subsequent use has the new attn; base is the main model
await anyio.to_thread.run_sync(_load_model, "base")
except Exception as e:
logger.exception("Failed to switch attn to %s", impl)
raise HTTPException(500, f"Failed to switch attn: {e}") from e
return {"ok": True, "impl": _ATTN_IMPL}
@app.post("/v1/tts")
async def generate_tts(
text: str = Form(...),
sample_rate: int = Form(24000),
voice_description: str | None = Form(None),
ref_audio: UploadFile | None = File(None),
ref_text: str | None = Form(None),
speaker_embedding: str | None = Form(None),
prompt_slug: str | None = Form(None),
temperature: float = Form(0.9),
do_sample: bool = Form(True),
seed: int | None = Form(None),
):
"""Generate TTS audio.
- voice_description + text -> VoiceDesign generation
- ref_audio + text + ref_text -> VoiceClone generation
- speaker_embedding + text -> Direct embedding generation (fastest, most consistent)
- prompt_slug -> ICL generation from cached voice-clone prompt (most faithful &
consistent; the "8k matrix" built by POST /v1/voice-prompt)
prompt_slug takes priority: if the cached prompt exists it is used, giving
the speaker the identical reference voice on every utterance.
"""
gen_kwargs: dict = {"temperature": temperature, "do_sample": do_sample}
if seed is not None:
torch.manual_seed(seed)
if torch.cuda.is_available():
torch.cuda.manual_seed_all(seed)
_mark_gen_started()
async with _model_lock:
if prompt_slug:
items = _load_prompt_items(prompt_slug)
if items is None:
raise HTTPException(
404, f"No cached voice prompt for slug '{prompt_slug}'"
)
def _icl() -> tuple[np.ndarray, int]:
model = _load_model("base")
wavs, sr = model.generate_voice_clone(
text=text,
voice_clone_prompt=items,
language="english",
**gen_kwargs,
)
return wavs, sr
wavs, sr = await anyio.to_thread.run_sync(_icl)
elif speaker_embedding is not None:
emb_list = json.loads(speaker_embedding)
emb_np = np.array(emb_list, dtype=np.float32)
spk_tensor = torch.from_numpy(emb_np)
def _embed() -> tuple[np.ndarray, int]:
model = _load_model("base")
device = next(model.model.parameters()).device
dtype = next(model.model.parameters()).dtype
spk = spk_tensor.to(device).to(dtype)
wavs, sr = model.generate_voice_clone(
text=text,
voice_clone_prompt={
"ref_code": [None],
"ref_spk_embedding": [spk],
"x_vector_only_mode": [True],
"icl_mode": [False],
},
language="english",
**gen_kwargs,
)
return wavs, sr
wavs, sr = await anyio.to_thread.run_sync(_embed)
elif ref_audio is not None:
ref_bytes = await ref_audio.read()
def _clone() -> tuple[np.ndarray, int]:
model = _load_model("base")
ref_audio_array, ref_sr = sf.read(io.BytesIO(ref_bytes), dtype="float32")
if ref_sr != sample_rate:
import librosa
ref_audio_array = librosa.resample(
ref_audio_array, orig_sr=ref_sr, target_sr=sample_rate
)
wavs, sr = model.generate_voice_clone(
text=text,
ref_audio=(ref_audio_array, sample_rate),
ref_text=None,
x_vector_only_mode=True,
language="english",
**gen_kwargs,
)
return wavs, sr
wavs, sr = await anyio.to_thread.run_sync(_clone)
elif voice_description:
def _design() -> tuple[np.ndarray, int]:
model = _load_model("voicedesign")
wavs, sr = model.generate_voice_design(
text=text,
instruct=voice_description,
language="english",
**gen_kwargs,
)
return wavs, sr
wavs, sr = await anyio.to_thread.run_sync(_design)
else:
raise HTTPException(400, "Either voice_description or ref_audio required")
_mark_gen_done()
if not wavs:
raise HTTPException(500, "Model returned no audio")
# Return WAV as binary
wav_bytes = io.BytesIO()
sf.write(wav_bytes, wavs[0], sr, format="WAV")
wav_bytes.seek(0)
return Response(
content=wav_bytes.read(),
media_type="audio/wav",
headers={"Content-Disposition": "attachment; filename=output.wav"},
)
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8881)

147
scripts/reattribute_all.py Normal file
View File

@@ -0,0 +1,147 @@
#!/usr/bin/env python3
"""Re-run LLM speaker attribution on existing utterances without re-segmenting.
Preserves utterance IDs and mechanical segmentation; only updates speaker_name.
"""
from __future__ import annotations
import sqlite3
import sys
import time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent))
from umbraaudio.analysis.llm_attributor import LLMAttributor
from umbraaudio.config import AppConfig, LLMConfig
from umbraaudio.voices.model_coordinator import ModelCoordinator, PipelineStage
def main():
import argparse
parser = argparse.ArgumentParser()
parser.add_argument(
"--project",
type=str,
default=None,
help="Project name or path",
)
parser.add_argument(
"--chapters",
type=str,
default=None,
help="Comma-separated chapter IDs (e.g. '5,6,7')",
)
parser.add_argument(
"--model",
type=str,
default="google/gemma-4-e4b",
help="LLM model name",
)
parser.add_argument(
"--sleep",
type=float,
default=1.0,
help="Seconds to sleep between LLM batches",
)
args = parser.parse_args()
from umbraaudio.config import load_config
cfg = load_config(args.project)
config = cfg.llm
coordinator = ModelCoordinator(cfg)
if not coordinator.switch_to(
PipelineStage.CHAPTER_ANALYSIS, lm_model=args.model
).get("ok"):
print("Failed to switch models to CHAPTER_ANALYSIS stage")
sys.exit(1)
db_path = cfg.db_path
if not db_path.exists():
print(f"Database not found: {db_path}")
return
db = sqlite3.connect(str(db_path))
db.row_factory = sqlite3.Row
if args.chapters:
chapter_ids = [int(x.strip()) for x in args.chapters.split(",")]
placeholders = ",".join("?" * len(chapter_ids))
chapters = db.execute(
f"SELECT id, title, chapter_index FROM chapters "
f"WHERE id IN ({placeholders}) ORDER BY chapter_index",
chapter_ids,
).fetchall()
else:
chapters = db.execute(
"SELECT id, title, chapter_index FROM chapters ORDER BY chapter_index"
).fetchall()
print(f"Found {len(chapters)} chapters to attribute")
print(f"Model: {args.model}")
attributor = LLMAttributor(config, model=args.model, data_dir=cfg.data_dir)
total_dialogue = 0
total_narrator_left = 0
for ch in chapters:
ch_id = ch["id"]
title = ch["title"] or f"Chapter {ch['chapter_index']}"
utts = db.execute(
"SELECT id, utterance_index, text, type, speaker_name "
"FROM utterances WHERE chapter_id=? ORDER BY utterance_index",
(ch_id,),
).fetchall()
if not utts:
print(f" {title}: no utterances, skipping")
continue
utt_list = [dict(u) for u in utts]
dialogue_before = sum(1 for u in utt_list if u.get("type") == "dialogue")
narrator_before = sum(
1
for u in utt_list
if u.get("type") == "dialogue"
and (u.get("speaker_name") or "narrator").lower() == "narrator"
)
attributed = attributor.attribute_speakers(utt_list, title, ch_id)
time.sleep(0.5)
for u in attributed:
db.execute(
"UPDATE utterances SET speaker_name=? WHERE id=?",
(u.get("speaker_name", "Narrator"), u["id"]),
)
db.commit()
narrator_after = sum(
1
for u in attributed
if u.get("type") == "dialogue"
and (u.get("speaker_name") or "narrator").lower() == "narrator"
)
total_dialogue += dialogue_before
total_narrator_left += narrator_after
print(
f" {title} (id={ch_id}): dialogue {dialogue_before}, "
f"narrator-left {narrator_before} -> {narrator_after}"
)
db.close()
coordinator.switch_to(PipelineStage.IDLE)
print(
f"\nTotal: {total_dialogue} dialogue utterances, "
f"{total_narrator_left} still attributed to narrator"
)
print("Done!")
if __name__ == "__main__":
main()

178
scripts/reindex_chapter6.py Normal file
View File

@@ -0,0 +1,178 @@
#!/usr/bin/env python3
"""Re-index utterance_index values and fix remaining issues for Chapter 6."""
from __future__ import annotations
import argparse
import sqlite3
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent))
from umbraaudio.config import load_config
CHAPTER_ID = 6
def reindex_and_fix(db_path: Path) -> None:
conn = sqlite3.connect(str(db_path))
conn.row_factory = sqlite3.Row
# Get all utterances for chapter 6 ordered by current utterance_index
utts = conn.execute(
"SELECT id, utterance_index, text, type, speaker_name, emotion, instruct "
"FROM utterances WHERE chapter_id=? ORDER BY utterance_index",
(CHAPTER_ID,),
).fetchall()
print(f"Found {len(utts)} utterances before re-indexing")
# Re-index sequentially from 0
for new_idx, u in enumerate(utts):
conn.execute(
"UPDATE utterances SET utterance_index=? WHERE id=?",
(new_idx, u["id"]),
)
conn.commit()
print("Re-indexed utterances 0 to", len(utts) - 1)
# Now fix specific issues with correct new indices
# Get fresh data
utts = conn.execute(
"SELECT id, utterance_index, text, type, speaker_name, emotion, instruct "
"FROM utterances WHERE chapter_id=? ORDER BY utterance_index",
(CHAPTER_ID,),
).fetchall()
print("\n=== After re-indexing ===")
for u in utts:
if u["utterance_index"] in range(50, 70) or u["utterance_index"] in range(140, 160) or \
u["utterance_index"] in range(180, 200) or u["utterance_index"] in range(210, 230) or \
u["utterance_index"] in range(350, 370):
print(f" utt{u['utterance_index']:03d} [{u['type']}] {u['speaker_name']}: {u['text'][:80]}...")
# Fix: "What ship is this?" should be Kainda (not Nanny)
# Find the utterance with this text
for u in utts:
if u["text"] == "What ship is this?" and u["speaker_name"] == "Nanny":
conn.execute(
"UPDATE utterances SET speaker_name=?, emotion=?, instruct=? WHERE id=?",
("Kainda Walters Foster Murphy", "confused", "Speak with confusion and disorientation. Trembling, uncertain delivery.",
u["id"]),
)
print(f"\nFixed utt{u['utterance_index']}: 'What ship is this?' -> Kainda Walters Foster Murphy (confused)")
# Verify "Hello." is Nanny (the robot)
for u in utts:
if u["text"] == "Hello." and u["utterance_index"] > 180 and u["utterance_index"] < 190:
if u["speaker_name"] != "Nanny":
conn.execute(
"UPDATE utterances SET speaker_name=? WHERE id=?",
("Nanny", u["id"]),
)
print(f"Fixed utt{u['utterance_index']}: 'Hello.' -> Nanny")
# Verify "You are on the Deal Hunter." is Nanny
for u in utts:
if u["text"] == "You are on the Deal Hunter." and u["speaker_name"] != "Nanny":
conn.execute(
"UPDATE utterances SET speaker_name=? WHERE id=?",
("Nanny", u["id"]),
)
print(f"Fixed utt{u['utterance_index']}: 'You are on the Deal Hunter.' -> Nanny")
# Verify "There are no rescue ships..." is Nanny
for u in utts:
if "no rescue ships" in u["text"] and u["speaker_name"] != "Nanny":
conn.execute(
"UPDATE utterances SET speaker_name=? WHERE id=?",
("Nanny", u["id"]),
)
print(f"Fixed utt{u['utterance_index']}: 'There are no rescue ships...' -> Nanny")
# Verify utt 211 (now reindexed) "That was a missile..." is Nanny
for u in utts:
if "missile trying to disable" in u["text"] and u["speaker_name"] != "Nanny":
conn.execute(
"UPDATE utterances SET speaker_name=? WHERE id=?",
("Nanny", u["id"]),
)
print(f"Fixed utt{u['utterance_index']}: 'That was a missile...' -> Nanny")
# Verify the scene break was removed (should be no <<<>>> utterances)
scene_breaks = conn.execute(
"SELECT id, utterance_index FROM utterances WHERE chapter_id=? AND text='<<<>>>'",
(CHAPTER_ID,),
).fetchall()
if scene_breaks:
print(f"\nWARNING: {len(scene_breaks)} scene break utterances still exist!")
for sb in scene_breaks:
print(f" utt{sb['utterance_index']}: {sb['id']}")
else:
print("\nScene breaks removed successfully.")
# Verify Willamina Wake yelling at "Get the princess on board!"
for u in utts:
if "Get the princess on board" in u["text"]:
emotion = u["emotion"] if u["emotion"] else ""
if u["speaker_name"] != "Willamina Wake" or emotion != "yelling":
conn.execute(
"UPDATE utterances SET speaker_name=?, emotion=?, instruct=? WHERE id=?",
("Willamina Wake", "yelling", "Speak with authority and command. Firm, unwavering tone. LOUD.",
u["id"]),
)
print(f"Fixed utt{u['utterance_index']}: 'Get the princess on board!' -> Willamina Wake (yelling)")
else:
print(f"utt{u['utterance_index']}: Willamina Wake yelling - already correct")
# Verify Kainda pleading at "Help me, please." (the first one, utt ~147)
for u in utts:
if u["text"] == "Help me, please." and u["utterance_index"] < 160 and u["speaker_name"] != "Kainda Walters Foster Murphy":
conn.execute(
"UPDATE utterances SET speaker_name=?, emotion=?, instruct=? WHERE id=?",
("Kainda Walters Foster Murphy", "pleading", "Speak with desperate pleading. Urgent, emotional.",
u["id"]),
)
print(f"Fixed utt{u['utterance_index']}: 'Help me, please.' -> Kainda (pleading)")
elif u["text"] == "Help me, please." and u["utterance_index"] < 160:
# Check emotion is set
if u.get("emotion") != "pleading":
conn.execute(
"UPDATE utterances SET emotion=?, instruct=? WHERE id=?",
("pleading", "Speak with desperate pleading. Urgent, emotional.", u["id"]),
)
print(f"Fixed utt{u['utterance_index']}: Added pleading emotion")
conn.commit()
# Final verification
print("\n=== Final verification of key utterances ===")
utts = conn.execute(
"SELECT id, utterance_index, text, type, speaker_name, emotion, instruct "
"FROM utterances WHERE chapter_id=? ORDER BY utterance_index",
(CHAPTER_ID,),
).fetchall()
key_indices = [56, 57, 58, 147, 183, 188, 190, 191, 192, 213, 224, 352]
for u in utts:
if u["utterance_index"] in key_indices:
emotion = u["emotion"] if u["emotion"] else ""
emo = f" [{emotion}]" if emotion else ""
instruct = u["instruct"] if u["instruct"] else ""
inst = f" ({instruct[:40]}...)" if instruct else ""
print(f" utt{u['utterance_index']:03d} [{u['type']}] {u['speaker_name']}{emo}{inst}: {u['text'][:80]}...")
conn.close()
print("\n=== Done ===")
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Re-index and fix Chapter 6 utterances")
parser.add_argument(
"--project", type=str, default=None, help="Project name or path"
)
args = parser.parse_args()
cfg = load_config(args.project)
reindex_and_fix(cfg.db_path)

View File

@@ -0,0 +1,202 @@
"""Deterministic re-merge of existing discovery chapter data with fixed logic.
Regenerates .umbra/speakers.json from .umbra/discovery/discovery_pass1.json
chapter results using the corrected _merge_chapter_results (no non-speaker
promotion, shared-specific-alias dedup, LLM alias evidence respected).
The discovery pass1 chapter results are unchanged (LLM per-chapter output);
only the deterministic merge/dedup post-processing is re-applied.
"""
from __future__ import annotations
import json
import shutil
import sys
from datetime import datetime
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent))
from umbraaudio.analysis.character_discovery import (
ChapterSpeakers,
CharacterDiscoverer,
)
from umbraaudio.config import load_config
def main() -> None:
import argparse
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--project", type=str, default=None, help="Project name or path"
)
args = parser.parse_args()
cfg = load_config(args.project)
data_dir = cfg.data_dir
pass1_path = data_dir / "discovery" / "discovery_pass1.json"
speakers_path = data_dir / "speakers.json"
with open(pass1_path) as f:
pass1 = json.load(f)
crs = []
for cr in pass1.get("chapter_results", []):
crs.append(
ChapterSpeakers(
chapter_index=cr.get("chapter_index", 0),
chapter_title=cr.get("chapter_title", ""),
speaking_characters=cr.get("speaking_characters", []),
non_speaking_characters=cr.get("non_speaking_characters", []),
validation_notes=cr.get("validation_notes", []),
)
)
cd = CharacterDiscoverer.__new__(CharacterDiscoverer)
cd.TITLE_WORDS = CharacterDiscoverer.TITLE_WORDS
speakers = cd._merge_chapter_results(crs, pass1.get("book_title", ""), 0)
has_narrator = any(s.is_narrator for s in speakers)
if not has_narrator:
from umbraaudio.analysis.character_discovery import (
CommunicationStyle,
Demographics,
Speaker,
)
narrator = Speaker(
primary_name="Narrator",
aliases=["narrator", "the narrator"],
demographics=Demographics(type="Human", gender="Male"),
vocal_traits=["deep", "resonant", "calm", "authoritative"],
communication_style=CommunicationStyle(
primary_register="Formal",
style_shifts_or_notes=(
"Steady, even-toned throughout. Never rushes."
),
),
description=(
"Deep, resonant male voice. Calm, authoritative tone. "
"Rich baritone timbre, slow and deliberate pacing, "
"like a late-night radio host."
),
is_narrator=True,
)
speakers.insert(0, narrator)
result = {
"speakers": [s.to_dict() for s in speakers],
"book_title": pass1.get("book_title", ""),
"total_chapters": pass1.get("total_chapters", 0),
"total_words": pass1.get("total_words", 0),
}
if speakers_path.exists():
backup = (
data_dir
/ "archives"
/ "speakers_remigrated"
/ f"{datetime.now():%Y%m%d_%H%M%S}.json"
)
backup.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(speakers_path, backup)
print(f"Backed up previous speakers.json -> {backup}")
_port_enriched_fields(backup, result)
with open(speakers_path, "w") as f:
json.dump(result, f, indent=2)
print(f"Wrote {len(speakers)} speakers to {speakers_path}")
for s in speakers:
n = s.primary_name
if "kainda" in n.lower() or "capek" in n.lower():
print(f" {n}: ch{s.chapters_appeared} aliases={s.aliases}")
_reconcile_voice_profiles(data_dir, speakers)
def _port_enriched_fields(backup: Path, result: dict) -> None:
with open(backup) as f:
old = json.load(f)
old_by_name = {s["primary_name"].lower(): s for s in old.get("speakers", [])}
old_by_alias: dict[str, dict] = {}
for s in old.get("speakers", []):
for alias in s.get("aliases", []):
old_by_alias.setdefault(alias.lower(), s)
for speaker in result["speakers"]:
n = speaker["primary_name"]
candidates = []
if n.lower() in old_by_name:
candidates.append(old_by_name[n.lower()])
if n.lower() in old_by_alias:
candidates.append(old_by_alias[n.lower()])
candidates.extend(
s
for key, s in old_by_name.items()
if key != n.lower() and key in n.lower()
)
old_speaker = next(
(s for s in candidates if s.get("description") or s.get("sample_dialogue")),
candidates[0] if candidates else None,
)
if not old_speaker:
continue
for field in ("description", "sample_dialogue"):
if not speaker.get(field) and old_speaker.get(field):
speaker[field] = old_speaker[field]
def _reconcile_voice_profiles(data_dir: Path, speakers: list) -> None:
profiles_path = data_dir / "voice_profiles.json"
if not profiles_path.exists():
return
with open(profiles_path) as f:
profiles = json.load(f)
current_names = {s.primary_name for s in speakers}
merged = {}
for s in speakers:
name = s.primary_name
if name in profiles:
merged[name] = profiles[name]
alias_to_canonical: dict[str, str] = {}
for s in speakers:
for alias in s.aliases:
alias_to_canonical.setdefault(alias.lower(), s.primary_name)
for old_name in list(profiles.keys()):
if old_name in merged:
continue
canonical = alias_to_canonical.get(old_name.lower())
if canonical is None:
canonical = next(
(
n
for n in current_names
if n.lower() != old_name.lower()
and old_name.lower() in n.lower()
),
None,
)
if canonical and canonical not in merged:
merged[canonical] = profiles[old_name]
elif canonical:
for key, value in profiles[old_name].items():
if value and not merged[canonical].get(key):
merged[canonical][key] = value
with open(profiles_path, "w") as f:
json.dump(merged, f, indent=2)
print(f"Reconciled voice profiles: {len(merged)} kept (was {len(profiles)})")
if __name__ == "__main__":
main()

142
scripts/resegment_all.py Normal file
View File

@@ -0,0 +1,142 @@
#!/usr/bin/env python3
"""Re-segment all chapters using mechanical splitter + LLM attribution."""
from __future__ import annotations
import json
import sqlite3
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent))
from umbraaudio.analysis.llm_attributor import LLMAttributor
from umbraaudio.config import LLMConfig
from umbraaudio.mapping.segmenter import Segmenter
def main():
import argparse
parser = argparse.ArgumentParser()
parser.add_argument(
"--project", type=str, default=None, help="Project name or path"
)
parser.add_argument(
"--chapters", type=str, default=None, help="Comma-separated chapter IDs (e.g. '5,6,7')"
)
parser.add_argument("--model", type=str, default="gemma-4-12b-it-qat", help="LLM model name")
parser.add_argument(
"--mechanical-only", action="store_true", help="Skip LLM attribution, use mechanical only"
)
args = parser.parse_args()
from umbraaudio.config import load_config
cfg = load_config(args.project)
config = cfg.llm
db_path = cfg.db_path
if not db_path.exists():
print(f"Database not found: {db_path}")
return
db = sqlite3.connect(str(db_path))
db.row_factory = sqlite3.Row
if args.chapters:
chapter_ids = [int(x.strip()) for x in args.chapters.split(",")]
placeholders = ",".join("?" * len(chapter_ids))
chapters = db.execute(
f"SELECT id, title, chapter_index, source_text FROM chapters "
f"WHERE id IN ({placeholders}) ORDER BY chapter_index",
chapter_ids,
).fetchall()
else:
chapters = db.execute(
"SELECT id, title, chapter_index, source_text FROM chapters ORDER BY chapter_index"
).fetchall()
print(f"Found {len(chapters)} chapters to segment")
print(f"Model: {args.model}")
print(f"LLM attribution: {'disabled' if args.mechanical_only else 'enabled'}")
# Load character names and alias map from speakers.json
speakers_path = cfg.data_dir / "speakers.json"
character_names = []
alias_map = {}
if speakers_path.exists():
with open(speakers_path) as f:
data = json.load(f)
for s in data.get("speakers", []):
primary = s.get("primary_name", "")
if not primary:
continue
character_names.append(primary)
for alias in s.get("aliases", []):
alias_map[alias] = primary
print(f"Loaded {len(character_names)} character names, {len(alias_map)} aliases")
seg = Segmenter(alias_map=alias_map)
attributor = LLMAttributor(config, model=args.model, data_dir=cfg.data_dir) if not args.mechanical_only else None
total_utterances = 0
for ch in chapters:
ch_id = ch["id"]
title = ch["title"] or f"Chapter {ch['chapter_index']}"
source_text = ch["source_text"]
old_count = db.execute(
"SELECT COUNT(*) as cnt FROM utterances WHERE chapter_id = ?", (ch_id,)
).fetchone()["cnt"]
# Phase 1: Mechanical segmentation
mechanical = seg.segment(source_text, ch_id, character_names, chapter_title=title)
utterances = []
for i, u in enumerate(mechanical):
speaker = u.get("character_name", "narrator")
if speaker is None:
speaker = "narrator"
is_scene_break = u["text"].strip() in ("<<<>>>",)
utterances.append(
{
"utterance_index": i,
"text": u["text"],
"type": u.get("type", "narration"),
"speaker_name": "scene_break" if is_scene_break else speaker,
}
)
# Phase 2: LLM attribution (if enabled)
if attributor and not args.mechanical_only:
utterances = attributor.attribute_speakers(utterances, title, ch_id)
# Write to database
db.execute("DELETE FROM utterances WHERE chapter_id = ?", (ch_id,))
for u in utterances:
db.execute(
"INSERT INTO utterances "
"(chapter_id, utterance_index, text, type, speaker_name) "
"VALUES (?, ?, ?, ?, ?)",
(ch_id, u["utterance_index"], u["text"], u["type"], u["speaker_name"]),
)
db.commit()
total_utterances += len(utterances)
print(f" {title}: {old_count} -> {len(utterances)} utterances")
db.close()
print(f"\nTotal: {total_utterances} utterances across {len(chapters)} chapters")
print("Done!")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,201 @@
from __future__ import annotations
import logging
from typing import Any
from umbraaudio.analysis.llm_attributor import LLMAttributor
from umbraaudio.config import load_config
from umbraaudio.database import Database
logger = logging.getLogger(__name__)
async def run_llm_attribution(
project_dir: str = ".",
chapter_ids: list[int] | None = None,
) -> dict[str, Any]:
"""
Run multi-pass LLM speaker attribution on chapters.
This replaces the mechanical _attribute_speakers with LLM-based attribution
that includes context analysis, confidence scoring, and exception detection.
"""
from pathlib import Path
config = load_config(Path(project_dir))
db = Database(config.db_path)
await db.connect()
try:
# Get chapters to process
all_chapters = await db.get_chapters(1)
if chapter_ids:
chapters = [ch for ch in all_chapters if ch["id"] in chapter_ids]
else:
chapters = all_chapters
# Initialize LLM attributor
attributor = LLMAttributor(config.llm, data_dir=config.data_dir)
results = {
"chapters_processed": 0,
"total_utterances": 0,
"attributed": 0,
"exceptions_found": 0,
"quality_gate_passed": True,
}
for ch in chapters:
chapter_id = ch["id"]
chapter_title = ch.get("title") or f"Chapter {ch['chapter_index'] + 1}"
logger.info(f"Running LLM attribution for chapter {chapter_id}: {chapter_title}")
# Get utterances from database
utterances = await db.get_utterances(chapter_id)
if not utterances:
logger.warning(f"Chapter {chapter_id}: no utterances found")
continue
# Prepare chapter data for context analysis
chapter_data = {
"id": chapter_id,
"title": chapter_title,
"source_text": ch.get("source_text", ""),
"utterances": [dict(u) for u in utterances],
}
# Run multi-pass LLM attribution
updated_utterances = attributor.attribute_speakers(
utterances=[dict(u) for u in utterances],
chapter_title=chapter_title,
chapter_id=chapter_id,
chapter_data=chapter_data,
)
# Update database with new attributions
for u in updated_utterances:
if u.get("type") == "dialogue" and "speaker_name" in u:
await db.update_utterance_speaker(
u["id"],
u["speaker_name"],
u.get("attribution_confidence", 0.5),
u.get("attribution_reasoning", ""),
u.get("emotion"),
u.get("emotion_confidence"),
)
# Populate exception reviews from confidence scores
await _populate_exceptions(db, chapter_id, updated_utterances)
results["chapters_processed"] += 1
results["total_utterances"] += len([u for u in updated_utterances if u.get("type") == "dialogue"])
results["attributed"] += len([u for u in updated_utterances if u.get("type") == "dialogue" and u.get("speaker_name") != "narrator"])
# Check quality gate
quality_gate = await _check_quality_gate(db)
results["quality_gate_passed"] = quality_gate["passed"]
results["exception_rate"] = quality_gate["exception_rate"]
results["exceptions_found"] = quality_gate["total_exceptions"]
logger.info(f"LLM attribution complete: {results}")
return results
finally:
await db.close()
async def _populate_exceptions(db: Database, chapter_id: int, utterances: list[dict[str, Any]]) -> int:
"""Populate exception_reviews table from low-confidence attributions."""
import json
total_added = 0
for u in utterances:
if u.get("type") != "dialogue":
continue
confidence = u.get("attribution_confidence", 1.0)
if confidence >= 0.7:
continue
# Check if already in exception_reviews
cursor = await db.conn.execute(
"SELECT id FROM exception_reviews WHERE utterance_id = ?",
(u["id"],)
)
existing = await cursor.fetchone()
if existing:
continue
# Determine ambiguity type
ambiguity_type = "low_confidence"
reasoning = u.get("attribution_reasoning", "")
if confidence == 0.0:
ambiguity_type = "no_confidence"
elif u.get("speaker_name") == "narrator":
ambiguity_type = "narrator_dialogue"
elif "pronoun" in reasoning.lower():
ambiguity_type = "pronoun"
elif "role" in reasoning.lower() or "unnamed" in reasoning.lower():
ambiguity_type = "unnamed_role"
elif "retroactive" in reasoning.lower():
ambiguity_type = "retroactive_name"
elif "generic" in reasoning.lower():
ambiguity_type = "generic_role"
await db.conn.execute("""
INSERT INTO exception_reviews (
project_id, chapter_id, utterance_id, utterance_index,
current_speaker, attributed_speaker, confidence,
ambiguity_type, evidence, reasoning, alternatives, text_preview, status
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending')
""", (
1, chapter_id, u["id"], u["utterance_index"],
u.get("speaker_name", ""), u.get("speaker_name", ""),
confidence,
ambiguity_type,
json.dumps([reasoning] if reasoning else ["Low confidence attribution"]),
reasoning,
json.dumps([]),
u["text"][:200],
))
total_added += 1
return total_added
async def _check_quality_gate(db: Database) -> dict[str, Any]:
"""Check if exception rate exceeds 1% quality gate."""
cursor = await db.conn.execute(
"SELECT COUNT(*) as cnt FROM utterances WHERE type = 'dialogue'"
)
total_utterances = await cursor.fetchone()
cursor = await db.conn.execute(
"SELECT COUNT(*) as cnt FROM exception_reviews WHERE status = 'pending'"
)
total_exceptions = await cursor.fetchone()
total_utt = total_utterances["cnt"] if total_utterances else 0
total_exc = total_exceptions["cnt"] if total_exceptions else 0
exception_rate = (total_exc / total_utt * 100) if total_utt > 0 else 0
return {
"passed": exception_rate <= 1.0,
"exception_rate": exception_rate,
"total_exceptions": total_exc,
"total_utterances": total_utt,
"threshold": 1.0,
}
# CLI entry point
if __name__ == "__main__":
import asyncio
import sys
chapter_ids = None
if len(sys.argv) > 1:
chapter_ids = [int(x) for x in sys.argv[1].split(",")]
result = asyncio.run(run_llm_attribution(chapter_ids=chapter_ids))
print(result)

View File

@@ -0,0 +1,53 @@
from __future__ import annotations
import json
import logging
from pathlib import Path
from umbraaudio.analysis.voice_analysis import VoiceAnalysisEngine
from umbraaudio.config import load_config
logger = logging.getLogger(__name__)
def run_voice_analysis(project_dir: Path | None = None) -> None:
"""Compute the objective voice-analysis report for a project."""
project_dir = project_dir or Path(".")
cfg = load_config(project_dir)
speakers_path = cfg.data_dir / "speakers.json"
speaker_names: set[str] = set()
if speakers_path.exists():
with open(speakers_path) as f:
speaker_names = {s["primary_name"] for s in json.load(f).get("speakers", [])}
engine = VoiceAnalysisEngine(
refs_dir=cfg.data_dir / "voices",
output_dir=cfg.data_dir / "analysis",
target_sample_rate=cfg.tts.sample_rate,
speaker_names=speaker_names,
)
report = engine.analyze()
summary = report["summary"]
print(
f"Analyzed {summary['total']} voices: "
f"{summary['passed']} passed, {summary['failed']} failed, "
f"{summary['near_duplicate_count']} near-duplicate pairs"
)
if report["near_duplicates"]:
print("Near-duplicate pairs:")
for pair in report["near_duplicates"]:
print(f" {pair['a']} <-> {pair['b']} ({pair['similarity']})")
if report["errors"]:
print("Errors:")
for name, err in report["errors"].items():
print(f" {name}: {err}")
print(f"Report written to {engine.report_path}")
if __name__ == "__main__":
import sys
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
project = Path(sys.argv[1]) if len(sys.argv) > 1 else None
run_voice_analysis(project)

38
scripts/start.sh Executable file
View File

@@ -0,0 +1,38 @@
#!/bin/bash
# UmbraAudio Startup Script
set -e
echo "=== UmbraAudio Startup ==="
# Check MySQL
echo "Checking MySQL..."
if ! mysqladmin ping -u root -p'MyQl#26Pas$' --silent 2>/dev/null; then
echo "Starting MySQL..."
sudo systemctl start mysql
fi
echo "MySQL: OK"
# Check Redis
echo "Checking Redis..."
if ! redis-cli ping 2>/dev/null | grep -q PONG; then
echo "Starting Redis..."
sudo systemctl start redis-server
fi
echo "Redis: OK"
# Ensure database exists
echo "Ensuring database..."
mysql -u root -p'MyQl#26Pas$' -e "CREATE DATABASE IF NOT EXISTS umbra_audio CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;" 2>/dev/null
echo "Database: OK"
# Run migrations
echo "Running migrations..."
cd /home/bm/Apps/UmbraAudio
alembic upgrade head 2>/dev/null
echo "Migrations: OK"
echo ""
echo "=== Services Ready ==="
echo "Start API server: python -m umbra_audio.main"
echo "Start Celery worker: celery -A umbra_audio.tasks worker --loglevel=info"

329
scripts/test_parser.py Normal file
View File

@@ -0,0 +1,329 @@
#!/usr/bin/env python3
"""
Test script for document parser and entity extraction.
Tests the parsing of DealHunterShip manuscript.
"""
import json
import sys
from pathlib import Path
# Add parent directory to path
sys.path.insert(0, str(Path(__file__).parent.parent))
from umbra_audio.agents.entity_extractor import (
extract_characters_from_text,
merge_characters,
)
from umbra_audio.core.document_parser import (
extract_dialogue_lines,
get_document_stats,
get_section_content_for_audio,
parse_manuscript,
validate_manuscript,
)
def test_document_parser():
"""Test the document parser on DealHunterShip."""
print("=" * 80)
print("TESTING DOCUMENT PARSER")
print("=" * 80)
# Read the manuscript
manuscript_path = Path(__file__).parent.parent / "DealHunterShip.txt"
if not manuscript_path.exists():
print(f"ERROR: Manuscript not found at {manuscript_path}")
return False
with open(manuscript_path, encoding='utf-8') as f:
text = f.read()
print(f"\nManuscript loaded: {len(text)} characters")
# Validate manuscript
print("\n1. Validating manuscript structure...")
validation = validate_manuscript(text)
print(f" Valid: {validation['valid']}")
print(f" Errors: {validation['errors']}")
print(f" Warnings: {validation['warnings']}")
print(f" Stats: {json.dumps(validation['stats'], indent=2)}")
# Parse manuscript
print("\n2. Parsing manuscript...")
manuscript = parse_manuscript(text)
print(f" Title: {manuscript.title}")
print(f" Author: {manuscript.author}")
print(f" Sections found: {len(manuscript.sections)}")
# Analyze sections
print("\n3. Section analysis:")
chapter_count = 0
scene_count = 0
for section in manuscript.sections:
if section.section_type == 'chapter':
chapter_count += 1
print(f" Chapter {section.chapter_number}: {section.chapter_title[:50]}...")
print(f" Content length: {len(section.content)} chars")
print(f" Scenes: {section.scene_index}")
elif section.section_type == 'scene':
scene_count += 1
print(f"\n Total chapters: {chapter_count}")
print(f" Total scenes: {scene_count}")
# Test audio content extraction
print("\n4. Testing audio content extraction...")
if manuscript.sections:
first_section = manuscript.sections[0]
audio_content = get_section_content_for_audio(first_section)
print(f" First section audio content length: {len(audio_content)} chars")
print(f" Preview: {audio_content[:200]}...")
# Test dialogue extraction
print("\n5. Testing dialogue extraction...")
all_dialogues = []
for section in manuscript.sections:
dialogues = extract_dialogue_lines(section)
all_dialogues.extend(dialogues)
print(f" Total dialogue lines extracted: {len(all_dialogues)}")
# Show sample dialogues
if all_dialogues:
print("\n Sample dialogues:")
for d in all_dialogues[:5]:
print(f" Speaker: {d['speaker']}")
print(f" Dialogue: {d['dialogue'][:80]}...")
print()
# Get document stats
print("\n6. Document statistics:")
stats = get_document_stats(text)
print(f" Total characters: {stats['total_chars']}")
print(f" Total lines: {stats['total_lines']}")
print(f" Word count: {stats['word_count']}")
print(f" Chapters: {stats['chapter_count']}")
print(f" Scenes: {stats['scene_count']}")
print(f" Dialogue lines: {stats['dialogue_count']}")
print("\n Top character mentions:")
for name, count in list(stats['character_mentions'].items())[:10]:
print(f" {name}: {count}")
return validation['valid']
def test_entity_extraction():
"""Test entity extraction on DealHunterShip."""
print("\n" + "=" * 80)
print("TESTING ENTITY EXTRACTION")
print("=" * 80)
# Read the manuscript
manuscript_path = Path(__file__).parent.parent / "DealHunterShip.txt"
if not manuscript_path.exists():
print(f"ERROR: Manuscript not found at {manuscript_path}")
return False
with open(manuscript_path, encoding='utf-8') as f:
text = f.read()
print(f"\nManuscript loaded: {len(text)} characters")
# Extract characters
print("\n1. Extracting characters...")
characters = extract_characters_from_text(text)
print(f" Characters found: {len(characters)}")
# Show top characters
print("\n2. Top characters by dialogue count:")
for char in characters[:15]:
if char.dialogue_count > 0:
print(f"\n {char.canonical_name}:")
print(f" Dialogue lines: {char.dialogue_count}")
if char.aliases:
print(f" Aliases: {', '.join(char.aliases[:3])}")
if char.voice_clues:
print(f" Voice clues: {', '.join(char.voice_clues[:2])}")
if char.sample_dialogues:
print(f" Sample: {char.sample_dialogues[0][:60]}...")
# Merge characters
print("\n3. Merging characters with aliases...")
merged = merge_characters(characters)
print(f" Characters after merge: {len(merged)}")
# Save results
output_path = Path(__file__).parent.parent / "DealHunterShip_characters.json"
output_data = {
"book_title": "Deal Hunter",
"total_characters_found": len(merged),
"characters": []
}
for char in merged:
output_data["characters"].append({
"canonical_name": char.canonical_name,
"aliases": char.aliases,
"is_speaker": char.is_speaker,
"dialogue_count": char.dialogue_count,
"voice_clues": char.voice_clues,
"sample_dialogues": char.sample_dialogues,
"qwen_voice_prompt": "" # To be filled by voice agent
})
with open(output_path, 'w', encoding='utf-8') as f:
json.dump(output_data, f, indent=2, ensure_ascii=False)
print(f"\n Results saved to: {output_path}")
return True
def test_combined_workflow():
"""Test the combined parser + extraction workflow."""
print("\n" + "=" * 80)
print("TESTING COMBINED WORKFLOW")
print("=" * 80)
# Read the manuscript
manuscript_path = Path(__file__).parent.parent / "DealHunterShip.txt"
if not manuscript_path.exists():
print(f"ERROR: Manuscript not found at {manuscript_path}")
return False
with open(manuscript_path, encoding='utf-8') as f:
text = f.read()
# Parse manuscript
print("\n1. Parsing manuscript...")
manuscript = parse_manuscript(text)
print(f" Sections: {len(manuscript.sections)}")
# Extract characters
print("\n2. Extracting characters...")
characters = extract_characters_from_text(text)
print(f" Characters: {len(characters)}")
# Match characters to sections
print("\n3. Matching characters to sections...")
character_sections = {}
for char in characters[:10]: # Top 10 characters
char_sections = []
for section in manuscript.sections:
if char.canonical_name in section.content or \
any(alias in section.content for alias in char.aliases):
char_sections.append({
'chapter': section.chapter_number,
'scene': section.scene_index,
'title': section.chapter_title
})
character_sections[char.canonical_name] = char_sections
print("\n Character appearances by chapter:")
for char_name, sections in character_sections.items():
if sections:
chapter_nums = sorted(set(s['chapter'] for s in sections))
print(f" {char_name}: Chapters {chapter_nums}")
# Generate voice prompts
print("\n4. Generating voice prompts...")
voice_prompts = {}
for char in characters[:10]:
if char.is_speaker and char.voice_clues:
# Simple voice prompt generation based on clues
gender = "female" if any(word in char.canonical_name.lower() for word in ['princess', 'kainda', 'vicky', 'nanny', 'willamina']) else "male"
age = "young" if any(word in char.voice_clues for word in ['young', 'youth']) else "mature"
voice_prompts[char.canonical_name] = {
"gender": gender,
"age": age,
"clues": char.voice_clues[:3],
"prompt": f"A {age} {gender} voice with {', '.join(char.voice_clues[:2])} characteristics."
}
print(f" Voice prompts generated: {len(voice_prompts)}")
# Save combined results
output_path = Path(__file__).parent.parent / "DealHunterShip_analysis.json"
output_data = {
"manuscript": {
"title": manuscript.title,
"author": manuscript.author,
"sections": len(manuscript.sections),
"chapters": len([s for s in manuscript.sections if s.section_type == 'chapter']),
},
"characters": [
{
"name": char.canonical_name,
"aliases": char.aliases,
"is_speaker": char.is_speaker,
"dialogue_count": char.dialogue_count,
"voice_clues": char.voice_clues,
"voice_prompt": voice_prompts.get(char.canonical_name, {})
}
for char in characters[:20]
],
"character_sections": character_sections
}
with open(output_path, 'w', encoding='utf-8') as f:
json.dump(output_data, f, indent=2, ensure_ascii=False)
print(f"\n Analysis saved to: {output_path}")
return True
def main():
"""Run all tests."""
print("UmbraAudio Document Parser & Entity Extraction Tests")
print("=" * 80)
results = {}
try:
results['document_parser'] = test_document_parser()
except Exception as e:
print(f"ERROR in document parser test: {e}")
results['document_parser'] = False
try:
results['entity_extraction'] = test_entity_extraction()
except Exception as e:
print(f"ERROR in entity extraction test: {e}")
results['entity_extraction'] = False
try:
results['combined_workflow'] = test_combined_workflow()
except Exception as e:
print(f"ERROR in combined workflow test: {e}")
results['combined_workflow'] = False
# Summary
print("\n" + "=" * 80)
print("TEST SUMMARY")
print("=" * 80)
all_passed = True
for test_name, passed in results.items():
status = "PASS" if passed else "FAIL"
print(f" {test_name}: {status}")
if not passed:
all_passed = False
print("\n" + "=" * 80)
if all_passed:
print("ALL TESTS PASSED!")
else:
print("SOME TESTS FAILED!")
print("=" * 80)
return 0 if all_passed else 1
if __name__ == "__main__":
sys.exit(main())

309
scripts/test_tts.py Normal file
View File

@@ -0,0 +1,309 @@
#!/usr/bin/env python3
"""
Test script for Qwen3-TTS voice synthesis.
Tests the TTS server with character voice prompts.
"""
import sys
import time
from pathlib import Path
# Add parent directory to path
sys.path.insert(0, str(Path(__file__).parent.parent))
from umbra_audio.core.tts_client import QwenTTSClient
def test_tts_connection():
"""Test connection to TTS server."""
print("=" * 80)
print("TESTING TTS CONNECTION")
print("=" * 80)
client = QwenTTSClient()
try:
# Test with a simple text
print("\n1. Testing basic synthesis...")
start_time = time.time()
audio_data = client.synthesize_speech(
text="Hello, this is a test.",
voice_prompt="A clear, neutral voice."
)
elapsed = time.time() - start_time
print(f" Success! Audio data: {len(audio_data)} bytes")
print(f" Time taken: {elapsed:.2f} seconds")
# Save the test audio
output_path = Path(__file__).parent.parent / "test_audio_basic.wav"
with open(output_path, 'wb') as f:
f.write(audio_data)
print(f" Saved to: {output_path}")
return True
except Exception as e:
print(f" Error: {e}")
return False
finally:
client.close()
def test_character_voices():
"""Test synthesis with character voice prompts."""
print("\n" + "=" * 80)
print("TESTING CHARACTER VOICES")
print("=" * 80)
client = QwenTTSClient()
# Test characters with voice prompts
test_characters = [
{
"name": "Princess Kainda",
"voice_prompt": "A young adult female in her early 20s with a clear, bright mid-range voice. Speaks with a curious, confident cadence and a refined, royal accent.",
"test_line": "I will not be trapped in my cabin any longer. I want to see what's out there."
},
{
"name": "Captain Hunter",
"voice_prompt": "A middle-aged male in his 40s with a gravelly, low-pitched baritone. Speaks with a slow, deliberate pace, rough texture, and authoritative delivery.",
"test_line": "Orders, Captain. We need to move fast before the salvagers arrive."
},
{
"name": "King Kabir",
"voice_prompt": "A senior male in his 60s with a resonant, regal baritone. Speaks with a slow, dignified pace and an authoritative, paternal tone.",
"test_line": "My daughter has always been headstrong. Perhaps that is what we need in these troubled times."
},
{
"name": "Nanny (Robot)",
"voice_prompt": "A synthesized female voice with a smooth, neutral tone. Speaks with a steady, measured cadence and precise, matter-of-fact delivery.",
"test_line": "Princess, please put this on quickly. We are under attack by pirates."
},
{
"name": "Aunt Vicky",
"voice_prompt": "A mature female in her 50s with a warm, concerned alto. Speaks with a gentle, maternal cadence and a worried, caring tone.",
"test_line": "Who is the captain of this ship? I need to speak with him immediately."
}
]
results = []
for i, char in enumerate(test_characters):
print(f"\n{i+1}. Testing {char['name']}...")
try:
start_time = time.time()
audio_data = client.synthesize_speech(
text=char['test_line'],
voice_prompt=char['voice_prompt']
)
elapsed = time.time() - start_time
# Save audio
safe_name = char['name'].replace(' ', '_').replace('(', '').replace(')', '')
output_path = Path(__file__).parent.parent / f"test_audio_{safe_name}.wav"
with open(output_path, 'wb') as f:
f.write(audio_data)
results.append({
"name": char['name'],
"success": True,
"audio_size": len(audio_data),
"time_seconds": elapsed,
"output_path": str(output_path)
})
print(f" Success! Audio: {len(audio_data)} bytes, Time: {elapsed:.2f}s")
print(f" Saved to: {output_path}")
except Exception as e:
results.append({
"name": char['name'],
"success": False,
"error": str(e)
})
print(f" Error: {e}")
client.close()
# Summary
print("\n" + "=" * 80)
print("CHARACTER VOICE TEST SUMMARY")
print("=" * 80)
successful = sum(1 for r in results if r['success'])
print(f"Successful: {successful}/{len(results)}")
for r in results:
status = "PASS" if r['success'] else "FAIL"
print(f" {r['name']}: {status}")
if r['success']:
print(f" Size: {r['audio_size']} bytes, Time: {r['time_seconds']:.2f}s")
else:
print(f" Error: {r.get('error', 'Unknown')}")
return successful == len(results)
def test_dialogue_extraction():
"""Test synthesis with actual dialogue from the manuscript."""
print("\n" + "=" * 80)
print("TESTING DIALOGUE SYNTHESIS")
print("=" * 80)
client = QwenTTSClient()
# Real dialogue from DealHunterShip
dialogues = [
{
"speaker": "Kainda",
"text": "Where is the ship that brought me? Did you fire on that ship?",
"voice_prompt": "A young adult female in her early 20s with a clear, bright mid-range voice. Speaks with a curious, confident cadence and a refined, royal accent."
},
{
"speaker": "Hunter",
"text": "Orders, Captain.",
"voice_prompt": "A middle-aged male in his 40s with a gravelly, low-pitched baritone. Speaks with a slow, deliberate pace, rough texture, and authoritative delivery."
},
{
"speaker": "Kabir",
"text": "Or I can let her run loose and watch the fun.",
"voice_prompt": "A senior male in his 60s with a resonant, regal baritone. Speaks with a slow, dignified pace and an authoritative, paternal tone."
}
]
results = []
for i, d in enumerate(dialogues):
print(f"\n{i+1}. Synthesizing {d['speaker']}'s dialogue...")
try:
start_time = time.time()
audio_data = client.synthesize_speech(
text=d['text'],
voice_prompt=d['voice_prompt']
)
elapsed = time.time() - start_time
# Save audio
output_path = Path(__file__).parent.parent / f"test_dialogue_{d['speaker'].lower()}.wav"
with open(output_path, 'wb') as f:
f.write(audio_data)
results.append({
"speaker": d['speaker'],
"success": True,
"audio_size": len(audio_data),
"time_seconds": elapsed
})
print(f" Success! Audio: {len(audio_data)} bytes, Time: {elapsed:.2f}s")
except Exception as e:
results.append({
"speaker": d['speaker'],
"success": False,
"error": str(e)
})
print(f" Error: {e}")
client.close()
# Summary
print("\n" + "=" * 80)
print("DIALOGUE SYNTHESIS SUMMARY")
print("=" * 80)
successful = sum(1 for r in results if r['success'])
print(f"Successful: {successful}/{len(results)}")
for r in results:
status = "PASS" if r['success'] else "FAIL"
print(f" {r['speaker']}: {status}")
return successful == len(results)
def test_voice_prompt_validation():
"""Test voice prompt validation."""
print("\n" + "=" * 80)
print("TESTING VOICE PROMPT VALIDATION")
print("=" * 80)
client = QwenTTSClient()
test_prompts = [
"A young adult female in her early 20s with a clear, bright mid-range voice. Speaks with a curious, confident cadence and a refined, royal accent.",
"A middle-aged male with a gravelly, low-pitched baritone.",
"This is a very long prompt that exceeds the forty word limit and should fail validation because it contains too many words for the voice synthesis system to handle properly.",
"A voice that sounds like a mysterious philosopher from ancient times."
]
for i, prompt in enumerate(test_prompts):
print(f"\n{i+1}. Validating prompt: {prompt[:50]}...")
result = client.validate_voice_prompt(prompt)
print(f" Valid: {result['valid']}")
print(f" Word count: {result['word_count']}")
if result['issues']:
print(f" Issues: {result['issues']}")
client.close()
return True
def main():
"""Run all TTS tests."""
print("UmbraAudio Voice Synthesis Tests")
print("=" * 80)
results = {}
try:
results['connection'] = test_tts_connection()
except Exception as e:
print(f"ERROR in connection test: {e}")
results['connection'] = False
try:
results['character_voices'] = test_character_voices()
except Exception as e:
print(f"ERROR in character voice test: {e}")
results['character_voices'] = False
try:
results['dialogue'] = test_dialogue_extraction()
except Exception as e:
print(f"ERROR in dialogue test: {e}")
results['dialogue'] = False
try:
results['validation'] = test_voice_prompt_validation()
except Exception as e:
print(f"ERROR in validation test: {e}")
results['validation'] = False
# Final summary
print("\n" + "=" * 80)
print("FINAL TEST SUMMARY")
print("=" * 80)
all_passed = True
for test_name, passed in results.items():
status = "PASS" if passed else "FAIL"
print(f" {test_name}: {status}")
if not passed:
all_passed = False
print("\n" + "=" * 80)
if all_passed:
print("ALL TESTS PASSED!")
else:
print("SOME TESTS FAILED!")
print("=" * 80)
return 0 if all_passed else 1
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,172 @@
# UmbraAudio — Project Overview & Architecture
## Purpose
UmbraAudio is a quality-first, multi-pass audiobook generation system that converts English manuscripts (EPUB, PDF, TXT, MD) into fully produced audiobooks using neural TTS. Each pass has quality gates. Quality is the primary concern; generation speed is secondary.
Two acoustic-quality invariants run through every pass and are non-negotiable for release:
1. **Narrator consistency** — the narrator's voice stays calm, even, and low-emphasis for the entire book, independent of how dramatic the underlying scene text is.
2. **Cast distinctiveness** — every character who shares dialogue proximity with another character must be *audibly* distinguishable, not just textually different.
A third principle governs delivery: **punctuation and typography are prosody instructions, not decoration.** A question mark, an ellipsis, an em-dash interruption, or a shouted ALL-CAPS word must change how a line is spoken, not just how it reads.
A book is a project. Its source, analysis, intermitent files, and outputs are kept seperate from other projects.
## High-Level Pipeline
```
EPUB/PDF/TXT/MD
│
▼
┌─────────────────────────────────────────────────┐
│ PASS 1a: PARSE │
│ Parse manuscript into chapters and scense "<<<>>>"│
│ Preserves emphasis markup + punctuation verbatim│
│ Output: chapters table (source_text) │
└─────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────┐
│ PASS 1b: CHARACTER DISCOVERY │
│ LLM chapter-by-chapter character extraction │
│ Merge across chapters, deduplicate aliases │
│ Narrator identified (not a character) │
| identify characteristics/voice of each character
| Each voice must be unique for the listener.
│ Voice reference generation via Qwen3-TTS │
│ Verification samples generated │
│ Output: speakers.json, voice_assignments.json │
└─────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────┐
│ PASS 2: TWO-PHASE SEGMENTATION │
│ Phase 1: Mechanical segmenter splits text │
│ - Sentence splitting at .!? │
│ - Dialogue detection via quotes │
│ - Tag-based attribution (forward/backward) │
│ - Scene break (<<<>>>) handling │
│ Phase 2: LLM speaker attribution │
│ - Attributes ALL dialogue (overrides guesses)│
│ - Resolves pronouns and context │
│ - Uses character profiles from speakers.json │
| Uterances are narrator "Bob said," and then Bob speaking.
│ Output: utterances table │
└─────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────┐
│ PASS 3: AUDIO GENERATION │
│ Qwen3-TTS Base → voice cloning from references │
| Each character has a unique voice the listener can decern
│ Voice assignment from voice_assignments.json │
│ Retry logic for rate limiting │
│ Output: chapter_audio/ch{id}/*.wav │
└─────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────┐
│ PASS 4: REVIEW │
│ Web UI for human review of: │
│ - Character profiles and voice assignments │
│ - Voice test samples │
│ - Chapter audio with per-utterance playback │
│ - Speaker attribution accuracy │
│ Output: approved chapters │
└─────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────┐
│ EXPORT │
│ Stitch chapter audio → full audiobook │
│ Output: export/*.m4b, export/*.mp3 │
└─────────────────────────────────────────────────┘
```
## Runtime Data Layout
```
.umbra/ # Runtime data directory
├── project.db # SQLite database
├── speakers.json # Character profiles (61 speakers)
├── voice_assignments.json # Voice assignments (60 mappings)
├── voice_tests/ # Verification audio samples
│ ├── {speaker_name}.wav
│ └── ...
├── chapter_audio/ # Generated chapter audio
│ ├── ch5/
│ │ ├── utt_0000.wav
│ │ └── ...
│ ├── ch6/
│ └── ch7/
├── analysis/
│ └── input.txt # Extracted manuscript text
└── defects.json # Quality defect log
```
## Technology Stack
| Layer | Technology | Version |
|-------|-----------|---------|
| Language | Python | 3.13+ |
| CLI | Click + Rich | 8.1+ / 13.0+ |
| Database | SQLite (aiosqlite) | 0.19+ |
| LLM | OpenAI-compatible (LM Studio) | 1.10+ |
| TTS | Qwen3-TTS 1.7B (local GPU) | 0.1+ |
| LLM Model | gemma-4-12b-it-qat (remote LM Studio, context_length=8192) | remote |
| Web | FastAPI + HTMX + Jinja2 | 0.104+ |
| Parsing | ebooklib, beautifulsoup4, PyMuPDF | 0.18+ / 4.0+ / 1.23+ |
## GPU Requirements
- **Qwen3-TTS 1.7B Base**: ~3.9GB VRAM (voice cloning)
- **Qwen3-TTS 1.7B VoiceDesign**: ~3.9GB VRAM (voice reference generation)
- **LM Studio (gemma-4-12b-it-qat)**: remote server; load with `context_length: 8192` (speaker attribution)
- **Note**: Only one Qwen3-TTS model can be loaded at a time (auto-swaps). LM Studio must be unloaded before TTS generation if VRAM is tight.
## Module Structure
```
umbraaudio/
├── cli.py # Click CLI (all commands)
├── config.py # Pydantic settings (AppConfig tree)
├── database.py # SQLite schema + async CRUD
├── orchestrator.py # Full pipeline coordinator
├── parsers/ # Manuscript parsing (EPUB, PDF, TXT, MD)
├── analysis/ # LLM character discovery, profiling
│ ├── character_discovery.py # Chapter-by-chapter LLM extraction
│ ├── llm_attributor.py # Two-phase speaker attribution
│ ├── llm_segmenter.py # LLM-based segmentation (legacy)
│ └── CHARACTER_DISCOVERY_PROMPT.md
├── mapping/ # Mechanical segmentation, speaker attribution
│ └── segmenter.py # Text splitting + tag-based attribution
├── voices/ # TTS engine and voice management
│ ├── qwen_tts.py # Qwen3-TTS engine (voice design + cloning)
│ ├── lmstudio_lifecycle.py # LM Studio VRAM management
│ ├── edge_tts.py # Edge-TTS wrapper (legacy)
│ ├── voice_designer.py # Voice reference generation pipeline
│ └── embeddings.py # Speaker embedding extraction
├── generation/ # Audio generation
├── web/ # FastAPI review UI
└── scripts/ # Standalone scripts
├── resegment_all.py # Batch segmentation + attribution
├── generate_voice_references.py # Voice ref generation
├── generate_chapter_audio.py # Per-chapter audio
└── generate_all_audio.py # Background full generation
```
## Key Design Decisions
1. **Quality over speed**: Each pass has quality gates; the pipeline can stop and wait for human review
2. **Two-phase segmentation**: Mechanical segmenter handles text splitting (narration by sentence; quoted dialogue kept whole), LLM handles speaker attribution — this division of labor produces a 0.05% dialogue-as-narrator error rate (1/1,882)
3. **LLM attributes all dialogue**: The LLM attributes every dialogue utterance, using the mechanical `current_speaker` as a hint and overriding it when wrong — ambiguous or incorrectly guessed lines get corrected
4. **Narrator is not a character**: Dedicated soft-spoken male voice (en-US-GuyNeural:-10), never participates in character discovery
5. **59 unique voices via Qwen3-TTS**: VoiceDesign generates unique voice profiles from character descriptions; VoiceClone maintains consistency across utterances using reference audio
6. **Character profiles in JSON**: speakers.json stores all character data; voice_assignments.json maps characters to voice references
7. **Scene break handling**: <<<>>> markers preserved as special utterances, LLM maintains context across scene breaks
8. **Web review at checkpoints**: HTMX-powered UI for human approval of voices, speaker assignments, and chapter audio
9. **Punctuation as prosody**: Question marks, ellipses, em-dashes, and ALL-CAPS words change how lines are spoken
10. **Cast-wide distinctiveness gate**: Characters must be audibly distinguishable, not just textually different
11. **Verification phrase**: Rainbow Passage (phonetically balanced paragraph covering all English phonemes) used for voice testing
12. **Author watermark**: "Author B.D. Murphy" embedded in final audiobook

113
specs/01-parsing.md Normal file
View File

@@ -0,0 +1,113 @@
# Spec: EPUB Parsing (Pass 1a)
## Overview
The parser converts manuscripts in EPUB, PDF, TXT, or MD format into a list of chapter objects with title and source text. All parsing logic lives in `umbraaudio/parsers/__init__.py`.
## Entry Point
```python
def parse_manuscript(path: Path, fmt: str) -> list[dict[str, str | None]]
```
Returns: `[{"title": str | None, "text": str}, ...]`
## Format Detection
```python
def detect_format(path: Path) -> str
```
| Extension | Format | Status |
|-----------|--------|--------|
| `.txt` | `"txt"` | Implemented |
| `.md`, `.markdown` | `"md"` | Implemented |
| `.epub` | `"epub"` | Implemented |
| `.pdf` | `"pdf"` | Implemented |
Raises `ValueError` for unsupported formats.
## EPUB Parser
```python
def _parse_epub(path: Path) -> list[dict]
```
- Uses `ebooklib` to iterate over `ITEM_DOCUMENT` items
- Extracts text via `BeautifulSoup.get_text()`
- Title detection: first `h1`, `h2`, or `h3` tag in each document
- Groups consecutive items under the same heading into one chapter
- Falls back to item index if no heading found
### Dependencies
- `ebooklib >= 0.18`
- `beautifulsoup4`
## TXT Parser
```python
def _parse_txt(path: Path) -> list[dict]
```
- Reads file as UTF-8 text
- Splits on newlines
- **Chapter detection**: Lines that are < 100 characters AND entirely UPPERCASE
- Groups text under each detected header into a chapter
- Title = the uppercase header line
## MD Parser
```python
def _parse_md(path: Path) -> list[dict]
```
- Reads file as UTF-8 text
- Splits on lines starting with `#` (any heading level)
- Title = text after stripping leading `#` characters and whitespace
- Groups content between headings into chapter text
## PDF Parser
```python
def _parse_pdf(path: Path) -> list[dict]
```
- Uses PyMuPDF (`fitz`) for text extraction
- Same ALL-UPPERCASE header detection as TXT parser
- Additional constraint: header must have ≤ 8 words
- Groups text between headers into chapters
### Dependencies
- `PyMuPDF >= 1.23`
## Integration with CLI
The `init` command calls `parse_manuscript()`:
```python
@main.command()
@click.argument("manuscript", type=click.Path(exists=True))
@click.option("--name", "-n", help="Project name")
def init(ctx, manuscript, name):
fmt = detect_format(path)
chapters = parse_manuscript(path, fmt)
# Insert into DB: chapters table
```
## Output Schema
Each chapter dict:
```python
{
"title": str | None, # Chapter title (from heading)
"text": str, # Full chapter text
}
```
## Edge Cases
- EPUBs with no headings: falls back to document item index as title
- TXT/PDF with no uppercase headers: entire file treated as one chapter
- Empty chapters: filtered out (no empty source_text)
- Encoding: UTF-8 assumed; no fallback for other encodings
- Nested headings (MD): only first `#` level triggers chapter break

332
specs/02-analysis.md Normal file
View File

@@ -0,0 +1,332 @@
# Spec: Analysis — BookNLP & Character Profiling (Pass 1b)
## Overview
Pass 1b extracts characters, dialogue, and coreferences from chapter text using BookNLP, then generates detailed voice profiles for each character using an LLM. Unusual terms get pronunciation guides.
Critically, this pass is also where voice *characteristics* — soft-spoken, assertive, gravelly, and so on — are converted from free-text description into a small, fixed vocabulary (the **voice attribute taxonomy**). Pass 2 (Voice Design) and Pass 3 (Mapping) both consume this taxonomy directly rather than re-interpreting prose. This is what makes character voices reproducible and what lets the system check, mechanically, whether two characters are actually going to sound different from each other.
The narrator is handled as a special case throughout this pass — see "Narrator Profile" below — because the narrator must not inherit the LLM's free-ranging interpretation that named characters get.
## Components
### 1. BookNLP Engine (`analysis/booknlp_engine.py`)
#### Class: `BookNLPEngine`
```python
class BookNLPEngine:
def __init__(self) -> None
async def analyze(self, text: str, output_dir: Path) -> dict
```
**Initialization:**
- Lazy-loads `BookNLP("en", model_params={"model": "small", "pipeline": "entity,coref,quote"})`
- Model params: entity recognition + coreference resolution + quote attribution
**Analysis Flow:**
1. Writes text to `{output_dir}/input.txt`
2. Runs `self._nlp.process(input_file, output_dir, idd="input")`
3. Parses output files:
- `input.entities` — TSV: entity mentions (PER persons with coref IDs)
- `input.quotes` — TSV: dialogue with speaker attribution
**Output Format:**
```python
{
"characters": [
{
"name": str, # Character name
"mention_count": int, # How many times mentioned
"coref_id": int, # Coreference cluster ID
"context": str, # First 200 chars of chapter text
}
],
"quotes": [
{
"speaker": str, # Speaker name (from BookNLP)
"text": str, # Quoted dialogue text
"char_id": int, # Character ID
}
]
}
```
**Patching Required:**
- `booknlp/english/entity_tagger.py`: `load_state_dict(..., strict=False)` for transformers 5.x
- `booknlp/english/litbank_coref.py`: `load_state_dict(..., strict=False)`
- `booknlp/english/bert_qa.py`: `load_state_dict(..., strict=False)`
### 2. Voice Attribute Taxonomy
Every profiled character (and the narrator, via fixed defaults below) is described by a small set of enumerated dimensions. These are the only values Pass 2 and Pass 3 are allowed to branch on — free-text `voice_description` is for human review and TTS conditioning text, not for program logic.
| Dimension | Allowed Values | Drives |
|---|---|---|
| `pitch_register` | `very_low`, `low`, `mid`, `high`, `very_high` | Sample-text style, embedding-collision grouping |
| `pace_baseline` | `very_slow`, `slow`, `moderate`, `brisk`, `fast` | Default `pace` in manifest before punctuation adjustment |
| `energy` | `subdued`, `soft_spoken`, `measured`, `assertive`, `commanding`, `volatile` | TTS `exaggeration`/`cfg_weight` range (Pass 2); how strongly punctuation cues scale (Pass 3) |
| `timbre` | `warm`, `breathy`, `nasal`, `gravelly`, `clear`, `resonant`, `thin` | Sample-text selection; collision grouping |
| `texture` | `smooth`, `raspy`, `clipped`, `melodic`, `monotone` | Sample-text selection |
| `formality` | `casual`, `conversational`, `formal`, `archaic` | Speech-pattern conditioning text |
| `emotional_baseline` | free-form short tag, e.g. `neutral_calm`, `weary_cynical`, `anxious`, `stoic`, `warm_optimistic` | Default `instruct` framing in Pass 3 |
| `distinguishing_trait` | one short phrase, e.g. `"drawls vowels"`, `"clips sentences short"`, `"trails off when uncertain"` | Human-review summary; tie-breaker in collision review |
`accent_marker` is optional free text (e.g. `"received_pronunciation"`, `"none"`) and is only ever populated when the text itself gives clear evidence (e.g. explicit dialect spelling, a stated nationality with described speech). It is never inferred from a character's name alone, and the profiler must not invent accents that play into ethnic or national stereotyping without direct textual support — if evidence is thin, `accent_marker` stays `"none"`.
### 3. Character Profiler (`analysis/character_profiler.py`)
#### Class: `CharacterProfiler`
```python
class CharacterProfiler:
def __init__(self, llm_config: LLMConfig) -> None
async def profile_character(self, name, aliases, gender, context) -> CharacterProfile
async def check_distinctiveness(
self, profile: CharacterProfile, existing: list[CharacterProfile]
) -> DistinctivenessResult
async def generate_pronunciation_guide(self, terms, context) -> dict[str, str]
```
**LLM Provider Selection (auto mode):**
1. Try LM Studio at `config.llm.lmstudio_base_url/models` (default `http://192.168.123.14:1234/v1`, overridable via `LLMConfig.lmstudio_base_url`); the model is `config.llm.lmstudio_model` (default `gemma-4-12b-it-qat` — must be loaded with `context_length: 8192`), and the configured model is preferred over whatever the server has loaded
2. Fall back to OpenAI (requires `OPENAI_API_KEY`)
3. Raise `RuntimeError` if neither available
**Lifecycle note:** the `lmstudio_lifecycle` module resolves the same URL from config via `load_config().llm.lmstudio_base_url` for server/status operations, with `http://localhost:1234/v1` only as a hardcoded fallback when config cannot be loaded. Standalone scripts (`reattribute_all.py`, `resegment_all.py`, `analyze_emotions.py`) still hardcode the LAN URL (`http://192.168.123.14:1234/v1`) directly; keep the config default and the script constant in sync.
**Character Profile Prompt:**
```
You are an expert audiobook director. Analyze the following character
from a novel and create a detailed voice profile for text-to-speech generation.
Character name: {name}
Known aliases: {aliases}
Gender (inferred): {gender}
Context from the book:
{context}
Already-cast voices in this book (avoid sounding like any of these):
{existing_cast_summary}
Create a JSON profile with these fields:
- personality: Brief personality description (1-2 sentences)
- voice_description: Detailed voice description for TTS voice design
(include gender, age, accent, tone, pitch characteristics)
- emotion_tendencies: List of 3-5 emotions this character commonly expresses
- speech_patterns: How this character speaks (formal/casual, sentence structure, etc.)
- narrator_style: If this character were narrating, what style would they use?
- pitch_register: one of [very_low, low, mid, high, very_high]
- pace_baseline: one of [very_slow, slow, moderate, brisk, fast]
- energy: one of [subdued, soft_spoken, measured, assertive, commanding, volatile]
- timbre: one of [warm, breathy, nasal, gravelly, clear, resonant, thin]
- texture: one of [smooth, raspy, clipped, melodic, monotone]
- formality: one of [casual, conversational, formal, archaic]
- emotional_baseline: short tag, e.g. "weary_cynical"
- distinguishing_trait: one short phrase
- accent_marker: only set if the text gives direct evidence; otherwise "none"
Return ONLY valid JSON, no other text.
```
`existing_cast_summary` is a compact list of `{name, pitch_register, energy, timbre}` for every character already profiled in this book (built incrementally as profiling proceeds), so the LLM has a chance to avoid an obvious collision before one is even generated.
**LLM Settings:**
- Model: GPT-4o (or local LM Studio model)
- Temperature: 0.3
- Max tokens: 4096
- Context truncated to 2000 chars
**Response Parsing:**
1. Try full JSON parse
2. Fallback: extract first `{...}` block from response
3. **Enum validation:** any taxonomy field whose value is not in its allowed set is coerced to a defined fallback (the dimension's middle/neutral value, e.g. `energy → measured`, `pace_baseline → moderate`) and a warning is logged with character name + bad value. The pipeline never raises on this — a bad enum degrades to a safe default rather than blocking the run.
4. Returns `CharacterProfile` dataclass
#### Cast-Aware Distinctiveness Check
This is a text-level pre-check before any audio is generated; it catches the cheap, obvious collisions before Pass 2's more expensive embedding-based check runs.
```python
@dataclass
class DistinctivenessResult:
is_distinct: bool
colliding_with: list[str] # character names
matched_dimensions: list[str] # which taxonomy fields matched
```
Algorithm:
1. Compare the new profile's `(pitch_register, pace_baseline, energy, timbre, texture, formality)` tuple against every existing profiled character who **co-occurs** with this one — defined as: both have at least one BookNLP-attributed quote within the same chapter.
2. If **4 or more of the 6 dimensions match exactly** with any co-occurring character, flag a collision.
3. On collision: re-prompt the LLM once with an explicit instruction — "Character X already has {colliding dimensions}; revise this character so it differs in at least two of: pitch_register, energy, timbre, texture, while staying consistent with the text evidence." Maximum 2 retries.
4. If still colliding after retries, accept the profile but mark `characters.status = 'collision_pending'` — this surfaces in the Pass 2 quality gate (see `03-voice-design.md`) rather than blocking Pass 1b.
Characters who never share a chapter with attributed dialogue are not compared against each other — two minor characters in different parts of the book sounding similar is not a defect worth flagging.
### 4. Narrator Profile — Required Defaults
The narrator's `CharacterProfile` is **not** produced by the generic LLM character-profiling call above. It is constructed directly with fixed values, because the narrator's job is to disappear behind the prose, not to have a distinct dramatic personality:
```python
NARRATOR_DEFAULT_PROFILE = CharacterProfile(
name="narrator",
is_narrator=True,
pitch_register="mid",
pace_baseline="moderate",
energy="measured",
timbre="clear",
texture="smooth",
formality="conversational",
emotional_baseline="neutral_calm",
distinguishing_trait="even, unhurried, minimal emphasis",
accent_marker="none",
voice_description=(
"A calm, clear, even-toned narrator. Minimal emotional coloring, "
"no shouting, no dramatic swings in pace or pitch. Reads "
"descriptive prose steadily regardless of how dramatic the "
"events being described are."
),
)
```
This profile is **never** sent through `check_distinctiveness` against itself, and is excluded from the LLM prompt's "already-cast voices" list (the narrator is an anchor, not a competing cast member).
**First-person exception:** if BookNLP/coref analysis indicates the book is narrated in first person by a named character (the narrator *is* a character in the story, e.g. a memoirist or first-person protagonist), that character still gets a full, normally-profiled `CharacterProfile` with `is_narrator=True` — they are allowed a real personality. However, the calm/measured ceiling above still applies specifically to their **narration-type** utterances (descriptive/expository prose); their **dialogue-type** utterances (lines they speak as a character within remembered scenes) use their full taxonomy-driven range. This split is enforced in Pass 3 (see `04-mapping.md`, "Narrator Constraint"), not here — Pass 1b's job is only to set `is_narrator=True` correctly.
### 5. Character Profile Dataclass (`analysis/__init__.py`)
```python
@dataclass
class CharacterProfile:
name: str
aliases: list[str] = field(default_factory=list)
gender: str | None = None
age_range: str | None = None
is_narrator: bool = False
personality: str | None = None
voice_description: str | None = None
emotion_tendencies: list[str] = field(default_factory=list)
speech_patterns: str | None = None
narrator_style: str | None = None
# Voice attribute taxonomy (see "Voice Attribute Taxonomy" above)
pitch_register: str = "mid"
pace_baseline: str = "moderate"
energy: str = "measured"
timbre: str = "clear"
texture: str = "smooth"
formality: str = "conversational"
emotional_baseline: str = "neutral_calm"
distinguishing_trait: str | None = None
accent_marker: str = "none"
```
Defaults match the narrator baseline deliberately — an unprofiled character (e.g. a one-line walk-on the profiler skipped) renders as plain and unremarkable rather than as an error or as something randomly dramatic.
#### Taxonomy Revision (Post-Hoc)
A character's taxonomy is not frozen once Pass 1b completes. It can be revised later — specifically when a human selects **"Always"** on a detected speech descriptor during Pass 3's review queue (e.g. a line tagged "with his raspy voice" gets promoted from a one-off note to a permanent trait). See `04-mapping.md`, "Speech Descriptor Extractor & Review Queue."
That revision is never a direct field write. It always routes through the same `apply_edit_prompt()` mechanism described in `03-voice-design.md`:
1. The detected phrase seeds an edit prompt (human can refine the wording before submitting)
2. The LLM translates it into specific taxonomy deltas (e.g. `timbre: clear → gravelly`)
3. The character's voice reference is regenerated against the new taxonomy
4. The **cast-aware distinctiveness check re-runs against the full cast** — not just a sanity check, but a requirement: changing one character's `timbre` from `clear` to `gravelly` can create a brand-new collision with a different character who was previously safely distinct
5. `characters.status` drops from `voiced_approved` back to a pending-review state for the duration of this loop, exactly as if Pass 2 were running for the first time on this character
6. Only on re-approval does the revised taxonomy become the character's new permanent baseline
This keeps the system honest about a real trade-off: a transient state ("her voice was hoarse from crying in this one scene") should never silently become a permanent trait. Promoting something to "Always" costs exactly as much process as the original profiling did, on purpose.
### 6. Pronunciation Generator (`analysis/pronunciation.py`)
#### Class: `PronunciationGenerator`
```python
class PronunciationGenerator:
def __init__(self, llm_config: Any) -> None
def find_unusual_terms(self, text: str) -> list[str]
def phonetic_to_ssml(self, word: str, phonetic: str) -> str
```
**Term Detection Algorithm (3 regex passes):**
1. **Proper nouns:** `[A-Z][a-z]+(\s[A-Z][a-z]+)*` — filters common words (The, And, But, For, Not)
2. **Foreign words:** Words containing accented characters (é, ñ, ü, etc.)
3. **Hyphenated words:** Words containing hyphens
**Note — not the same as shouted ALL-CAPS:** this generator is for unusual/foreign *pronunciation*, not for vocal emphasis. In-line ALL-CAPS words inside dialogue (e.g. `"NO!"`) are a Pass 3 prosody signal (see `04-mapping.md`), not a pronunciation-guide entry. Do not route ALL-CAPS dialogue words into `find_unusual_terms`.
**Pronunciation via LLM:**
- Prompt: "Give IPA pronunciation for: {terms}"
- Temperature: 0.1 (low for accuracy)
- Context truncated to 1000 chars
**SSML Output:**
```xml
<phoneme alphabet="ipa" ph="{phonetic}">{word}</phoneme>
```
## Orchestrator Flow
```python
# In orchestrator.py run_pipeline():
async def _run_analysis(db, project_dir, chapters_db):
# 0. Assign the fixed narrator profile (no LLM call)
narrator_profile = NARRATOR_DEFAULT_PROFILE
await db.upsert_character_profile("narrator", narrator_profile)
# 1. BookNLP per chapter
for ch in chapters_db:
result = booknlp.analyze(ch["source_text"], analysis_dir)
# 2. Merge characters across chapters
merged = _merge_characters(raw_characters, all_quotes)
# 3. Profile each character via LLM, checking distinctiveness as we go
cast_so_far: list[CharacterProfile] = []
for char in merged[:30]:
profile = await profiler.profile_character(...)
result = await profiler.check_distinctiveness(profile, cast_so_far)
if not result.is_distinct:
profile = await profiler.profile_character(..., retry_hint=result)
cast_so_far.append(profile)
await db.update_character_profile(char_id, profile)
# 4. Pronunciation guide for unusual terms
guide = await profiler.generate_pronunciation_guide(terms, context)
```
## Character Merging Algorithm
```python
def _merge_characters(raw_characters: list[dict], quotes: list[dict]) -> list[dict]
```
1. **Filter**: Remove pronouns (I, you, he, she, it, we, they...) and NOISE_CHARACTERS (people, everyone, someone, pirates, warbots...)
2. **Filter**: Remove single-character names
3. **Merge by lowercase**: Group variants (e.g., "Princess", "princess", "PRINCESS" → one entry)
4. **Keep highest mention count** as primary
5. **Build alias groups**: Case-variant duplicates become aliases
6. **Attach quote context**: First 200 chars from associated quotes
7. **Sort by mention count** (descending)
## Noise Characters (Excluded from Voice Design)
```python
NOISE_CHARACTERS = {
"people", "everyone", "someone", "anyone", "nobody", "something",
"nothing", "everything", "the fighters", "the fugitives", "the pirates",
"Warbots", "the Warbots", "pirates",
}
```
## Database Updates
| Stage | Table | Fields Set |
|-------|-------|------------|
| Narrator assigned | `characters` | fixed taxonomy fields, `is_narrator=True`, `status='profiled'` (skips `'identified'`) |
| Characters extracted | `characters` | `name`, `aliases`, `gender`, `age_range`, `status='identified'` |
| Profiled via LLM | `characters` | `profile_json`, `voice_description`, taxonomy fields, `status='profiled'` |
| Distinctiveness flagged | `characters` | `status='collision_pending'` (surfaced again in Pass 2 gate) |
| Pronunciation | `pronunciation_guide` | `term`, `phonetic`, `notes` |
| Voice designed | `characters` | `voice_ref_path`, `speaker_embedding`, `status='voiced'` |

266
specs/03-voice-design.md Normal file
View File

@@ -0,0 +1,266 @@
# Spec: Voice Design (Pass 2)
## Overview
Pass 2 generates unique voice reference audio for each character using Chatterbox TTS. Each character's TTS parameters (`exaggeration`, `cfg_weight`) are now derived in two steps rather than one: first, the character's **voice attribute taxonomy** (from Pass 1b) selects a parameter *range* appropriate to how that character is actually described; second, an MD5 hash of the name picks a deterministic point *within* that range, so re-running the pipeline always reproduces the same voice while two characters who share the same `energy` value still land at different points in the range.
The narrator is the one character whose parameters are **not** range-and-hash derived at all — they are fixed constants, because the narrator must sound the same in every project, every run, forever (see "Narrator Parameters" below).
This pass also adds a hard quality gate: after every voice reference is generated, the system checks whether any two co-occurring characters are acoustically too similar, and blocks the handoff to Pass 3 until that's resolved or a human explicitly overrides it.
## Components
### Voice Designer (`voices/voice_designer.py`)
```python
class VoiceDesigner:
def __init__(self, config: TTSConfig) -> None
def generate_voice_reference(
self,
character: CharacterProfile,
output_dir: Path,
sample_text: str | None = None,
) -> Path
def generate_all_references(
self,
characters: list[CharacterProfile],
output_dir: Path,
) -> dict[str, Path]
def check_cast_distinctiveness(
self,
characters: list[CharacterProfile],
embeddings: dict[str, bytes],
cooccurrence: dict[str, set[str]],
) -> list[VoiceCollision]
```
### Voice Reference Generation Flow
```
CharacterProfile
│
├── _character_params(character) → (exaggeration, cfg_weight)
│ energy → base range (ATTRIBUTE_PARAM_MAP)
│ MD5 hash of name → deterministic position within that range
│ narrator → fixed constants, taxonomy/hash ignored entirely
│
├── _generate_sample_text(character) → str
│ Selected from taxonomy fields (energy, timbre, pitch_register),
│ falling back to voice_description keyword sniffing only for
│ legacy profiles created before the taxonomy existed
│
└── ChatterboxEngine.generate(
text=sample_text,
output_path={name}_ref.wav,
voice_ref=None, # No prior ref for initial generation
instruct=voice_description, # Text conditioning (not forwarded to model)
exaggeration=exag,
cfg_weight=cfg,
)
│
└── ChatterboxTTS.generate(text, audio_prompt_path=None, exaggeration, cfg_weight)
│
└── Perth watermarker (if available)
│
└── Write WAV via soundfile.write()
```
### Attribute-Driven Parameter Ranges
`energy` is the primary driver of expressive intensity. `cfg_weight` runs inversely to `exaggeration`: higher `cfg_weight` means the model adheres closely to a neutral, reference-like delivery; lower `cfg_weight` paired with higher `exaggeration` gives a more emphatic, expressive delivery. A calm, measured character should have low exaggeration and high cfg_weight; a volatile, commanding character should have the opposite.
```python
ATTRIBUTE_PARAM_MAP: dict[str, tuple[tuple[float, float], tuple[float, float]]] = {
# energy: (exaggeration_range, cfg_weight_range)
"subdued": ((0.15, 0.30), (0.55, 0.70)),
"soft_spoken": ((0.20, 0.35), (0.45, 0.60)),
"measured": ((0.30, 0.45), (0.40, 0.55)),
"assertive": ((0.45, 0.60), (0.30, 0.45)),
"commanding": ((0.55, 0.70), (0.20, 0.35)),
"volatile": ((0.60, 0.80), (0.15, 0.30)),
}
```
### Per-Character Parameter Derivation
```python
def _character_params(self, character: CharacterProfile) -> tuple[float, float]:
if character.is_narrator and character.name == "narrator":
return NARRATOR_EXAGGERATION, NARRATOR_CFG_WEIGHT
exag_range, cfg_range = ATTRIBUTE_PARAM_MAP[character.energy]
name_hash = int(hashlib.md5(character.name.encode()).hexdigest()[:8], 16)
exaggeration = exag_range[0] + ((name_hash % 100) / 100.0) * (exag_range[1] - exag_range[0])
cfg_weight = cfg_range[0] + (((name_hash >> 8) % 100) / 100.0) * (cfg_range[1] - cfg_range[0])
return exaggeration, cfg_weight
```
**Example Values:**
| Character | `energy` | MD5 Prefix | Exaggeration | Cfg Weight |
|-----------|----------|-----------|--------------|------------|
| narrator | — (fixed) | — | **0.25 (fixed)** | **0.65 (fixed)** |
| Princess Kainda | assertive | a3f7... | 0.512 | 0.341 |
| Captain | commanding | 4b2e... | 0.598 | 0.247 |
| Deal Hunter | measured | 8c1d... | 0.368 | 0.452 |
| Nanny | soft_spoken | f5a3... | 0.248 | 0.512 |
| The robot | subdued | 7e9b... | 0.198 | 0.612 |
| Aunt Vicky | measured | 2d4c... | 0.402 | 0.481 |
Values are illustrative; exact figures depend on the live MD5 digest, but every character's pair now falls inside the band their `energy` attribute defines — an "assertive" character can no longer accidentally land in subdued-character territory the way pure name-hashing allowed.
### Narrator Parameters
```python
NARRATOR_EXAGGERATION = 0.25
NARRATOR_CFG_WEIGHT = 0.65
```
These are constants, not derived from anything character-specific — explicitly so the narrator sounds identical across every book and every pipeline run, which is the acoustic anchor the rest of the cast is heard against. Do not route the narrator through `ATTRIBUTE_PARAM_MAP` or the hash function, even though `NARRATOR_DEFAULT_PROFILE.energy == "measured"` would otherwise resolve to a valid range — the explicit override always wins for `character.name == "narrator"`.
### Sample Text Generation
```python
def _generate_sample_text(self, character: CharacterProfile) -> str
```
Primary path: select sample-text style from `(character.energy, character.timbre, character.pitch_register)` directly — deterministic, no string matching.
| `energy` | `timbre` | Sample Text Style |
|---|---|---|
| `subdued` / `soft_spoken` | any | Quiet, understated first-person line |
| `measured` | `gravelly`, `nasal`, `monotone`-adjacent | Wise elder / contemplative narration |
| `subdued` | `monotone`, `clear` | Flat, mechanical/robotic delivery |
| `volatile` / `commanding` | any | Assertive dramatic narration |
| any, `pitch_register=high` + youthful context | — | Enthusiastic, energetic dialogue |
| narrator (fixed) | — | Descriptive landscape prose, even pace |
Legacy fallback (only used when a profile predates the taxonomy and taxonomy fields are still at their defaults): substring-match `voice_description` for keywords (`female/woman/girl/she`, `old/elder/gruff`, `robot/machine/synth`, `child/young`, `narrator`), same behavior as before.
### Output
- **File**: `{output_dir}/{character_name_lower}_ref.wav`
- **Format**: WAV, 24kHz sample rate, mono
- **Size**: Typically 100KB–500KB depending on text length
### Voice Reference vs Chapter Generation
| Aspect | Voice Reference | Chapter Generation |
|--------|----------------|-------------------|
| `voice_ref` param | `None` | Path to reference WAV |
| Purpose | Create initial voice identity | Clone voice to new text |
| `exaggeration`/`cfg_weight` | From taxonomy range + name hash (or narrator constants) | Same values, reused |
| `instruct` param | `voice_description` | `None` (dropped) |
| Watermark | Applied | Applied |
### Speaker Embedding Extraction
After voice reference generation, an embedding is extracted:
```python
embedding = tts.extract_embedding(ref_path)
# Returns: bytes (numpy array serialized via .tobytes())
# Stored in: characters.speaker_embedding column
```
Uses Chatterbox's internal `encode_speaker()` or `encode_spk()` method.
### Cast Distinctiveness Quality Gate
This is the acoustic counterpart to Pass 1b's text-level distinctiveness pre-check, and it is the gate that's allowed to actually block the pipeline.
```python
@dataclass
class VoiceCollision:
character_a: str
character_b: str
similarity: float
remediation_attempts: int
resolved: bool
```
Algorithm:
1. After all references are generated and embeddings extracted, compute pairwise cosine similarity across every character pair, **including the narrator** as a fixed comparison point (no character should sound like the narrator either).
2. For any pair with `similarity > 0.92` **and** who co-occur (per the same co-occurrence definition used in Pass 1b — share an attributed-dialogue chapter): flag a `VoiceCollision`.
3. **Automatic remediation** (up to 3 attempts per flagged character): re-derive parameters using a disambiguated hash input — `hashlib.md5(f"{character.name}#{attempt}".encode())` — keeping the same `ATTRIBUTE_PARAM_MAP` range, regenerate the reference, re-extract the embedding, re-check.
4. If still colliding after 3 attempts: write an entry to `.umbra/defects.json` (`type: "voice_collision"`, both names, similarity score) and set both characters' `status` to `collision_pending` if not already.
5. **Pipeline behavior**: the orchestrator does not proceed to Pass 3 while any `collision_pending` status remains, unless the operator passes `--allow-voice-collisions` (logged in the project's run history) or resolves it via the web review UI.
This mirrors the project's "quality over speed" principle (`00-project-overview.md`): an unresolved collision between two characters who actually talk in the same scene is treated as a release blocker, not a cosmetic issue.
### Human Approval Workflow (Required Gate)
Passing the distinctiveness gate makes a voice *safe to listen to* — it doesn't make it *right*. A character described as "gruff and commanding" can still get a reference that technically sits in the right parameter range and is acoustically distinct from everyone else, while simply not sounding like what the author had in mind. So every character's reference — after the distinctiveness gate, not instead of it — goes through an explicit human checkpoint before it's allowed to become the voice used anywhere else in the pipeline.
```python
class VoiceReviewSession:
def submit_for_review(self, character: CharacterProfile, ref_path: Path) -> ReviewItem
def approve(self, character_name: str) -> None
def reject(self, character_name: str) -> Path
async def edit_with_prompt(self, character_name: str, prompt: str) -> CharacterProfile
```
**Flow, per character:**
1. **Generate** — as above: taxonomy-ranged params, hash-disambiguated, embedding extracted, distinctiveness-gated.
2. **Present** — queued in the web review UI: character name, `voice_description`, taxonomy summary, a playable reference WAV, and three actions.
3. **Human chooses one:**
| Action | Effect |
|---|---|
| **Approve** | `status = 'voiced_approved'`. Parameters are locked as this character's "normal" baseline. Nothing else in the pipeline may change them except a future taxonomy revision (`02-analysis.md`, "Taxonomy Revision"). |
| **Reject** | Regenerate using a disambiguated hash variant (`name#attempt`, same mechanism as collision remediation) — same `ATTRIBUTE_PARAM_MAP` range, same described character, just a different roll within it. Re-extract embedding, re-queue. This is for "wrong roll of the dice," not "wrong direction." |
| **Edit with prompt** | Human supplies free text describing the desired change — "make it gruffer and slower," "less nasal, lower the pitch." Routed through `apply_edit_prompt()` below. |
```python
async def apply_edit_prompt(character: CharacterProfile, prompt: str) -> CharacterProfile:
"""LLM translates a free-text director's note into concrete taxonomy
deltas. Returns a candidate updated profile; does not mutate the
stored profile until the human approves the regenerated result."""
```
```
You are adjusting a TTS voice profile based on director feedback.
Current profile:
{taxonomy_json}
Director's note: "{prompt}"
Return ONLY a JSON object containing the fields that should change, from:
pitch_register, pace_baseline, energy, timbre, texture, formality,
emotional_baseline, distinguishing_trait. Only include a field if the
note actually justifies changing it. Do not invent unrelated changes.
```
4. **On an edit prompt:** apply the returned deltas to a candidate profile → re-derive `(exaggeration, cfg_weight)` via `_character_params()` → regenerate the reference and sample text → re-extract the embedding → **re-run the cast distinctiveness check** (an edit can introduce a collision that didn't exist before — it isn't optional just because this is round two) → re-present per step 2. Loop continues until **Approve**.
5. **Pipeline gate:** Pass 3's manifest finalization and Pass 5 generation will not bind or render against any character whose `status != 'voiced_approved'`. Pass 3's text-level segmentation/attribution can still run in parallel since it doesn't touch audio — but nothing downstream of voice selection proceeds for an unapproved character.
| Table | Fields |
|---|---|
| `voice_review_history` | `id`, `character_name`, `action` (`generated`/`approved`/`rejected`/`edit_requested`), `prompt_text` (nullable), `resulting_exaggeration`, `resulting_cfg_weight`, `taxonomy_snapshot_json`, `reviewed_by`, `reviewed_at` |
This table is what later lets a Pass 3 "Always" promotion (`04-mapping.md`, "Speech Descriptor Extractor & Review Queue") reopen exactly this same loop on an already-approved character, with full history of how the voice got to where it is.
Once approved, this is the "normal" voice referenced by everything downstream: Pass 3's punctuation-driven prosody hints modulate *around* it, and human-reviewed per-line descriptor overrides modulate around it too. Neither layer ever resets the baseline itself without going back through this same approval loop.
### Skip Logic
Voice references are not regenerated if:
1. `character.voice_ref_path` is set in DB
2. The referenced file exists on disk
3. `character.status == 'voiced_approved'` — anything else (`collision_pending`, mid-review, or rolled back by a Pass 3 "Always" promotion) is always regenerated, since skipping would silently re-ship a voice that's either known-broken or mid-revision
This allows incremental pipeline runs.
### GPU Requirements
- Chatterbox model: ~3.2 GB VRAM
- One voice ref: ~3-8 seconds to generate
- 29 characters: ~2-4 minutes of GPU time total (plus remediation regenerations for any flagged collisions)
- The human approval loop adds wall-clock time, not GPU load, beyond what's already accounted for — each reject/edit cycle is just another single-reference regeneration
- Requires LM Studio to be unloaded first (VRAM conflict)

264
specs/04-mapping.md Normal file
View File

@@ -0,0 +1,264 @@
# Spec: Mapping — Two-Phase Segmentation (Pass 2)
## Overview
Pass 2 segments chapter text into utterances (narration and dialogue) and attributes speakers using a two-phase approach: a mechanical segmenter handles text splitting, and an LLM attributes speakers to all dialogue.
This pass handles:
- Sentence splitting at `.` `!` `?` (not commas)
- Dialogue detection via quotes (`"'""`)
- Speaker attribution via dialogue tags and context
- Scene break (`<<<>>>`) handling
- Chapter title detection
## Components
### 1. Mechanical Segmenter (`mapping/segmenter.py`)
#### Class: `Segmenter`
```python
class Segmenter:
def segment(
self,
text: str,
chapter_id: int,
character_names: list[str] | None = None,
chapter_title: str | None = None,
) -> list[dict[str, Any]]
```
Special chapter handling by `chapter_title`: chapters titled "copyright", "author's note", "back matter", and "title" are force-attributed to the narrator.
**Returns:**
```python
[{
"index": int,
"text": str,
"type": "narration" | "dialogue",
"character_name": str | None,
}, ...]
```
**Segmentation Pipeline:**
```
Raw chapter text
│
├── 1. _split_segments(text) → list[str]
│ Character-by-character parser
│ Detects quote boundaries and sentence endings
│
├── 2. _build_utterances(segments) → list[dict]
│ Tags each segment as "dialogue" or "narration"
│ based on leading quote character
│
└── 3. _attribute_speakers(utterances, character_names) → list[dict]
Forward-tracking speaker attribution
Regex matching against character names
```
**Quote Preservation:**
- A quoted dialogue span is extracted whole (`_extract_quote` scans to the closing quote)
and kept as a **single utterance even when it contains multiple sentences**. The inner
text is never split back into per-sentence re-quoted fragments — that split is what caused
multi-sentence speech to lose its single-speaker evidence.
- `_restore_quote_wrap` re-wraps the stripped inner text with typographic double quotes. It
only treats double-quote closes (`"`, `\u201d`) as outer closes, so a trailing **inner
single-quote** (`’`, e.g. `...named ‘Nanny.’`) is preserved instead of being mistaken for
the outer closing quote.
**Quote Detection:**
- Open: `"`, `'`, `\u201c` (left double), `\u2018` (left single)
- Close: `"`, `'`, `\u201d` (right double), `\u2019` (right single)
**Sentence Splitting:**
- Split at `.`, `!`, `?` followed by whitespace/quote/dash (narration only)
- Quote boundaries always create segment breaks
- Quoted dialogue is never split by sentence endings — the whole quote is one utterance
**Speaker Attribution Algorithm:**
1. **Tag Detection**: Find `NAME said/asked/replied` patterns
2. **Forward Tracking**: When tag found, set `next_speaker = NAME`
3. **Backward Reference**: When no tag, look back for `said NAME` patterns
4. **Name Normalization**: Case-insensitive matching, alias resolution
**Speech Verbs (53 total):**
```
said, asked, replied, whispered, shouted, muttered, called,
exclaimed, answered, began, continued, added, finished, noted,
stated, remarked, yelled, screamed, laughed, sighed, says,
tells, told, orders, ordered, demands, demanded, insists,
insisted, begs, begged, pleads, pleaded, commands, commanded,
snapped, snarled, hissed, growled, barked, roared, croaked,
grunted, murmured, purred, intoned, declared, announced,
proclaimed, responds, repeats, mentions, offers
```
### 2. LLM Speaker Attribution (`analysis/llm_attributor.py`)
#### Class: `LLMAttributor`
```python
class LLMAttributor:
def __init__(self, config: LLMConfig, model: str | None = None) -> None
def attribute_speakers(
self,
utterances: list[dict[str, Any]],
chapter_title: str,
chapter_id: int,
) -> list[dict[str, Any]]
```
**Key Insight:** The LLM attributes **all** dialogue utterances. The mechanical attribution from the segmenter is preserved only as a `current_speaker` hint for the LLM (rule 8) — the LLM's verdict takes precedence so that ambiguous or incorrectly guessed lines can be corrected. This differs from the original design where only `speaker_name == "narrator"` dialogue was attributed; the current implementation lets the LLM override mechanical guesses.
**Attribution Flow:**
1. **Filter**: Collect all dialogue utterances (`type == "dialogue"`)
2. **Batch**: Group into batches of 40 utterances
3. **Context**: Include surrounding utterances for context
4. **LLM Call**: Send batch with character profiles
5. **Parse Response**: Extract JSON array with index → speaker mapping
6. **Post-Process**: Strip speech verb suffixes ("says", "said", etc.) and run `_normalize_speaker` (alias/first-word resolution, narrator lowercase, pronoun guard)
7. **Update**: Apply LLM verdicts to all dialogue utterances
8. **Fallback**: Re-attribute any dialogue still un-attributed via
- **Speech-tag detection** on the 3 preceding utterances (regex `NAME says/said/asked/...`)
- **Speaker carry-forward** from the last active speaker within the same scene
- Scene breaks (`<<<>>>`) reset the carried speaker
**Performance:**
- ~5 seconds per batch of 40 utterances with `gemma-4-12b-it-qat` (~34 tok/s on the remote LM Studio server)
- The model must be loaded with `context_length: 8192`; the default 262k-token context requests ~44.9 GB of memory and blocks loading
- Processes all dialogue utterances; deterministic fallback recovers any that the LLM misses (target 100% dialogue attribution)
### 3. Emotion Mapping (`mapping/emotion.py`)
#### Class: `EmotionMapper`
```python
class EmotionMapper:
def __init__(self, llm_client: Any = None, model: str = "") -> None
def analyze_utterances(self, utterances, chapter_context="") -> list[dict]
def batch_generate(self, utterances, chapter_context="") -> list[dict]
```
Maps each utterance to TTS voice-direction metadata: `emotion`, `pace`, `emphasis_words` (max 3), `instruct`, `pause_before_ms`, `pause_after_ms`.
**Batching:** utterances are processed in batches of 30 per LLM call. The output JSON array must match the input order; a length mismatch falls back to deterministic defaults for the whole batch.
**Question Intonation (required):**
- Any utterance ending with `?` (including `?"`) is a question.
- The LLM prompt must instruct rising question intonation for question-ending utterances.
- The deterministic fallback (`_default_result`) also emits `instruct` = "Speak naturally with rising question intonation at the end." for question-ending dialogue when no LLM is available or a batch fails.
- This ensures every question has rising intonation even if the LLM is unavailable.
**Defaults:**
- Narrator text → `emotion="neutral"`, `pace="moderate"`, `instruct="Read steadily and evenly. Low emphasis, consistent tone."`
- Non-question dialogue → `emotion="neutral"`, `pace="moderate"`, `instruct="Speak naturally."`
- All results default to `pause_before_ms=500`, `pause_after_ms=500`.
**LLM Settings:** temperature 0.2, max_tokens 8192, 3 attempts with exponential backoff, robust JSON extraction (strip fenced blocks, brace/bracket trimming).
### 4. Scene Break Handling
**Scene Breaks (`<<<>>>`):**
- 51 scene breaks across the book
- Marked as special utterances: `{"speaker": "scene_break", "text": "<<<>>>"}`
- Within a scene: track which characters are present
- At a scene break: re-evaluate present characters
- Chapter context carries across scene breaks
### 5. Name Normalization
All LLM-returned speakers pass through `_normalize_speaker` before being stored:
**Aliases → Primary Names (from `speakers.json`):**
- "Princess", "Kainda", "the princess" → "Kainda Walters Foster Murphy"
- "Captain", "Captain Wake" → "Willamina Wake"
- "Hunter", "Ship AI", "The Deal Hunter" → "Deal Hunter" (the ship's AI — one speaker)
**First-word-of-primary mapping:** the first word of each primary name maps to the full
primary name when it is not already an alias (e.g. "kainda" → "Kainda Walters Foster
Murphy"). This fixes attributions that only match the primary name's first word.
**Narrator lowercase:** "narrator" / "the narrator" resolve to the canonical lowercase
`narrator` (the DB and generation code check this exact case).
**Pronoun guard:** pronouns ("she", "he", "they", "her", etc.) resolve to `narrator`
instead of fabricating a speaker — a bare pronoun (e.g. "She") is a real LLM failure
mode, never a character name.
**Resolution:**
- Case-insensitive matching
- Alias map from `speakers.json`
- Fallback: unknown names are kept as-is and surfaced on the orphaned-utterances page for review
## Output
Utterances saved to `utterances` table:
```sql
INSERT INTO utterances (
chapter_id,
utterance_index,
text,
type,
speaker_name
) VALUES (?, ?, ?, ?, ?)
```
## Quality Metrics
| Metric | Value |
|--------|-------|
| Total utterances | 4,339 |
| Dialogue utterances | 1,882 |
| Narration utterances | 2,453 |
| Dialogue-as-narrator errors | 1 (0.05%) |
| Scene breaks | 51 |
| Chapters | 35 |
## Scripts
### resegment_all.py
```bash
# Run on all chapters with LLM attribution
python scripts/resegment_all.py --model gemma-4-12b-it-qat
# Run on specific chapters
python scripts/resegment_all.py --chapters 5,6,7
# Mechanical-only (no LLM)
python scripts/resegment_all.py --mechanical-only
```
### reattribute_all.py
Re-runs LLM speaker attribution on existing utterances **without re-segmenting** (preserves utterance IDs and mechanical segmentation; only updates `speaker_name`):
```bash
# All chapters
python scripts/reattribute_all.py --model gemma-4-12b-it-qat
# Specific chapters (--sleep adds a delay between chapters to pace the server)
python scripts/reattribute_all.py --chapters 5,6,7 --sleep 0.2
```
### analyze_emotions.py
Re-runs emotion mapping (including question intonation) on all utterances in the database.
## Key Design Decisions
1. **Two-phase over one-phase**: Mechanical handles splitting reliably; LLM does attribution
2. **Quotes kept whole**: A quoted dialogue span stays one utterance even with multiple sentences (Rule 3d) — splitting it would lose the single-speaker evidence
3. **Inner-quote preservation**: `_restore_quote_wrap` only recognizes double-quote closes so a trailing inner single-quote (`’`) survives re-wrapping
4. **LLM attributes all dialogue**: The LLM's verdict overrides the mechanical `current_speaker` guess so ambiguous lines get corrected
5. **Deterministic fallback**: Speech-tag regex + speaker carry-forward guarantee un-attributed dialogue still gets a speaker
6. **Batch processing**: 40 utterances per LLM call for efficiency
7. **Scene break preservation**: `<<<>>>` markers maintained as special utterances; they reset the carried speaker
8. **Speaker name normalization**: Aliases and first-word-of-primary resolve to canonical primary names; narrator stays lowercase; pronouns map to narrator
9. **Question intonation is mandatory**: every `?`-ending utterance carries rising question intonation in its TTS instruct text, enforced in both the LLM prompt and the deterministic fallback

184
specs/05-generation.md Normal file
View File

@@ -0,0 +1,184 @@
# Spec: Audio Generation (Pass 3)
## Overview
Pass 3 generates audio for every utterance in every chapter using **Qwen3-TTS** (primary engine) with per-character **voice reference samples** (`*_ref.wav`). Each utterance gets its own WAV file. Generation resolves each utterance's speaker to a voice reference, then clones that voice for the utterance. Edge-TTS remains available for test samples and as a fallback path inside the voice designer.
## Components
### Qwen3-TTS Engine (`voices/qwen_tts.py`)
```python
class QwenTTSEngine:
def __init__(self, device="cuda", sample_rate=24000, qwen_tts_url: str) -> None
def load_remote_model(self) -> None
def generate(self, text: str, output_path: Path, voice_ref: Path | None = None, instruct: str = "") -> Path
def unload_all(self) -> None
```
**Qwen3-TTS Features:**
- Remote/on-prem synthesis via `qwen_tts_url` (LM Studio / Qwen server)
- Voice cloning from a reference WAV per character
- Optional `instruct` text for prosody/emotion direction (e.g. rising question intonation)
- WAV output at 24kHz sample rate
### Edge-TTS (`voices/edge_tts.py`) — Test Samples & Fallback
```python
class EdgeTTSEngine:
def __init__(self) -> None
def generate(self, text: str, output_path: Path, voice: str, pitch_shift: int = 0) -> Path
```
- Used by `scripts/generate_test_samples.py` (verification phrase per speaker) and as the `_generate_edge_fallback` path in the voice designer.
- 27 distinct base voices (US, UK, AU, IE, IN, CA) with pitch shifting for variety.
### Voice Reference Resolution
```python
def resolve_reference(
speaker_name: str,
voice_refs: dict[str, Path],
alias_map: dict[str, str],
narrator_ref: Path | None = None,
) -> Path | None
```
`voice_refs` is built by scanning `.umbra/voices/*_ref.wav`; the key is the filename stem with underscores converted to spaces (e.g. `captain_weston_ref.wav` → `captain weston`).
**Resolution Order:**
1. Direct match against `voice_refs` keys (speaker primary name)
2. Case-insensitive match against reference names
3. Alias map lookup (primary names + aliases from `speakers.json`, all lowercased)
4. Fall back to `narrator_ref` (the narrator's reference WAV); if no narrator ref exists, fall back to the Kainda reference
**Fallback when no reference resolves at all:** the utterance is generated with a plain instruction (`"A calm, neutral voice speaking clearly."`) instead of a cloned voice.
### Generation Flow
```
For each chapter:
│
├── 1. Load utterances from DB
│ SELECT id, speaker_name, text, utterance_index
│ FROM utterances WHERE chapter_id = ? ORDER BY utterance_index
│
├── 2. Create output directory
│ .umbra/chapter_audio/ch{id}/
│
└── 3. For each utterance:
│
├── Skip empty text
├── Skip if file exists
│ if output_path.exists(): continue
│
├── Resolve voice reference
│ ref_path = resolve_reference(speaker, voice_refs, alias_map, narrator_ref)
│
├── Generate audio (Qwen3-TTS)
│ engine.generate(text, output_path, voice_ref=ref_path)
│ # no ref → engine.generate(text, output_path, instruct=neutral)
│
├── Verify output file exists (else count as failed)
│
└── Handle errors
Log error, continue to next utterance
```
**Engine mode:** the engine uses remote synthesis when `cfg.tts.qwen_tts_url` is set (the default deployment). The remote server holds the VoiceDesign and Base models loaded and answers `/v1/tts`; the local `QwenTTSEngine` only loads models itself when no URL is configured.
### Output Structure
```
.umbra/chapter_audio/
├── ch5/
│ ├── utt_0000.wav
│ ├── utt_0001.wav
│ └── ... (one WAV per utterance)
├── ch6/
│ ├── utt_0000.wav
│ └── ...
└── ch7/
├── utt_0000.wav
└── ...
```
Each WAV is named `utt_{utterance_index:04d}.wav`, so filenames are stable across re-runs and regeneration after utterance edits deletes and recreates the matching file.
### Remote Server Coordination
- The engine checks VRAM on start (`nvidia-smi`) and logs used/free memory; the remote model is expected to be the only heavy VRAM consumer on the serving host.
- The remote server keeps the **VoiceDesign model** loaded so a *single* synthesis call can switch between voice-cloning (`ref_audio`) and description-driven (`voice_description`) generation without a model swap — the per-utterance Clone is what drives bulk generation, so the Base model stays resident.
- On completion, `unload_all()` is called only on the client side (remote models are left for the server's lifecycle manager).
### Scripts
#### generate_all_audio.py (bulk generation)
```bash
# Background chapter audio generation
nohup python scripts/generate_all_audio.py > .umbra/generation.log 2>&1 &
```
**Behavior:**
- Loads voice references from `.umbra/voices/*_ref.wav` and speakers/aliases from `.umbra/speakers.json`
- Iterates chapters in `chapter_index` order, generating each missing utterance WAV
- Logs per-chapter progress (`+N gen, +M fail`), running total, and elapsed minutes
- Emits progress estimates assuming ~6.4s per utterance
- Exits with a final `COMPLETE: N generated, M failed` summary
- Accepts optional `status_callback` and `generation_manager` hooks so the web UI can display live progress (see `09-web-review-ui.md`)
**Resume semantics:** because existing WAVs are skipped, re-running the script after a crash or interruption resumes exactly where it left off.
## Voice Library
### Voice References (`.umbra/voices/*_ref.wav`)
Each speaker gets a reference WAV synthesized from the verification phrase *"When the sunlight strikes raindrops in the air, they act as a prism and form a rainbow. The rainbow is a division of white light into many beautiful colors..."* (the Rainbow Passage — a phonetically balanced paragraph covering all English phonemes) using their taxonomy-derived voice description (see `03-voice-design.md`).
- Narrator → `narrator_ref.wav`
- General/misc speakers (from `GENERAL_SPEAKER_NAMES`) → `general_ref.wav`
- Named speakers → `{name_lower_spaces_to_underscores}_ref.wav`
**Reference count in current deployment:** 92 speakers in `speakers.json`, ~26 unique reference WAVs generated (many characters intentionally share a reference or fall back to the narrator reference rather than each getting a distinct clone).
### Voice Variety Strategy
1. **Distinct voice descriptions**: each character's taxonomy (`gender`, `age`, `timbre`, `pitch_register`, `energy`, `texture`, `formality`, `accent`, `distinguishing_trait`) is rendered into a unique natural-language description via `build_voice_description()`
2. **Voice cloning**: the speaker's reference WAV is the identity anchor; clone synthesis preserves that identity across all utterances
3. **No pitch-shifting**: variety comes from the reference voice itself, not post-hoc pitch shifts
## Error Handling
- **Empty Text**: Skip utterances with no text
- **File Exists**: Skip if output file already exists
- **No Reference Resolved**: Fall back to narrator reference (or Kainda), else neutral instruction
- **TTS Failure** (`QwenTTSUnavailableError` — connect refused, timeout after 300s, non-200 HTTP, or response without audio): log and continue to next utterance
- **Per-utterance exception**: caught, logged, counted as failed — never aborts the chapter run
## Performance
| Metric | Value |
|--------|-------|
| Rate | ~6.4 seconds/utterance (remote synthesis) |
| Full book (6,861 utterances) | ~12 hours |
| Progress logging | Every 50 utterances, plus per-chapter summary |
| Resume | Idempotent via file-existence skip |
Success rate is reported per run as `generated / failed`; failed utterances can be regenerated by re-running the script (only the missing files are produced).
## Quality Considerations
- All voices tested with verification phrase (test samples reviewed in the web UI)
- Web UI allows human review of generated audio per chapter (`/chapters-audio`)
- Utterance text/speaker edits in the web UI delete the stale WAV and trigger background regeneration
- Voice references can be regenerated individually or en masse from the web UI
## Key Design Decisions
1. **Qwen3-TTS over Edge-TTS**: cloning from per-character reference samples gives stable, character-accurate voices; Edge-TTS is retained only for test samples and the `_generate_edge_fallback` path in the voice designer
2. **Remote synthesis**: the Qwen server (`qwen_tts_url`) hosts the models, so bulk generation doesn't fight the LLM (LM Studio) for local GPU/VRAM
3. **Voice references over voice strings**: identity comes from `*_ref.wav` cloning instead of `voice_assignments.json` voice strings with pitch shifts
4. **Sequential generation**: avoids overloading the remote server and simplifies resumability
5. **File-based caching**: skip already-generated files; regeneration after edits is file-targeted
6. **Error resilience**: individual utterance failures never abort a chapter run; a final summary counts successes and failures

View File

@@ -0,0 +1,204 @@
# Spec: Normalization & Watermarking
## Overview
Post-generation processing includes audio normalization (RMS leveling, peak limiting) and neural watermarking (author identity embedded in audio). These run as part of the generation pipeline per-utterance.
## Components
### 1. Audio Normalizer (`assembly/normalize.py`)
```python
class AudioNormalizer:
def __init__(
self,
target_rms_db: float = -20.0,
peak_limit_db: float = -3.0,
noise_floor_db: float = -60.0,
sample_rate: int = 24000,
) -> None
def normalize_file(self, input_path: Path, output_path: Path) -> dict
def check_acx_compliance(self, audio_path: Path) -> dict
```
#### Normalization Algorithm
```python
def normalize_file(self, input_path: Path, output_path: Path) -> dict:
# 1. Read audio
audio, sr = sf.read(input_path)
# 2. Mono downmix if stereo
if audio.ndim > 1:
audio = audio.mean(axis=1)
# 3. Compute current RMS in dB
rms = np.sqrt(np.mean(audio ** 2))
current_rms_db = 20 * np.log10(rms + 1e-10)
# 4. Calculate gain
gain_db = self.target_rms_db - current_rms_db
gain = 10 ** (gain_db / 20)
audio = audio * gain
# 5. Peak limiting
peak = np.max(np.abs(audio))
peak_db = 20 * np.log10(peak + 1e-10)
if peak_db > self.peak_limit_db:
limiting_gain = 10 ** ((self.peak_limit_db - peak_db) / 20)
audio = audio * limiting_gain
# 6. Clip to [-1.0, 1.0]
audio = np.clip(audio, -1.0, 1.0)
# 7. Write output
sf.write(output_path, audio, self.sample_rate)
```
**Returns:**
```python
{
"input_path": str,
"output_path": str,
"original_rms_db": float,
"normalized_rms_db": float,
"peak_db": float,
"gain_applied_db": float,
}
```
#### ACX Compliance Check
```python
def check_acx_compliance(self, audio_path: Path) -> dict
```
**ACX Requirements:**
| Metric | Range |
|--------|-------|
| RMS level | -23.0 dB to -18.0 dB |
| Peak level | Below -3.0 dB |
**Returns:**
```python
{
"rms_db": float,
"peak_db": float,
"rms_in_range": bool, # -23.0 <= rms <= -18.0
"peak_below_limit": bool, # peak < -3.0
"compliant": bool, # rms_in_range AND peak_below_limit
}
```
### 2. Watermarking
#### Author Watermark Bits
```python
def _author_watermark_bits(author_name: str, length: int = 128) -> np.ndarray
```
**Algorithm:**
1. SHA-256 hash of author name string
2. Convert 128 bits from the hash digest to a float array
3. Each byte → 8 bits, take first `length` bits
4. Return as `np.float32` array
**Example:**
```python
author_name = "B.D. Murphy"
digest = sha256(b"B.D. Murphy").digest() # 32 bytes = 256 bits
bits = first 128 bits → np.float32 array of 128 values (0.0 or 1.0)
```
#### Perth Neural Watermarker
```python
class ChatterboxEngine:
def _get_watermarker(self) -> watermarker | None
def apply_watermark(self, wav, watermark_bits, sample_rate) -> torch.Tensor
```
**Watermarker Selection:**
1. Try `PerthImplicitWatermarker(device="cuda")` — neural watermarking
2. Fall back to `DummyWatermarker()` — no-op pass-through
3. Return `None` if neither available
**Application:**
```python
def apply_watermark(self, wav, watermark_bits, sample_rate=None):
if self._watermarker is None:
return wav # No watermarking available
try:
audio_np = wav.squeeze(0).cpu().numpy()
if audio_np.ndim > 1:
audio_np = audio_np.mean(axis=0)
watermarked = self._watermarker.embed(audio_np, watermark_bits)
return torch.from_numpy(watermarked).unsqueeze(0)
except Exception:
return wav # Fallback: return unwatermarked
```
**PerthImplicitWatermarker:**
- Located at: `~/.cache/torch/hub/snakers4_perth`
- Model: `perth_net` — implicit neural audio watermarking
- Embeds 128-bit payload into audio signal
- Robust to common audio transformations (compression, resampling)
- VRAM usage: minimal (~50MB)
**DummyWatermarker:**
- No-op implementation
- Returns audio unchanged
- Used as fallback when Perth is unavailable
### Integration with Generation
Watermarking runs inside `ChatterboxEngine.generate()`:
```python
def generate(self, text, output_path, voice_ref, instruct, watermark_bits, ...):
wav = self._model.generate(text, ...)
if watermark_bits is not None:
wav = self.apply_watermark(wav, watermark_bits)
# Write to file
sf.write(str(output_path), audio_np, self.sample_rate)
```
### Integration with Normalization
Normalization runs inside `ChapterGenerator.generate_chapter()`:
```python
# After successful generation:
if self.normalizer:
normalized_path = normalized_dir / out_path.name
self.normalizer.normalize_file(out_path, normalized_path)
```
### Quality Defect Tracking
ACX compliance is checked per-file in the orchestrator:
```python
# After generation:
result = normalizer.check_acx_compliance(audio_path)
if not result["compliant"]:
db.log_defect(project_id, "quality", "WARNING",
f"ACX non-compliance: RMS={result['rms_db']:.1f}dB, Peak={result['peak_db']:.1f}dB",
chapter_id=ch["id"])
```
## Default Parameters
| Parameter | Default | ACX Requirement |
|-----------|---------|-----------------|
| `target_rms_db` | -20.0 dB | -23.0 to -18.0 dB |
| `peak_limit_db` | -3.0 dB | Below -3.0 dB |
| `noise_floor_db` | -60.0 dB | N/A |
| `sample_rate` | 24000 Hz | N/A |
| `watermark_length` | 128 bits | N/A |

162
specs/07-assembly-export.md Normal file
View File

@@ -0,0 +1,162 @@
# Spec: Assembly & Export
## Overview
The assembly module stitches normalized audio files into final audiobook formats (M4B, MP3, WAV) using FFmpeg and Python audio libraries.
## Components
### 1. Audio Stitcher (`assembly/__init__.py`)
```python
class AudioStitcher:
def __init__(
self,
silence_between_utterances_ms: int = 500,
silence_between_paragraphs_ms: int = 2000,
silence_between_chapters_ms: int = 4000,
sample_rate: int = 24000,
) -> None
def stitch_utterances(self, audio_files: list[Path], output_path: Path) -> Path
def stitch_chapters(self, chapter_files: list[Path], output_path: Path) -> Path
```
#### Silence Generation
```python
def _make_silence(self, duration_ms: int) -> np.ndarray:
num_samples = int(self.sample_rate * duration_ms / 1000)
return np.zeros(num_samples, dtype=np.float32)
```
#### Utterance Stitching
Concatenates audio files with configurable silence between them:
- Each utterance followed by `silence_between_utterances_ms` (500ms default)
- Mono downmix if any file is stereo
- Output: single WAV file per chapter
#### Chapter Stitching
Concatenates chapter files with longer pauses:
- Each chapter followed by `silence_between_chapters_ms` (4000ms default)
- Used for final audiobook assembly
### 2. Audiobook Exporter (`assembly/export.py`)
```python
class AudiobookExporter:
def export_m4b(
self,
chapter_files: list[Path],
output_path: Path,
metadata: dict | None = None,
) -> Path
def export_mp3(
self,
chapter_files: list[Path],
output_dir: Path,
metadata: dict | None = None,
) -> list[Path]
def export_wav(
self,
chapter_files: list[Path],
output_dir: Path,
) -> list[Path]
```
#### M4B Export
```python
def export_m4b(self, chapter_files, output_path, metadata=None):
# Create FFmpeg concat file
with open(concat_file, "w") as f:
for chapter in chapter_files:
f.write(f"file '{chapter.absolute()}'\n")
# Run FFmpeg
subprocess.run([
"ffmpeg", "-f", "concat", "-safe", "0",
"-i", str(concat_file),
"-c", "copy",
str(output_path),
])
```
**Requirements:** FFmpeg must be installed on the system.
#### MP3 Export
```python
def export_mp3(self, chapter_files, output_dir, metadata=None):
for i, chapter in enumerate(chapter_files):
output_path = output_dir / f"chapter_{i+1:03d}.mp3"
subprocess.run([
"ffmpeg", "-i", str(chapter),
"-codec:a", "libmp3lame",
"-b:a", "192k",
str(output_path),
])
```
**Settings:** 192kbps CBR MP3 encoding.
#### WAV Export
```python
def export_wav(self, chapter_files, output_dir):
for i, chapter in enumerate(chapter_files):
output_path = output_dir / f"chapter_{i+1:03d}.wav"
shutil.copy2(chapter, output_path) # No re-encoding
```
### 3. Metadata (`assembly/metadata.py`)
Currently a stub (empty file). Planned: ID3 tag writing for MP3/M4B metadata (title, author, chapter markers).
## Export Flow
```
Normalized audio files
│
├── Stitch per chapter → chapter WAVs
│
├── Export formats:
│ ├── M4B: FFmpeg concat copy
│ ├── MP3: FFmpeg 192kbps per chapter
│ └── WAV: Direct file copy
│
└── Output: .umbra/export/
├── *.m4b
├── *.mp3
└── *.wav
```
## CLI Integration
```python
@main.command()
@click.option("--format", "-f", "formats", multiple=True, default=["m4b", "mp3"])
def export(ctx, formats):
# Source: prefers audio_normalized/, falls back to audio/
# Output: .umbra/export/
```
## Dependencies
- **FFmpeg**: Required for M4B and MP3 export
- **soundfile**: WAV reading/writing
- **numpy**: Audio array manipulation
## Default Timing Parameters
| Parameter | Default | Description |
|-----------|---------|-------------|
| `silence_between_utterances_ms` | 500ms | Gap between sentences |
| `silence_between_paragraphs_ms` | 2000ms | Gap between paragraphs |
| `silence_between_chapters_ms` | 4000ms | Gap between chapters |
| `sample_rate` | 24000 Hz | Audio sample rate |
| `mp3_bitrate` | 192k | MP3 encoding bitrate |

168
specs/08-database.md Normal file
View File

@@ -0,0 +1,168 @@
# Spec: Database Schema
## Overview
UmbraAudio uses SQLite for all project state. The database tracks the full lifecycle from parsed manuscript through generated audio. Character discovery and voice assignments are stored in JSON files for flexibility.
## Current Implementation
### Actual Schema (project.db)
```sql
-- Project metadata
CREATE TABLE IF NOT EXISTS project (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
source_path TEXT NOT NULL,
source_format TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
status TEXT NOT NULL DEFAULT 'initialized'
);
-- Chapters with source text
CREATE TABLE IF NOT EXISTS chapters (
id INTEGER PRIMARY KEY,
project_id INTEGER NOT NULL,
chapter_index INTEGER NOT NULL,
title TEXT,
source_text TEXT NOT NULL,
word_count INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'parsed',
audio_path TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- Utterances (narration and dialogue)
CREATE TABLE IF NOT EXISTS utterances (
id INTEGER PRIMARY KEY,
chapter_id INTEGER NOT NULL,
character_id INTEGER,
utterance_index INTEGER NOT NULL,
text TEXT NOT NULL,
type TEXT NOT NULL DEFAULT 'narration',
context_before TEXT,
context_after TEXT,
emotion TEXT,
pace TEXT,
emphasis_words TEXT,
pause_before_ms INTEGER,
pause_after_ms INTEGER,
instruct TEXT,
speaker_name TEXT,
status TEXT NOT NULL DEFAULT 'pending'
);
-- Generated audio files
CREATE TABLE IF NOT EXISTS generated_audio (
id INTEGER PRIMARY KEY,
utterance_id INTEGER NOT NULL,
character_id INTEGER,
audio_path TEXT NOT NULL,
duration_ms INTEGER,
mos_score REAL,
wer_score REAL,
speaker_similarity REAL,
artifact_score REAL,
generation_params TEXT,
status TEXT NOT NULL DEFAULT 'generated',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
```
### JSON Data Files
#### speakers.json
```json
{
"speakers": [
{
"primary_name": "Kainda",
"aliases": ["Princess Kainda", "Princess", "Captain", "XO Capek"],
"demographics": {
"type": "Human",
"gender": "Female"
},
"vocal_traits": ["assertive", "clear", "young"],
"communication_style": {
"primary_register": "Formal",
"style_shifts_or_notes": "Shifts to casual with close allies"
},
"is_narrator": false,
"is_group": false,
"description": "Princess and communication specialist",
"sample_dialogue": "I'm on a yacht.",
"chapters_appeared": [5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31]
}
]
}
```
#### voice_assignments.json
```json
{
"Narrator": "en-US-GuyNeural:-10",
"Kainda": "en-US-JennyNeural",
"Captain Weston": "en-US-ChristopherNeural",
"Hunter": "en-US-JasonNeural",
"Nanny": "en-GB-SoniaNeural",
"Aunt Vicky": "en-US-AvaNeural",
"Admiral Wake": "en-US-AndrewNeural",
"Princess Kainda": "en-US-JennyNeural",
"Doctor": "en-US-GuyNeural",
"Guards": "en-US-GuyNeural:+48"
}
```
## Entity Relationships
```
project (1) ──< chapters (many)
chapters (1) ──< utterances (many)
utterances (1) ──< generated_audio (many)
speakers.json ──< (character profiles)
voice_assignments.json ──< (voice mappings)
```
## Key Differences from Original Spec
1. **No characters table**: Character profiles stored in `speakers.json`
2. **No pronunciation_guide table**: Not currently implemented
3. **No quality_reports table**: Quality tracking via web UI
4. **No review_decisions table**: Review via web UI endpoints
5. **speaker_name column**: Added to utterances for speaker attribution
6. **JSON-based configuration**: speakers.json and voice_assignments.json
## Database Class API
```python
class Database:
def __init__(self, db_path: Path) -> None
def connect(self) -> None
def close(self) -> None
```
### Query Methods
| Method | Returns | Description |
|--------|---------|-------------|
| `get_chapters()` | `list[dict]` | All chapters ordered by index |
| `get_utterances(chapter_id)` | `list[dict]` | Utterances for a chapter |
| `get_audio_for_chapter(chapter_id)` | `list[dict]` | Generated audio files |
## Data Flow
1. **Parsing**: Manuscript → chapters table (source_text)
2. **Character Discovery**: LLM analysis → speakers.json
3. **Voice Assignment**: Manual/automated → voice_assignments.json
4. **Segmentation**: Chapter text + speakers.json → utterances table
5. **Audio Generation**: utterances + voice_assignments.json → chapter_audio/
## Indexes
```sql
CREATE INDEX IF NOT EXISTS idx_utterances_chapter ON utterances(chapter_id);
CREATE INDEX IF NOT EXISTS idx_generated_audio_utterance ON generated_audio(utterance_id);
CREATE INDEX IF NOT EXISTS idx_chapters_project ON chapters(project_id);
```

300
specs/09-web-review-ui.md Normal file
View File

@@ -0,0 +1,300 @@
# Spec: Web Review UI
## Overview
FastAPI + HTMX web interface for human review of generated audiobooks. Provides dashboards, voice review, chapter review with audio playback, and quality reports.
## Technology Stack
- **Framework:** FastAPI 0.104+
- **Templating:** Jinja2 3.1+
- **Interactive:** HTMX 1.9 (CDN)
- **Styling:** Custom dark theme (no CSS framework)
- **Database:** Synchronous sqlite3 (not aiosqlite — web runs in sync context)
## App Factory
```python
# umbraaudio/web/__init__.py
def create_app(config: AppConfig) -> FastAPI
```
- Creates FastAPI app (title: "UmbraAudio Review", version: "0.1.0")
- Sets up Jinja2Templates from `templates/` directory
- Mounts static files if `static/` directory exists
- Includes router from `umbraaudio.web.routes`
- Stores config on `app.state.config`
- Singleton pattern via global `_app`
## Routes
### HTML Pages (GET)
| Route | Function | Template | Description |
|-------|----------|----------|-------------|
| `GET /` | `index()` | `index.html` | Dashboard with stats, scan results, pipeline status, export files |
| `GET /voices` | `voice_review()` | `voice_review.html` | Voice review page |
| `GET /voices/partial` | `voice_list_partial()` | `_voice_list.html` | HTMX partial |
| `GET /voices/{name}/history` | `voice_history()` | `_voice_history.html` | Per-character voice review history |
| `GET /speakers` | `speakers_review()` | `speakers.html` | Speaker management (CRUD, aliases, merge) |
| `GET /speakers/partial` | `speakers_list_partial()` | `_speakers_list.html` | HTMX partial |
| `GET /proper-names` | `proper_names_review()` | `proper_names.html` | Proper-noun pronunciation review (`/character-names` is an alias) |
| `GET /orphaned-utterances` | `orphaned_utterances_page()` | `orphaned_utterances.html` | Utterances whose speaker is unknown or `__unassigned__` |
| `GET /voice-tests` | `voice_tests_review()` | `voice_tests.html` | Verification-phrase test samples |
| `GET /voice-tests/partial` | `voice_tests_partial()` | `_voice_tests_list.html` | HTMX partial |
| `GET /chapters/{id}` | `chapter_review()` | `chapter_review.html` | Chapter review |
| `GET /chapters/{id}/partial` | `chapter_utterances_partial()` | `_utterance_list.html` | HTMX partial |
| `GET /chapters-audio` | `chapters_audio_review()` | `chapters_audio.html` | Per-chapter audio listing |
| `GET /chapters-audio/{chapter_dir}` | `chapter_audio_review()` | `chapter_audio_detail.html` | Utterance WAV review with speaker/text editing |
| `GET /quality` | `quality_report()` | `quality_report.html` | Quality dashboard |
| `GET /registry` | `registry_index()` | `registry.html` | Cross-project voice registry |
| `GET /registry/partial` | `registry_list_partial()` | `_registry_list.html` | HTMX partial |
| `GET /registry/search?q=` | `registry_search()` | — (JSON) | Search registry by character or alias |
### Audio Serving (GET)
| Route | Description |
|-------|-------------|
| `GET /audio/{chapter_id}/{filename}` | Serve WAV (prefers `audio_normalized`, falls back to `audio`) |
| `GET /voice-audio/{filename}` | Serve voice reference WAVs from `.umbra/voices` |
| `GET /voice-test-audio/{filename}` | Serve test sample (prefers `.umbra/voices`, falls back to `.umbra/voice_tests`) |
| `GET /chapter-audio-file/{chapter_dir}/{filename}` | Serve per-utterance chapter WAV |
| `GET /registry-audio/{registry_id}/{filename}` | Serve a registered voice reference from the registry DB path |
### API
| Route | Description |
|-------|-------------|
| `POST /api/upload-manuscript` | Upload `.docx/.epub/.txt/.md/.pdf`, parse chapters, rebuild utterances; archives prior state in `mode=new`, else keeps speakers |
| `POST /api/speakers` | Create speaker (form fields: name, gender, type, aliases, traits, register, description, sample dialogue, narrator/group flags, accent) |
| `PUT /api/speakers/{index}` | Update speaker |
| `DELETE /api/speakers/{index}` | Delete speaker; flags their utterances `__unassigned__` |
| `POST /api/speakers/{index}/alias` | Add alias |
| `DELETE /api/speakers/{index}/alias/{alias}` | Remove alias |
| `POST /api/speakers/{index}/alias/{alias}/move/{target}` | Move alias to another speaker |
| `POST /api/speakers/{index}/alias/{alias}/create` | Promote alias to a new speaker |
| `POST /api/speakers/{source}/merge/{target}` | Merge speakers (attributes, aliases, chapters merged; utterances repointed) |
| `POST /api/speakers/voice-status` | Set `voice_status.json` entry; on `approved`, upsert into `voice_registry` |
| `POST /api/speakers/regenerate-voice` | Regenerate one speaker's voice reference (background) |
| `POST /api/speakers/generate-all-voices` | Generate all missing voice references (background, skips existing) |
| `PUT /api/phonetic/{speaker_name}` | Set/edit phonetic spelling; stored as an unconfirmed user candidate, regenerates preview audio (clearing the value deletes the record + audio) |
| `GET /api/pronunciation/{name}` | Look up the most common pronunciation (dictionaryapi.dev for common words, Wiktionary API for names) and return the candidate without saving |
| `POST /api/pronunciations/{name}/lookup` | Internet lookup → candidate record in `phonetic_names.json` + generated preview audio; dictionary hits auto-confirm as `common`, Wiktionary hits are stored `needs review` |
| `POST /api/pronunciations/{name}/confirm` | Mark a candidate confirmed (only confirmed spellings are applied to TTS) |
| `POST /api/pronunciations/{name}/clear` | Delete the record and its preview audio |
| `GET /api/pronunciations/audio?name=` | Serve the preview WAV from `.umbra/pronunciation_audio/{sanitized}.wav` |
| `GET /api/pronunciations/status` | Read batch progress from `pronunciation_status.json` (`{status, detail, done, total}`) |
| `POST /api/auto-pronunciations` | Background batch lookup over all proper nouns; auto-confirms common words, generates preview audio for Wiktionary hits, re-looks-up legacy records, writes progress to `pronunciation_status.json` |
| `POST /api/voices/{id}/approve` | Approve character voice (records `voice_review_history`) |
| `POST /api/voices/{id}/reject` | Reject character voice |
| `POST /api/voices/{id}/edit` | Queue a voice edit prompt (stored in history) |
| `POST /api/chapters/{id}/approve` | Approve chapter (`review_decisions`) |
| `POST /api/chapters/{id}/regenerate` | Stub (returns `{"status": "queued"}`) |
| `PUT /api/utterance/{utt_id}/speaker` | Reassign utterance speaker; deletes stale WAV and regenerates via Edge fallback |
| `PUT /api/utterance/{utt_id}/text` | Edit utterance text; deletes stale WAV |
| `DELETE /api/utterance/{utt_id}` | Delete utterance; renumber chapter; regenerate chapter WAVs via Qwen3 |
| `POST /api/chapter/{chapter_id}/utterance` | Append utterance; generate its WAV in background |
| `POST /api/orphaned-utterances/{utt_id}/reassign` | Reassign one orphaned utterance |
| `POST /api/orphaned-utterances/reassign-all` | Reassign all utterances that had the given old names |
| `POST /api/analyze` | Run character discovery in background (`analysis_status.json`) |
| `GET /api/analysis-status` | Read `analysis_status.json` |
| `POST /api/segment-utterances` | Run segmentation + LLM attribution + emotion analysis (skips attribution if LM Studio offline) |
| `POST /api/generate-audiobook` | Run full Qwen3-TTS generation in background (`scripts.generate_all_audio.main`) |
| `GET /api/generation/status` | Live generation progress (items, success/failure counts, interrupted flag) |
| `GET /api/pipeline/status` | Pipeline state machine status |
| `POST /api/pipeline/status` | Update pipeline status fields |
| `POST /api/pipeline/start` | Start pipeline (`run_pipeline_background`) |
| `POST /api/pipeline/pause` | Pause pipeline |
| `POST /api/pipeline/resume` | Resume paused pipeline |
| `POST /api/pipeline/cancel` | Cancel pipeline |
| `POST /api/pipeline/reset?step=` | Reset `discover` (speakers/voices/phonetics) / `voices` / `segment` (utterances) |
| `POST /api/pipeline/run-all` | Full pipeline: discover → voices → segment+attribute → generate → quality gate → export M4B/MP3 |
| `POST /api/chapter/{id}/build` | Stitch chapter WAVs into `ch{id}_full.wav` after quality gate |
| `GET /api/quality/check` | Quality-check all chapters (JSON) |
| `GET /api/quality/check/{chapter_id}` | Quality-check one chapter (JSON) |
| `GET /api/export/files` | List export directory files with download URLs |
| `GET /api/export/download/{path}` | Download export file (path-traversal protected) |
## Pages
### Dashboard (`/`)
**Stats displayed:**
- Project name, source path, and format (from `project` row + `scan_results.json`)
- Chapter count, character count (non-narrator), utterance count, audio segment count
- Scene count (`<<<>>>` markers across chapter source text)
- Speaker count (from `speakers.json`) and voice reference count (`*_ref.wav` in `.umbra/voices`)
- Pipeline status (current step, progress, error)
- Export files (name + size, with download links)
### Voice Review (`/voices`)
HTMX-powered page that loads character data on demand.
**`_voice_list.html` partial:**
| Column | Content |
|--------|---------|
| Name | Character name |
| Description | Truncated `voice_description` JSON |
| Role | Badge: Narrator / Character |
| Status | Badge: voiced_approved / rejected / pending |
| Energy params | Derived `exaggeration` / `cfg_weight` from `ATTRIBUTE_PARAM_MAP[energy]` (deterministic from name hash; narrator uses fixed values) |
| Audio | HTML5 `<audio>` player (if voice_ref exists) |
| Actions | Approve / Reject / Edit-prompt buttons (HTMX POST) |
**Approve/Reject flow:**
1. HTMX POST to `/api/voices/{id}/approve` or `/api/voices/{id}/reject`
2. Inserts a `voice_review_history` record (action, prompt, resulting exaggeration/cfg weight)
3. Updates `characters.status` to `voiced_approved` or `rejected`
### Speakers (`/speakers`)
Speaker management backed by `.umbra/speakers.json` (not the DB `characters` table). HTMX `_speakers_list.html` partial.
**Columns:** primary name, aliases (with move/promote/remove actions), demographics (type/gender), vocal traits, register, flagged status (no traits + unknown gender + no description → flagged for review), voice status, audio player.
**Operations:**
- Add / edit / delete speaker
- Add / remove / move / promote aliases
- Merge one speaker into another (attributes and aliases merged; utterances repointed to the target)
- Delete flags the speaker's utterances as `__unassigned__` for later review
- Set voice status (`pending`/`approved`); approving upserts the reference into the cross-project `voice_registry`
- Regenerate voice reference (single speaker or all missing)
### Proper Names (`/proper-names`)
Extracts capitalized noun phrases from chapter source text, filters titles/stopwords, counts frequency, and flags which are known speakers. Pronunciation is reviewed per name through a lookup → listen → confirm flow:
- **Lookup** (`POST /api/pronunciations/{name}/lookup`): searches dictionaryapi.dev (on the name and its lowercase form) then the Wiktionary API. Dictionary hits are well-understood common words — auto-confirmed and marked **Common**. Wiktionary hits are real names/places — stored as unconfirmed candidates (**Needs review**).
- **Preview audio**: the phonetic spelling is synthesized with the narrator's cloned voice (`.umbra/pronunciation_audio/{sanitized}.wav`) so the reviewer hears exactly what the substitution will sound like. TTS failures (e.g. transient server errors) are captured into `audio_error` on the record and surfaced in the UI without blocking manual entry.
- **Confirm**: after listening, the reviewer confirms the candidate; only confirmed spellings are returned by `load_phonetic_names()` and substituted into utterance text before synthesis.
- **Edit**: changing the spelling stores it as an unconfirmed user candidate and regenerates the preview audio.
- **Batch** ("Look Up All Pronunciations"): background pass over every proper noun, skipping records that already have a real source, re-looking-up legacy records, with progress polled from `/api/pronunciations/status`.
- Fictional/invented names with no dictionary or Wiktionary entry show "not found" and are set manually.
- The page offers a text filter and a status filter (Needs review / Confirmed / Common / No pronunciation) with per-row status badges.
Shared logic lives in `umbraaudio/pronunciation.py` (record load/save with legacy `{name: "SPELLING"}` migration, IPA→ASCII respelling, lookups, preview synthesis). `scripts/generate_all_audio.py` loads confirmed spellings once and applies them (longest-name-first) to every utterance before synthesis.
### Orphaned Utterances (`/orphaned-utterances`)
Lists utterances whose `speaker_name` is (a) not a known speaker name/alias, or (b) `__unassigned__`. Each can be reassigned to any known speaker; "reassign all" repoints every utterance that previously used a given old name.
### Voice Tests (`/voice-tests`)
Lists verification-phrase test samples from `.umbra/voice_tests/*.wav` joined with `voice_assignments.json` and `speakers.json` for voice/demographics context. Audio served via `/voice-test-audio/{filename}` (falls back to `.umbra/voices`).
### Chapter Audio Review (`/chapters-audio`)
- Listing page: one row per chapter (title, utterance count, directory `ch{id}`)
- Detail page (`/chapters-audio/ch{id}`): every utterance with index, speaker, truncated text, resolved voice, and an audio player for `utt_{index:04d}.wav` (if present)
- Per-utterance actions: reassign speaker (`PUT /api/utterance/{id}/speaker`), edit text (`PUT /api/utterance/{id}/text`), delete (`DELETE /api/utterance/{id}`), append utterance (`POST /api/chapter/{chapter_id}/utterance`); edits delete the stale WAV and trigger background regeneration
- Speaker dropdown canonicalization: `chapter_audio_review` builds a `lower_to_canonical` map from the primary names, **all aliases**, and the **first word of each primary name**, so an utterance whose stored speaker is an alias/first-word (e.g. `kainda` for `Kainda Walters Foster Murphy`) still selects the correct dropdown option instead of defaulting to AI-1
- Chapter build (`POST /api/chapter/{id}/build`): quality-gates then stitches into `ch{id}_full.wav`
### Registry (`/registry`)
Cross-project voice reference registry (`voice_registry_path` DB, default `~/.umbra/voice_registry.db`). Lists approved characters with their reference paths and audio, searchable by name or alias.
### Chapter Review (`/chapters/{id}`)
**Features:**
- Chapter navigation links (all 35 chapters, highlights current)
- Approve Chapter button (HTMX POST)
- HTMX loads `_utterance_list.html` partial
**`_utterance_list.html` partial:**
For each utterance:
- Speaker name (from `speaker_name` column)
- Type badge (narration / dialogue)
- Utterance text
- Audio player (if audio exists)
**Audio playback:**
```html
<audio controls preload="none">
<source src="/audio/{chapter_id}/{filename}" type="audio/wav">
</audio>
```
### Quality Report (`/quality`)
**Sections:**
1. **Defects table** — All logged quality issues with severity badges
2. **Chapter summary** — Per-chapter audio count, MOS scores, similarity
**SQL for chapter summary:**
```sql
SELECT ch.id, ch.title,
COUNT(ga.id) as audio_count,
MIN(ga.mos_score) as min_mos,
MAX(ga.mos_score) as max_mos,
AVG(ga.speaker_similarity) as avg_sim
FROM chapters ch
LEFT JOIN utterances u ON u.chapter_id = ch.id
LEFT JOIN generated_audio ga ON ga.utterance_id = u.id
GROUP BY ch.id ORDER BY ch.chapter_index
```
## Database Access
The web routes use synchronous sqlite3 (not aiosqlite) and run `SCHEMA_SQL` (CREATE TABLE IF NOT EXISTS for `project`, `chapters`, `characters`, `voice_review_history`, `pronunciation_guide`, `utterances`, `generated_audio`, `quality_reports`, `review_decisions`, plus indexes) on every connection:
```python
def _get_db(request: Request) -> sqlite3.Connection:
config = request.app.state.config
db_path = config.data_dir / "project.db"
db = sqlite3.connect(str(db_path))
db.row_factory = sqlite3.Row
db.executescript(SCHEMA_SQL)
return db
```
State JSON files are also read/written directly by routes: `speakers.json`, `voice_assignments.json`, `voice_status.json`, `phonetic_names.json`, `pipeline_status.json`, `analysis_status.json`, `scan_results.json`, `defects.json`.
## Background Job Pattern
Long-running work (analysis, segmentation, voice generation, audiobook generation, pronunciation lookup, chapter builds, utterance regeneration) runs in daemon `threading.Thread`s. Status surfaces through:
- `pipeline_status.json` — coarse step/status/detail/progress for pipeline flows
- `analysis_status.json` — character discovery progress
- `generation_manager` — item-level live progress for `/api/generation/status` (rejects concurrent runs with HTTP 409)
- WAV file existence — the generation script's natural resume marker
## Theme
Dark theme with custom CSS:
- Background: `#1a1a2e`
- Text: `#e0e0e0`
- Accent: `#7c3aed` (purple)
- Card background: `#16213e`
- Button approve: `#22c55e` (green)
- Button reject: `#ef4444` (red)
## HTMX Integration
Pages use HTMX for partial updates:
- `hx-trigger="load"` — Load content on page load
- `hx-get="/voices/partial"` — Fetch partial HTML
- `hx-target="#voice-list"` — Target element for replacement
- `hx-swap="innerHTML"` — Replace content
## Starting the Server
```bash
# Via CLI
umbraaudio review --host 0.0.0.0 --port 8420
# Via run_review.sh
setsid /home/bm/Apps/UmbraAudio/run_review.sh > /tmp/umbraaudio_review.log 2>&1 &
```
## Known Limitations
- `POST /api/chapters/{id}/regenerate` is a stub (returns `{"status": "queued"}`); real regeneration is file-targeted via utterance edit/delete endpoints
- `POST /api/utterance/{id}/speaker` regenerates the utterance with the **Edge-TTS fallback**, not Qwen3-TTS (inconsistent with bulk generation)
- No authentication/authorization
- No WebSocket for real-time updates (long-running jobs poll JSON status files)
- No pagination for large utterance lists
- Database connection opened per-request (no connection pooling)
- Dashboard speaker/voice counts read `speakers.json`/`.umbra/voices`, which may drift from the DB `characters` table

View File

@@ -0,0 +1,271 @@
# Spec: CLI & Orchestrator
## Overview
The CLI provides Click-based commands for each pipeline pass. The orchestrator coordinates the full pipeline with LM Studio lifecycle management.
## CLI Commands
### Global Options
```bash
umbraaudio [--project DIR] COMMAND
```
| Option | Description |
|--------|-------------|
| `--project`, `-p` | Project directory (default: `.`) |
### Command: `init`
```bash
umbraaudio init MANUSCRIPT [--name NAME]
```
| Argument/Option | Type | Description |
|-----------------|------|-------------|
| `MANUSCRIPT` | Path (required) | Source manuscript file |
| `--name`, `-n` | str (optional) | Project name |
**Flow:**
1. Detect format via `detect_format()`
2. Parse manuscript via `parse_manuscript()`
3. Create project in DB
4. Insert chapters into DB
5. Update status to "parsed"
### Command: `analyze`
```bash
umbraaudio analyze
```
**Flow:**
1. BookNLP analysis per chapter
2. Character merging and deduplication
3. LLM profiling for each character (up to 30)
4. Pronunciation guide for unusual terms
5. Update status to "analyzed"
### Command: `voices`
```bash
umbraaudio voices
```
**Flow:**
1. Load non-narrator characters with `voice_description`
2. Load Chatterbox TTS
3. Generate voice reference WAV per character
4. Extract speaker embedding
5. Update DB with paths and embeddings
### Command: `map-audio`
```bash
umbraaudio map-audio
```
**Flow:**
1. Segment each chapter via `Segmenter.segment()`
2. Build generation manifests
3. Save to `.umbra/manifests/ch{id}.json`
### Command: `test`
```bash
umbraaudio test [--chapter N]
```
| Option | Type | Description |
|--------|------|-------------|
| `--chapter`, `-c` | int (optional) | Chapter to test (default: 1) |
**Flow:**
1. Load manifest for specified chapter
2. Load Chatterbox TTS
3. Generate audio for the chapter
4. Report results
### Command: `generate`
```bash
umbraaudio generate [--chapters RANGE]
```
| Option | Type | Description |
|--------|------|-------------|
| `--chapters`, `-c` | str | Chapter range: "all", "3", "1-5", "3,7" |
**Flow:**
1. Load Chatterbox TTS
2. Load manifest per chapter
3. Generate audio with caching, retry, normalization, watermark
4. Report progress per chapter
### Command: `export`
```bash
umbraaudio export [--format FORMAT]
```
| Option | Type | Description |
|--------|------|-------------|
| `--format`, `-f` | str (multiple) | Export formats: "m4b", "mp3", "wav" |
**Flow:**
1. Load normalized audio (fallback to raw)
2. Stitch chapter audio
3. Export in requested formats
### Command: `pipeline`
```bash
umbraaudio pipeline [--chapters RANGE] [--author NAME]
```
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `--chapters`, `-c` | str | "all" | Chapter range |
| `--author`, `-a` | str | "B.D. Murphy" | Author name for watermark |
**Flow:** Calls `orchestrator.run_pipeline()`. See Orchestrator section below.
### Command: `status`
```bash
umbraaudio status
```
Displays project status, chapter count, character count, and progress in a Rich table.
### Command: `review`
```bash
umbraaudio review [--host HOST] [--port PORT]
```
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `--host` | str | "127.0.0.1" | Server bind address |
| `--port` | int | 8420 | Server port |
Launches the web review UI.
## Chapter Range Parsing
```python
def _parse_chapter_range(chapters_str: str, total: int) -> range
```
| Input | Result |
|-------|--------|
| `"all"` | `range(0, total)` |
| `"3"` | `range(2, 3)` |
| `"1-5"` | `range(0, 5)` |
| `"3,7"` | `range(2, 3) ∪ range(6, 7)` (union) |
## Orchestrator (`orchestrator.py`)
### Function: `run_pipeline()`
```python
async def run_pipeline(
project_dir: Path,
chapters: str | None,
author_name: str,
) -> dict[str, Any]
```
**Returns:**
```python
{
"stages": list[dict], # Per-stage results
"defects": list[dict], # Quality defects
"summary": dict, # Final statistics
}
```
### Full Pipeline Stages
```
1. init
└── Load project and chapters from DB
2. lms_unload
└── Run: lms unload --all (free VRAM)
3. chatterbox_load
└── Load ChatterboxTTS (~3.2 GB VRAM)
4. Pass 1: Analysis (conditional: status == "initialized" or "parsed")
├── BookNLP analysis per chapter
├── _merge_characters() (deduplicate, filter noise)
├── CharacterProfiler.profile_character() per character
├── PronunciationGenerator.find_unusual_terms()
└── Update DB + status → "analyzed"
5. Pass 2: Voice references
├── VoiceDesigner.generate_voice_reference() per character
├── Skip if voice_ref_path exists on disk
├── ChatterboxEngine.extract_embedding()
└── Update DB
6. Pass 3: Segment & manifest
├── Segmenter.segment() per chapter
├── build_generation_manifest()
└── save_manifest() to .umbra/manifests/
7. Pass 5: Generate audio
├── ChapterGenerator per chapter
├── Per-chapter: gc.collect() + torch.cuda.empty_cache()
└── AudioNormalizer per file
8. Quality check
└── check_acx_compliance() per generated file
9. Export
├── export_mp3()
└── export_wav()
10. chatterbox_unload
└── Unload Chatterbox from VRAM
11. lms_restart
└── Run: lms server stop && lms server start
```
### LM Studio Lifecycle
The orchestrator manages LM Studio to prevent VRAM conflicts:
```python
def unload_lm_studio() -> bool:
result = subprocess.run(["lms", "unload", "--all"], ...)
return result.returncode == 0
def stop_lm_studio() -> bool:
result = subprocess.run(["lms", "server", "stop"], ...)
return result.returncode == 0
def start_lm_studio() -> bool:
result = subprocess.run(["lms", "server", "start"], ...)
return result.returncode == 0
```
**Order:** Unload → Stop → (generate) → Unload Chatterbox → Start
### Error Handling
- Entire pipeline wrapped in try/except
- On failure: unload Chatterbox, restart LM Studio, log CRITICAL defect
- Individual utterance failures don't abort the pipeline
- Defects collected and written to `.umbra/defects.json`
### Progress Reporting
Rich progress bars per stage:
- Pass 1: Per-chapter analysis
- Pass 2: Per-character voice design
- Pass 3: Per-chapter segmentation
- Pass 5: Per-utterance generation with ETA

View File

@@ -0,0 +1,269 @@
# Spec: Configuration & Dependencies
## Overview
UmbraAudio uses Pydantic Settings for configuration with environment variable override support. All config lives in `umbraaudio/config.py`.
## Configuration Hierarchy
```
AppConfig (root)
├── project_dir: Path
├── data_dir: Path
├── tts: TTSConfig
├── quality: QualityConfig
├── audio: AudioConfig
├── llm: LLMConfig
└── web: WebConfig
```
**Environment variable prefix:** `UMBRA_` (e.g., `UMBRA_TTS_DEVICE=cpu`)
## Configuration Classes
### TTSConfig
```python
class TTSConfig(BaseSettings):
engine: Literal["chatterbox", "qwen"] = "chatterbox"
device: Literal["cuda", "cpu"] = "cuda"
sample_rate: int = 24000
max_retries: int = 3
retry_delay: float = 2.0
```
| Field | Default | Env Var | Description |
|-------|---------|---------|-------------|
| `engine` | `"chatterbox"` | `UMBRA_TTS_ENGINE` | TTS engine selection |
| `device` | `"cuda"` | `UMBRA_TTS_DEVICE` | Compute device |
| `sample_rate` | `24000` | `UMBRA_TTS_SAMPLE_RATE` | Audio sample rate (Hz) |
| `max_retries` | `3` | `UMBRA_TTS_MAX_RETRIES` | Generation retry attempts |
| `retry_delay` | `2.0` | `UMBRA_TTS_RETRY_DELAY` | Delay between retries (seconds) |
### QualityConfig
```python
class QualityConfig(BaseSettings):
min_mos: float = 3.8
min_test_mos: float = 4.0
max_wer: float = 0.03
min_speaker_similarity: float = 0.80
min_test_speaker_similarity: float = 0.85
min_artifact_score: float = 3.5
```
| Field | Default | Description |
|-------|---------|-------------|
| `min_mos` | `3.8` | Minimum Mean Opinion Score |
| `min_test_mos` | `4.0` | Minimum MOS for test pass |
| `max_wer` | `0.03` | Maximum Word Error Rate (3%) |
| `min_speaker_similarity` | `0.80` | Minimum speaker embedding similarity |
| `min_test_speaker_similarity` | `0.85` | Minimum for test pass |
| `min_artifact_score` | `3.5` | Minimum artifact detection score |
### AudioConfig
```python
class AudioConfig(BaseSettings):
target_rms_db: float = -20.0
peak_limit_db: float = -3.0
noise_floor_db: float = -60.0
silence_between_utterances_ms: int = 500
silence_between_paragraphs_ms: int = 2000
silence_between_chapters_ms: int = 4000
```
| Field | Default | Description |
|-------|---------|-------------|
| `target_rms_db` | `-20.0` | Target RMS level for normalization |
| `peak_limit_db` | `-3.0` | Maximum peak level |
| `noise_floor_db` | `-60.0` | Noise floor threshold |
| `silence_between_utterances_ms` | `500` | Gap between sentences (ms) |
| `silence_between_paragraphs_ms` | `2000` | Gap between paragraphs (ms) |
| `silence_between_chapters_ms` | `4000` | Gap between chapters (ms) |
### LLMConfig
```python
class LLMConfig(BaseSettings):
provider: Literal["lmstudio", "openai", "auto"] = "auto"
lmstudio_base_url: str = "http://localhost:1234/v1"
lmstudio_model: str = "local-model"
openai_model: str = "gpt-4o"
temperature: float = 0.3
max_tokens: int = 4096
```
| Field | Default | Description |
|-------|---------|-------------|
| `provider` | `"auto"` | LLM provider selection |
| `lmstudio_base_url` | `"http://localhost:1234/v1"` | LM Studio API endpoint |
| `lmstudio_model` | `"local-model"` | LM Studio model name |
| `openai_model` | `"gpt-4o"` | OpenAI model name |
| `temperature` | `0.3` | Generation temperature |
| `max_tokens` | `4096` | Maximum response tokens |
**Provider selection (auto mode):**
1. Try LM Studio at `lmstudio_base_url`
2. Fall back to OpenAI API
3. Raise error if neither available
### WebConfig
```python
class WebConfig(BaseSettings):
host: str = "127.0.0.1"
port: int = 8420
auto_open: bool = True
```
### AppConfig
```python
class AppConfig(BaseSettings):
project_dir: Path = Path(".")
data_dir: Path = Path(".umbra")
tts: TTSConfig = Field(default_factory=TTSConfig)
quality: QualityConfig = Field(default_factory=QualityConfig)
audio: AudioConfig = Field(default_factory=AudioConfig)
llm: LLMConfig = Field(default_factory=LLMConfig)
web: WebConfig = Field(default_factory=WebConfig)
```
**Properties:**
| Property | Returns | Description |
|----------|---------|-------------|
| `db_path` | `Path` | `.umbra/project.db` |
| `voices_dir` | `Path` | `.umbra/voices/` |
| `audio_dir` | `Path` | `.umbra/audio/` |
| `export_dir` | `Path` | `.umbra/export/` |
**Methods:**
| Method | Description |
|--------|-------------|
| `ensure_dirs()` | Creates all data directories |
### Factory Function
```python
def load_config(project_dir: Path | None = None) -> AppConfig
```
1. Creates `AppConfig()`
2. Sets `project_dir` and `data_dir` if provided
3. Calls `ensure_dirs()`
4. Returns config
## Dependencies
### Core Dependencies
| Package | Version | Purpose |
|---------|---------|---------|
| `click` | >=8.1 | CLI framework |
| `rich` | >=13.0 | CLI formatting |
| `aiosqlite` | >=0.19 | Async SQLite |
| `ebooklib` | >=0.18 | EPUB parsing |
| `PyMuPDF` | >=1.23 | PDF parsing |
| `booknlp` | >=1.0 | NLP analysis |
| `openai` | >=1.10 | LLM API client |
| `httpx` | >=0.25 | HTTP client |
| `chatterbox-tts` | >=0.1 | Primary TTS |
| `torch` | >=2.1 | ML framework |
| `torchaudio` | >=2.1 | Audio processing |
| `librosa` | >=0.10 | Audio analysis |
| `soundfile` | >=0.12 | Audio I/O |
| `pydub` | >=0.25 | Audio manipulation |
| `speechmos` | >=0.0.1 | MOS scoring |
| `openai-whisper` | >=20231117 | Speech recognition |
| `speechbrain` | >=0.5 | Speaker embeddings |
| `fastapi` | >=0.104 | Web framework |
| `uvicorn[standard]` | >=0.24 | ASGI server |
| `jinja2` | >=3.1 | Templating |
| `python-multipart` | >=0.0.6 | Form handling |
| `numpy` | >=1.24 | Numerical computing |
| `pydantic` | >=2.5 | Data validation |
| `pydantic-settings` | >=2.1 | Settings management |
### Optional Dependencies
| Group | Package | Purpose |
|-------|---------|---------|
| `dev` | `pytest>=7.4` | Testing |
| `dev` | `pytest-asyncio>=0.21` | Async testing |
| `dev` | `pytest-cov>=4.1` | Coverage |
| `dev` | `ruff>=0.1` | Linting |
| `dev` | `mypy>=1.7` | Type checking |
| `qwen` | `transformers>=4.36` | Qwen TTS support |
| `qwen` | `qwen3-tts>=0.1` | Qwen TTS engine |
### System Dependencies
| Tool | Purpose | Required |
|------|---------|----------|
| FFmpeg | Audio export (M4B, MP3) | For export |
| `lms` CLI | LM Studio management | For pipeline |
| CUDA toolkit | GPU acceleration | For TTS |
## Build System
```toml
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project.scripts]
umbraaudio = "umbraaudio.cli:main"
```
## Tool Configuration
### Ruff
```toml
[tool.ruff]
target-version = "py311"
line-length = 100
select = ["E", "F", "I", "N", "W", "UP", "B", "S", "SIM", "TCH"]
```
### Mypy
```toml
[tool.mypy]
python_version = "3.11"
strict = true
plugins = ["pydantic.mypy"]
```
### Pytest
```toml
[tool.pytest.ini_options]
testpaths = ["tests"]
asyncio_mode = "auto"
```
## Runtime Paths
```
.umbra/ # Data directory
├── project.db # SQLite database
├── voices/ # Voice reference WAVs
├── audio/ # Raw generated audio
├── audio_normalized/ # Normalized audio
├── manifests/ # Generation manifests
├── cache/ # Generation cache
│ └── cache_index.json
├── export/ # Final audiobook files
└── defects.json # Quality defect log
```
## Cache Locations
| Cache | Location | Description |
|-------|----------|-------------|
| HuggingFace | `/mnt/models/huggingface` | Model weights (symlinked) |
| Torch | `/mnt/models/torch` | Torch hub cache (symlinked) |
| Generation | `.umbra/cache/` | Text+voice_ref hash cache |
| Pip | `~/.cache/pip` | Package download cache |

220
specs/12-quality.md Normal file
View File

@@ -0,0 +1,220 @@
# Spec: Quality Checking
## Overview
Quality checking validates generated audio against configurable thresholds for MOS (Mean Opinion Score), WER (Word Error Rate), speaker similarity, and ACX compliance. Runs both during generation (per-file) and as a post-generation audit.
## Components
### 1. Quality Checker (`quality/__init__.py`)
```python
class QualityChecker:
def __init__(self, config: Any = None) -> None
def check_audio_quality(self, audio_path: Path) -> dict[str, float | None]
def measure_mos(self, audio_path: Path) -> float | None
def measure_wer(self, audio_path: Path, reference_text: str) -> float | None
```
#### MOS Measurement
```python
def measure_mos(self, audio_path: Path) -> float | None
```
- Uses `speechmos.jnt_fer_2023` (UTMOS model)
- Reads audio via soundfile
- Returns float score (1.0–5.0) or None on failure
- Model loaded lazily on first call
#### WER Measurement
```python
def measure_wer(self, audio_path: Path, reference_text: str) -> float | None
```
- Uses OpenAI Whisper (`"base"` model) for transcription
- Computes WER via Levenshtein distance
- Returns `min(wer, 1.0)` (capped at 1.0)
**Levenshtein Distance:**
```python
@staticmethod
def _levenshtein(s1: str, s2: str) -> int
```
Standard dynamic programming implementation.
#### Full Quality Check
```python
def check_audio_quality(self, audio_path: Path) -> dict[str, float | None]
```
**Returns:**
```python
{
"mos_score": float | None,
"wer_score": None, # Not implemented in current pipeline
"speaker_similarity": None, # Not implemented in current pipeline
"artifact_score": None, # Not implemented in current pipeline
}
```
### 2. Quality Report (`quality/report.py`)
```python
class QualityReport:
def __init__(self) -> None
def add_metric(self, name: str, value: float, threshold: float, passed: bool) -> None
def evaluate(self, config: QualityConfig) -> bool
def to_dict(self) -> dict
def save(self, output_path: Path) -> None
@classmethod
def load(cls, path: Path) -> QualityReport
```
**Evaluation logic:**
```python
def evaluate(self, config: QualityConfig) -> bool:
return (
self.metrics.get("mos_score", 0) >= config.min_mos
and self.metrics.get("wer_score", 1) <= config.max_wer
and self.metrics.get("speaker_similarity", 0) >= config.min_speaker_similarity
)
```
### 3. Consistency Checker (`quality/consistency.py`)
```python
class ConsistencyChecker:
def check_cross_chapter(
self,
chapter_audio_files: list[Path],
character_name: str,
threshold: float = 0.85,
) -> dict
```
**Algorithm:**
1. Extract speaker embedding from each file using SpeechBrain ECAPA-TDNN
2. Compare all embeddings to the first one (reference)
3. Compute cosine similarity for each pair
**Returns:**
```python
{
"consistent": bool, # All similarities >= threshold
"min_score": float, # Minimum similarity score
"threshold": float, # Comparison threshold
"scores": list[float], # All similarity scores
"character": str, # Character name
}
```
### 4. ACX Compliance (in AudioNormalizer)
```python
# From assembly/normalize.py
def check_acx_compliance(self, audio_path: Path) -> dict
```
**ACX Requirements:**
| Metric | Range | Check |
|--------|-------|-------|
| RMS level | -23.0 to -18.0 dB | `-23.0 <= rms_db <= -18.0` |
| Peak level | Below -3.0 dB | `peak_db < -3.0` |
**Returns:**
```python
{
"rms_db": float,
"peak_db": float,
"rms_in_range": bool,
"peak_below_limit": bool,
"compliant": bool,
}
```
## Quality Thresholds (from QualityConfig)
| Metric | Threshold | Description |
|--------|-----------|-------------|
| `min_mos` | 3.8 | Minimum Mean Opinion Score |
| `min_test_mos` | 4.0 | Minimum MOS for test pass |
| `max_wer` | 0.03 | Maximum Word Error Rate (3%) |
| `min_speaker_similarity` | 0.80 | Minimum speaker embedding similarity |
| `min_test_speaker_similarity` | 0.85 | Minimum for test pass |
| `min_artifact_score` | 3.5 | Minimum artifact detection score |
## Defect Tracking
Quality issues are logged to the database:
```python
db.log_defect(
project_id=1,
stage="quality",
severity="WARNING", # or "ERROR", "CRITICAL"
message="ACX non-compliance: RMS=-24.5dB, Peak=-2.1dB",
context=None,
chapter_id=5,
)
```
**Severity levels:**
- `WARNING` — Below threshold but non-blocking (e.g., ACX RMS slightly out of range)
- `ERROR` — Significant quality issue (e.g., generation failure)
- `CRITICAL` — Pipeline-level failure (e.g., model loading error)
## Defect Output
Defects are written to `.umbra/defects.json`:
```json
{
"defects": [
{
"stage": "quality",
"severity": "WARNING",
"message": "ACX non-compliance: RMS=-24.5dB",
"chapter_id": 5
}
],
"summary": {
"total_defects": 6,
"warnings": 6,
"errors": 0,
"critical": 0
}
}
```
## Integration Points
| Stage | Quality Check | When |
|-------|--------------|------|
| Pass 5 (Generate) | File existence check | Per utterance |
| Post-generation | ACX compliance | Per file |
| Pass 4 (Test) | Full quality suite | Single chapter |
| Web UI | Defect display | On demand |
| Export | Pre-export validation | Before stitching |
## SpeechBrain ECAPA-TDNN
```python
# From voices/embeddings.py
class EmbeddingManager:
def _ensure_loaded(self) -> None:
self._encoder = EncoderClassifier.from_hparams(
source="speechbrain/spkrec-ecapa-voxceleb"
)
def extract(self, audio_path: Path) -> bytes
def compare(self, emb1: bytes, emb2: bytes) -> float
def verify_consistency(self, reference, test_audio, threshold=0.85) -> tuple[bool, float]
```
- Cosine similarity: `dot(a,b) / (norm(a) * norm(b))`
- Range: [-1, 1] (1 = identical, 0 = unrelated, -1 = inverse)
- Typical threshold: 0.85 for same-speaker verification

View File

@@ -0,0 +1,275 @@
# Spec: Analysis — LLM Character Discovery (Pass 1b)
## Overview
Pass 1b extracts characters from chapter text using a 3-pass LLM analysis pipeline, builds character profiles with vocal traits and communication styles, validates alias relationships using text co-occurrence evidence, and assigns unique voices from the Edge-TTS voice library. The narrator is handled as a special case — not a character, but a dedicated voice.
## Architecture: 3-Pass Discovery Pipeline
```
Chapters (source_text)
│
▼
┌─────────────────────────────────────────────────┐
│ PASS 1: CHAPTER-BY-CHAPTER DISCOVERY │
│ LLM identifies characters, aliases, traits │
│ per chapter using CHARACTER_DISCOVERY_PROMPT │
│ │
│ - Known speakers passed forward │
│ - Scene awareness (scene_index output) │
│ - Alias evidence required │
│ - Unidentified speakers flagged │
│ │
│ Output: .umbra/discovery/discovery_pass1.json │
└─────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────┐
│ PASS 2: ALIAS VALIDATION │
│ Validates every claimed alias pair using │
│ text co-occurrence evidence │
│ │
│ - Scene-level co-occurrence check │
│ - Two proper names in same scene = not alias │
│ - Invalid aliases removed │
│ - Re-merges with cleaned alias lists │
│ │
│ Output: .umbra/discovery/discovery_pass2.json │
└─────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────┐
│ PASS 3: CROSS-CHAPTER RECONCILIATION │
│ Final validation across all chapters │
│ │
│ - Flags suspicious merges │
│ - Lists unresolved unidentified speakers │
│ - Emits validation log │
│ │
│ Output: .umbra/discovery/discovery_pass3.json │
└─────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────┐
│ FINAL: speakers.json │
│ Merged, validated speaker list │
│ Narrator auto-added if missing │
└─────────────────────────────────────────────────┘
```
## Components
### 1. Character Discovery Engine (`analysis/character_discovery.py`)
#### Class: `CharacterDiscoverer`
```python
class CharacterDiscoverer:
def __init__(self, llm_config: LLMConfig) -> None
async def discover_speakers(
self,
chapters: list[dict[str, Any]],
book_title: str = "",
max_chars: int = 40000,
) -> DiscoveryResult
```
**API Contract (preserved from prior version):**
- Input: `chapters` list of dicts with `source_text`, `title`, `chapter_index` keys
- Output: `DiscoveryResult` with `.speakers`, `.save()`, `.load()` methods
- Callers: CLI (`cli.py`), Web routes (`web/routes.py`)
#### Pass 1: Chapter-by-Chapter Discovery
1. For each chapter, send text to LLM with `CHARACTER_DISCOVERY_PROMPT.md`
2. Previously discovered speakers passed as `{known_speakers_section}` to prevent duplicates
3. LLM output includes: `speaking_characters`, `non_speaking_characters`, `validation_notes`
4. Each character includes: `scenes_appeared`, `unidentified`, `unidentified_reason`, `alias_evidence`
5. Results accumulated into `ChapterSpeakers` list
6. After all chapters: merge across chapters via `_merge_chapter_results()`
7. Save to `.umbra/discovery/discovery_pass1.json`
#### Pass 2: Alias Validation
For each chapter's discovered characters:
1. Parse chapter text into scenes (by `<<<>>>` breaks or paragraph grouping)
2. For each claimed alias pair (primary_name, alias):
- **Proper Name Test**: If both have distinct proper names and one is not contained in the other → invalid
- **Co-occurrence Test**: If both names appear as speakers in the same scene → invalid
3. Remove invalid aliases from character data
4. Re-merge across chapters with cleaned alias lists
5. Save to `.umbra/discovery/discovery_pass2.json`
#### Pass 3: Cross-Chapter Reconciliation
1. Flag any speakers still marked as `unidentified`
2. Scan all speakers for suspicious merges (two distinct proper names in primary_name + aliases)
3. Emit validation log with all corrections and warnings
4. Save to `.umbra/discovery/discovery_pass3.json`
### 2. LLM Prompt (`CHARACTER_DISCOVERY_PROMPT.md`)
**Key rules in the prompt:**
- **Rule 1 (Scene Awareness)**: Track which characters appear in which scenes
- **Rule 5 (Alias Validation)**: Hard rules for valid vs invalid aliases:
- VALID: Title + name (e.g., "Princess" = "Kainda")
- INVALID: Two distinct proper names (e.g., "Kainda" ≠ "Captain Willamina Wake")
- INVALID: Shared title only (e.g., "Captain" ≠ "Princess")
- INVALID: Role description (e.g., "the guard" ≠ "John")
- **Rule 6 (Unidentified Speaker Flagging)**: Flag speakers not introduced in the scene
- **Rule 7 (Cross-Chapter Identity)**: Use known speakers list, create new entry when in doubt
**Output schema additions:**
- `scenes_appeared`: List of scene indices (1-based) where character appears
- `unidentified`: Boolean flag for unidentified speakers
- `unidentified_reason`: Explanation of why the speaker is unidentified
- `alias_evidence`: Textual evidence confirming each alias is valid
- `validation_notes`: Any concerns about the chapter's analysis accuracy
### 3. Scene Parsing (`_parse_scenes`)
Splits chapter text into scenes for co-occurrence validation:
1. Primary: Split on `<<<>>>` scene break markers
2. Fallback: Split on paragraph breaks (`\n\s*\n`) with 3000-char grouping
3. Always returns at least one scene (the full chapter text)
### 4. Alias Validation (`validate_aliases`)
```python
def validate_aliases(
self,
chapter_text: str,
chapter_title: str,
characters: list[dict[str, Any]],
) -> tuple[list[dict[str, Any]], list[str]]
```
**Returns:**
- Cleaned character list with invalid aliases removed
- List of validation notes describing each correction
**Co-occurrence detection:**
1. Build regex from all known character names (longest-first matching)
2. For each scene, find which names appear in the text
3. If primary_name and alias both appear in the same scene as independent matches → invalid alias
### 5. Character Merge Algorithm
```python
def _merge_chapter_results(
self,
chapter_results: list[ChapterSpeakers],
book_title: str,
total_words: int,
) -> list[Speaker]
```
**Algorithm:**
1. **Collect**: Gather all characters from all chapters
2. **Dedup by Primary Name**: Case-insensitive matching
3. **Merge Aliases**: Combine alias lists from different chapters
4. **Merge Alias Evidence**: Combine evidence from all chapters
5. **Preserve Demographics**: Keep first non-unknown value for each field
6. **Merge Vocal Traits**: Union of all vocal traits across chapters
7. **Merge Communication Styles**: Keep most detailed description
8. **Context-Aware Dedup**: `_should_merge_by_context` prevents merging speakers with distinct proper names
9. **Sort by Frequency**: Characters appearing in more chapters first
### 6. Speaker Data Model (`Speaker` dataclass)
```python
@dataclass
class Speaker:
primary_name: str
aliases: list[str]
demographics: Demographics
vocal_traits: list[str]
communication_style: CommunicationStyle
is_narrator: bool
is_group: bool
description: str
sample_dialogue: str
chapters_appeared: list[int]
unidentified: bool # NEW: flagged if speaker not introduced
unidentified_reason: str # NEW: explanation of why
alias_evidence: list[str] # NEW: textual evidence for aliases
```
### 7. Temporary Storage Files
| File | Contents |
|------|----------|
| `.umbra/discovery/discovery_pass1.json` | Raw LLM discovery results, merged |
| `.umbra/discovery/discovery_pass2.json` | After alias validation |
| `.umbra/discovery/discovery_pass3.json` | After cross-chapter reconciliation |
| `.umbra/speakers.json` | Final output (overwritten on each run) |
### 8. Narrator Handling
The narrator is **not** a character — it's a dedicated voice assignment:
- Voice: `en-US-GuyNeural:-10` (soft-spoken male, deep radio speaker style)
- Auto-added by `DiscoveryResult.save()` if no `is_narrator=True` speaker exists
- Not included in character discovery
- Utterances with `speaker_name = "narrator"` use this voice
### 9. Voice Assignment (`scripts/assign_voices.py`)
```python
def assign_voices(
speakers: list[dict],
voice_library: list[str],
) -> dict[str, str]
```
**Voice Library:**
- 27 distinct Edge-TTS base voices (US, UK, AU, IE, IN, CA)
- Gender-balanced: 14 male, 13 female
- Pitch shifting for additional variety (+48 to -33 semitones)
**Assignment Rules:**
1. **Narrator**: Fixed voice `en-US-GuyNeural:-10`
2. **Characters**: Assign from pool based on gender and vocal traits
3. **Groups**: Assign distinct voices (e.g., "Management Group" → `en-US-AriaNeural`)
4. **Verification**: Each voice tested with the Rainbow Passage (phonetically balanced paragraph covering all English phonemes).
### 10. Quality Gates
**Character Discovery Quality:**
- All speaking characters must have aliases documented
- Aliases must have `alias_evidence` entries confirming validity
- Unidentified speakers must have `unidentified_reason` filled
- Vocal traits must be specific (not "normal" or "average")
- Communication style must include register and any shifts
- No two co-occurring characters can share the same primary_name or alias
**Alias Validation Quality:**
- No claimed alias pair may contain two distinct proper names
- No claimed alias pair may co-occur as independent speakers in the same scene
- All alias removals logged in validation_log
**Voice Assignment Quality:**
- No two characters sharing dialogue proximity can have identical voices
- Verification samples must be generated for all assigned voices
- Web UI allows human review and reassignment
## Database Updates
| Stage | Table | Fields Set |
|-------|-------|------------|
| Characters extracted | `speakers.json` | Full character profiles with alias_evidence |
| Voices assigned | `voice_assignments.json` | Character → voice mappings |
| Samples generated | `.umbra/voice_tests/` | WAV files for verification |
## Key Design Decisions
1. **3-pass pipeline over single-pass**: Discovery → Validation → Reconciliation catches alias errors that single-pass cannot
2. **Text co-occurrence validation**: Mechanical check that "Kainda" and "Captain Willamina Wake" can't be aliases if both appear in the same scene
3. **Unidentified speaker flagging**: Explicitly marks speakers that appear without introduction, rather than silently misattributing
4. **Alias evidence requirement**: LLM must cite textual evidence for each alias claim, enabling audit
5. **Context-aware dedup**: `_should_merge_by_context` prevents merging speakers with distinct proper names even after case-insensitive match
6. **Temporary JSON between passes**: Enables debugging, re-runs, and manual inspection of each pipeline stage
7. **Narrator is not a character**: Dedicated voice, never participates in character discovery
8. **Voice variety**: 59 unique voices from 27 base voices via pitch shifting
9. **Human review**: All voice assignments go through web UI approval
10. **Verification phrase**: Standard test sentence ensures voice quality

View File

@@ -0,0 +1,385 @@
# Spec: Two-Phase Segmentation (Pass 2)
## Overview
Pass 2 segments chapter text into utterances (narration and dialogue) and attributes speakers using a two-phase approach: a mechanical segmenter handles text splitting, and an LLM attributes speakers to all dialogue. This produces a 0.05% dialogue-as-narrator error rate (1/1,882 dialogue utterances) across all 35 chapters.
## Architecture
```
Raw chapter text
│
▼
┌─────────────────────────────────────────────────┐
│ PHASE 1: MECHANICAL SEGMENTER │
│ mapping/segmenter.py │
│ │
│ - Sentence splitting at .!? (not commas) │
│ - Dialogue detection via quotes ("'""') │
│ - Tag-based attribution (forward/backward) │
│ - Scene break (<<<>>>) handling │
│ - Chapter title detection │
└─────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────┐
│ PHASE 2: LLM SPEAKER ATTRIBUTION │
│ analysis/llm_attributor.py │
│ │
│ - Attributes ALL dialogue (overrides guesses) │
│ - Resolves pronouns using context │
│ - Uses character profiles from speakers.json │
│ - Batches of 40 utterances for efficiency │
│ - Normalizes names (aliases/first-word, │
│ narrator lowercase, pronoun guard) │
└─────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────┐
│ OUTPUT: Utterances table │
│ 4,339 utterances across 35 chapters │
│ 0.05% dialogue-as-narrator (1/1,882) │
└─────────────────────────────────────────────────┘
```
## Components
### 1. Mechanical Segmenter (`mapping/segmenter.py`)
#### Class: `Segmenter`
```python
class Segmenter:
def segment(
self,
text: str,
chapter_id: int,
character_names: list[str] | None = None,
) -> list[dict[str, Any]]
```
**Returns:**
```python
[{
"index": int,
"text": str,
"type": "narration" | "dialogue",
"character_name": str | None,
}, ...]
```
**Segmentation Pipeline:**
```
Raw text
│
├── 1. _split_segments(text) → list[str]
│ Character-by-character parser
│ Detects quote boundaries and sentence endings
│
├── 2. _build_utterances(segments) → list[dict]
│ Tags each segment as "dialogue" or "narration"
│ based on leading quote character
│
└── 3. _attribute_speakers(utterances, character_names) → list[dict]
Forward-tracking speaker attribution
Regex matching against character names
```
**Quote handling:** a quoted dialogue span is extracted whole and kept as one utterance even
with multiple sentences. `_restore_quote_wrap` re-wraps it with typographic double quotes and
only treats double-quote closes (`"`, `\u201d`) as outer closes — trailing **inner
single-quotes** (`’`) are preserved (e.g. `...named ‘Nanny.’`). Previously the inner text was
split back into sentences and each was re-quoted, which fragmented multi-sentence speech and
mangled inner single-quotes.
**Quote Detection:**
- Open: `"`, `'`, `\u201c` (left double), `\u2018` (left single)
- Close: `"`, `'`, `\u201d` (right double), `\u2019` (right single)
- Matching pairs: `"` → `"`, `\u201c` → `\u201d`, etc.
**Sentence Splitting:**
- Split at `.`, `!`, `?` followed by whitespace/quote/dash
- Minimum 2 words per segment (avoids fragments)
- Quote boundaries always create segment breaks
**Speaker Attribution Algorithm:**
```python
def _attribute_speakers(self, utterances, character_names) -> list[dict]
```
1. **Tag Detection**: Find `NAME said/asked/replied` patterns
2. **Forward Tracking**: When tag found, set `next_speaker = NAME`
3. **Backward Reference**: When no tag, look back for `said NAME` patterns
4. **Name Normalization**: Case-insensitive matching, alias resolution
**Speech Verbs (42 total):**
```
said, asked, replied, whispered, shouted, muttered, called,
exclaimed, answered, began, continued, added, finished, noted,
stated, remarked, yelled, screamed, laughed, sighed, says,
tells, told, orders, ordered, demands, demanded, insists,
insisted, begs, begged, pleads, pleaded, commands, commanded,
snapped, snarled, hissed, growled, barked, roared, croaked,
grunted, murmured, purred, intoned, declared, announced,
proclaimed, responds, repeats, mentions, offers
```
### 2. LLM Speaker Attribution (`analysis/llm_attributor.py`)
#### Class: `LLMAttributor`
```python
class LLMAttributor:
def __init__(self, config: LLMConfig, model: str | None = None) -> None
def attribute_speakers(
self,
utterances: list[dict[str, Any]],
chapter_title: str,
chapter_id: int,
) -> list[dict[str, Any]]
```
**Key Insight:** The LLM attributes **all** dialogue utterances. The mechanical attribution from the segmenter is preserved only as a `current_speaker` hint (Rule 8) — the LLM's verdict takes precedence so that ambiguous or incorrectly guessed lines can be corrected. This differs from the original design where only `speaker_name == "narrator"` dialogue was attributed.
**Attribution Flow:**
1. **Filter**: Collect all dialogue utterances (type == "dialogue")
2. **Batch**: Group into batches of 40 utterances
3. **Context**: Split into scenes (`<<<>>>`); include per-scene narration names + last-12 narration lines as context
4. **LLM Call**: Send batch with character profiles and scene context to LLM
5. **Parse Response**: Extract JSON array with index → speaker mapping
6. **Post-Process**: Strip speech verb suffixes ("says", "said", etc.); run `_normalize_speaker` (aliases + first-word-of-primary → primary; narrator/the-narrator → lowercase `narrator`; pronouns → `narrator` guard)
7. **Update**: Apply LLM verdicts to all dialogue utterances
8. **Fallback**: Re-attribute any dialogue still un-attributed via speech-tag regex on the 3 preceding utterances, then speaker carry-forward (scene breaks reset the carried speaker)
**LLM Prompt (inline):**
```
You are a dialogue attribution expert for audiobook production.
Given utterances that need speaker attribution, identify who is speaking each line.
## Character Profiles
{character_profiles}
## Chapter Title
{chapter_title}
## Scene Context
This chapter has {scene_count} scene(s) separated by scene breaks (<<<>>>).
You are attributing dialogue for: {scene_label}
{scene_speakers_context}
## Utterances to Attribute
{utterances_json}
## Rules
1. Each utterance is already correctly split — do NOT merge or split them
2. EVERY utterance MUST be attributed to a speaker. Return EXACTLY one entry per utterance index. Do not skip any.
3. ALL these utterances are dialogue (in quotes) — they are spoken by a CHARACTER, almost never "Narrator". Only use "Narrator" if the line is truly un-attributable.
3b. STRONG HINT: when a preceding narration line says a character IS telling/explaining/speaking to someone (e.g. "Nanny is telling the princess about what will happen."), the following quoted lines are that character's dialogue, until another character is indicated.
3c. A multi-line speech block from one character continues until dialogue tags, action, or the responder's lines indicate a change.
3d. A single quoted dialogue utterance may contain MULTIPLE sentences (e.g. one paragraph of speech) — every sentence within it belongs to the SAME speaker. Do not split the speaker across sentences within one utterance.
4. Use dialogue tags (X says/replies/asks) to identify speakers
5. Use context: who is present, who was speaking before, who is addressed
6. Resolve pronouns: "she says" refers to the most recently mentioned female character
7. If a character has aliases, use their EXACT full primary_name shown in the profiles (e.g. "Kainda Walters Foster Murphy", NOT "Kainda" or "Princess Kainda")
8. The "current_speaker" field shows who the mechanical segmenter guessed — use it as a hint but correct if wrong
9. In a conversation, speakers usually alternate. If one line is Character A, the next line is likely a different character (often the one addressed).
10. CRITICAL — only return the EXACT utterance indices provided in the input. Do NOT invent or hallucinate extra indices.
11. CRITICAL — every returned speaker must be the ACTUAL character speaking. Assigning the wrong speaker ruins the audiobook.
12. AI-1 is a specific AI character voice. Only attribute dialogue to AI-1 if the text explicitly indicates AI-1 is speaking. Do NOT default undetermined utterances to AI-1.
## Scene Attribution Rules (CRITICAL)
- Chapters can contain multiple scenes separated by scene breaks (<<<>>>)
- Each scene is an independent unit. Speakers identified in one scene may NOT appear in other scenes
- Within a scene, once a speaker is identified, subsequent dialogue by the same person continues until another speaker is identified
- "captain" in one scene may refer to a different person than "captain" in another scene — there can be multiple characters with the same title
- Only attribute a speaker to dialogue if there is evidence within THAT SCENE that they are speaking
- If a scene introduces new characters via narration, those characters are candidates for dialogue in that scene
- A speaker who appears in scene 1 may be entirely absent from scene 2 — do NOT carry speakers across scene boundaries
- When ambiguous ("he says", "she says"), resolve using the most recent speaker mention within the SAME SCENE
## Output Format
Return ONLY a JSON array of objects, one per utterance, each with:
- "index": the utterance index (number)
- "speaker": the correct speaker name (use primary_name from profiles, MUST be a character name, NOT "Narrator")
Return ONLY the JSON array, no other text.
```
**Performance:**
- ~5 seconds per batch of 40 utterances with `gemma-4-12b-it-qat` (~34 tok/s on the remote LM Studio server)
- The model must be loaded with `context_length: 8192` (default 262k-token context requests ~44.9 GB of memory and blocks loading)
- ~20-30 minutes for all 35 chapters; every dialogue utterance is processed (not just unattributed ones)
### 3. Scene Break Handling
**Scene Breaks (`<<<>>>`):**
- 51 scene breaks across the book
- Marked as special utterances: `{"speaker": "scene_break", "text": "<<<>>>"}`
- Within a scene: track which characters are present
- At a scene break: re-evaluate present characters
- Chapter context carries across scene breaks
**LLM Context:**
- Full chapter text with `<<<>>>` preserved
- LLM maintains context across scene breaks
- Can alter character presence between scenes
### 4. Sentence Splitting Rules
**Correct Behavior:**
- Split at sentence endings: `.` `!` `?` (narration only)
- Do NOT split at commas
- Narration: each sentence = separate utterance
- Quoted dialogue: the whole quoted span = ONE utterance, even when it contains multiple sentences (all sentences share the same speaker — Rule 3d)
**Examples:**
```
"I'm on a yacht." → ["I'm on a yacht."]
"That is not something..." → ["That is not something..."]
"Help! Open the latch." → ["Help! Open the latch."] # one utterance, one speaker
```
### 5. Speaker Name Resolution
Every LLM-returned speaker passes through `_normalize_speaker` before being stored:
**Aliases → Primary Names:**
- "Princess", "Kainda", "the princess" → "Kainda Walters Foster Murphy"
- "Captain", "Captain Wake" → "Willamina Wake"
- "Hunter", "Ship AI", "The Deal Hunter" → "Deal Hunter" (the ship's AI — one speaker)
**First-word-of-primary mapping:** the first word of each primary name maps to the full
primary name when it is not already an alias (e.g. "kainda" → "Kainda Walters Foster Murphy").
**Narrator lowercase:** "narrator" / "the narrator" → canonical lowercase `narrator` (the DB
and generation code check this exact case).
**Pronoun guard:** pronouns ("she", "he", "they", "her", etc.) → `narrator`; a bare pronoun
is an LLM failure, never a character.
**LLM Post-Processing:**
- Strip speech verb suffixes: "Kainda says" → "Kainda"
- Handle group speakers: "Management Group", "Guard Detail Members"
- Handle AI speakers: "Deal Hunter" (alias "Hunter"/"Ship AI"), "AI-1"
- Unknown names are kept as-is and surfaced on the orphaned-utterances page for review
## Output
Utterances are saved to the `utterances` table in `project.db`:
```sql
INSERT INTO utterances (
chapter_id,
utterance_index,
text,
type, -- "narration" | "dialogue"
speaker_name -- "narrator" | "Princess Kainda" | "scene_break" | etc.
) VALUES (?, ?, ?, ?, ?)
```
## Quality Metrics
| Metric | Value |
|--------|-------|
| Total utterances | 4,339 |
| Dialogue utterances | 1,882 |
| Narration utterances | 2,453 |
| Dialogue-as-narrator errors | 1 (0.05%) |
| Scene breaks | 51 |
| Chapters processed | 35 |
## Error Analysis
**Remaining 0.05% Error:**
- 1 dialogue utterance still attributed to narrator (ch35 "About the author" back matter — expected, no speaker is present)
- Edge cases where the LLM and mechanical segmenter both fail
- Acceptable for production use
**Common Failure Modes:**
1. Missing dialogue tags with no context
2. Multiple speakers in rapid succession
3. Internal thoughts (should be narrator, sometimes misattributed)
4. Indirect speech ("she said that...") vs direct speech
5. Bare pronoun speakers ("She") — caught by the pronoun guard, which maps them to `narrator`
## Validation
Run after any re-segment or re-attribute to confirm the quality gates hold:
```sql
-- Dialogue left as narrator (expect only the ch35 back-matter exception)
SELECT chapter_id, COUNT(*) FROM utterances
WHERE type = 'dialogue' AND lower(speaker_name) = 'narrator'
GROUP BY chapter_id;
-- Pronoun speakers (must be 0 — guard maps these to narrator)
SELECT COUNT(*) FROM utterances
WHERE type = 'dialogue' AND lower(speaker_name) IN
('i','me','my','you','your','he','him','his','she','her',
'they','them','their','we','us','our','it','its','one');
-- Narrator case (must be lowercase 'narrator', never 'Narrator')
SELECT COUNT(*) FROM utterances WHERE speaker_name = 'Narrator';
-- Unknown speakers (cross-check against speakers.json aliases/first-words;
-- leftovers appear on the /orphaned-utterances page)
SELECT DISTINCT speaker_name FROM utterances
WHERE type = 'dialogue' AND lower(speaker_name) != 'narrator';
```
Additional checks:
- **ch13 golden reference**: 44 utterances — 15 Kainda / 15 Nanny / 14 narrator (verified against the manuscript, e.g. "Fifty light years" is Kainda, "Don't help Princess Kainda" is Nanny)
- **Multi-sentence quotes**: spot-check that a paragraph of quoted speech is ONE dialogue utterance with a single speaker (Rule 3d)
- **Alternation**: dialogue should alternate between the characters present; a long run of one speaker with no tag is suspicious
## Scripts
### resegment_all.py
```bash
# Run on all chapters with LLM attribution
python scripts/resegment_all.py --model gemma-4-12b-it-qat
# Run on specific chapters
python scripts/resegment_all.py --chapters 5,6,7 --model gemma-4-12b-it-qat
# Mechanical-only (no LLM)
python scripts/resegment_all.py --mechanical-only
```
### reattribute_all.py
Re-runs LLM speaker attribution on existing utterances **without re-segmenting** (preserves utterance IDs and mechanical segmentation; only updates `speaker_name`):
```bash
# All chapters (--sleep paces requests to the remote server)
python scripts/reattribute_all.py --model gemma-4-12b-it-qat --sleep 0.2
# Specific chapters
python scripts/reattribute_all.py --chapters 5,6,7 --model gemma-4-12b-it-qat
```
**Note:** before running attribution, ensure the remote LM Studio server has the model loaded with `context_length: 8192`:
```bash
curl -X POST http://192.168.123.14:1234/api/v1/models/load \
-H 'Content-Type: application/json' \
-d '{"model": "gemma-4-12b-it-qat", "context_length": 8192, "flash_attention": true, "echo_load_config": true}'
```
## Key Design Decisions
1. **Two-phase over one-phase**: Mechanical segmenter handles text splitting reliably; LLM only does attribution
2. **LLM attributes all dialogue**: The LLM's verdict overrides the mechanical `current_speaker` guess so ambiguous lines get corrected (not just the unattributed ones)
3. **Quotes kept whole**: Multi-sentence quoted speech is one utterance; splitting it loses the single-speaker evidence
4. **Inner-quote preservation**: `_restore_quote_wrap` only recognizes double-quote closes so trailing inner single-quotes (`’`) survive re-wrapping
5. **Name normalization**: Aliases and first-word-of-primary map to canonical primary names; narrator stays lowercase; pronouns map to narrator (no fabricated speakers)
6. **Deterministic fallback**: Speech-tag regex + speaker carry-forward guarantee un-attributed dialogue still gets a speaker
7. **Batch processing**: 40 utterances per LLM call for efficiency
8. **Scene break preservation**: `<<<>>>` markers maintained as special utterances; they reset the carried speaker
9. **gemma-4-12b-it-qat model**: ~34 tok/s on the remote LM Studio server, must be loaded with `context_length: 8192` (~44.9 GB context otherwise blocks loading)

435
specs/15-multi-project.md Normal file
View File

@@ -0,0 +1,435 @@
# Spec: Multi-Project Support
## Overview
UmbraAudio currently operates on a single project stored in `.umbra/`. This spec adds first-class support for **multiple independent book projects**, each isolated with its own data, configuration, and outputs. Projects are identified by a slug (e.g., `deal-hunter`, `the-great-adventure`) and can be created, listed, switched, and removed via CLI and Web UI.
## Architecture
### Project Root Layout
```
UmbraAudio/ # Repository root
├── projects/ # Project registry (new)
│ ├── index.json # Master index of all projects
│ ├── deal-hunter/ # Project slug directory
│ │ ├── .umbra/ # Project data (was .umbra/)
│ │ ├── config.yaml # Project-local overrides
│ │ └── manifest.json # Project metadata
│ ├── the-great-adventure/
│ │ ├── .umbra/
│ │ ├── config.yaml
│ │ └── manifest.json
│ └── ...
├── .umbra/ # DEPRECATED: legacy single-project location
└── umbraaudio/ # Package code (unchanged)
```
### Project Identity
| Field | Type | Description |
|-------|------|-------------|
| `slug` | string | URL-safe identifier (kebab-case), unique, immutable |
| `title` | string | Human-readable book title |
| `author` | string | Author name |
| `created_at` | ISO8601 | Creation timestamp |
| `updated_at` | ISO8601 | Last activity timestamp |
| `status` | enum | `active`, `archived`, `error` |
| `source_format` | string | `docx`, `epub`, `pdf`, `txt`, `md` |
| `source_path` | string | Path to original manuscript (relative to project dir or absolute) |
| `chapters` | int | Number of parsed chapters |
| `speakers` | int | Number of discovered speakers |
| `pipeline_step` | string | Current pipeline stage (`parsed`, `analyzed`, `designed`, `mapped`, `tested`, `generated`, `exported`) |
### Project Index (`projects/index.json`)
```json
{
"version": 1,
"default_project": "deal-hunter",
"projects": [
{
"slug": "deal-hunter",
"title": "Deal Hunter",
"author": "B.D. Murphy",
"created_at": "2026-08-01T04:22:45",
"updated_at": "2026-08-06T10:30:00",
"status": "active",
"source_format": "docx",
"source_path": "uploads/DealHunterShip_4.0e_whole-to-audio.docx",
"chapters": 35,
"speakers": 61,
"pipeline_step": "generated"
},
{
"slug": "the-great-adventure",
"title": "The Great Adventure",
"author": "John Smith",
"created_at": "2026-08-06T14:00:00",
"updated_at": "2026-08-06T14:00:00",
"status": "active",
"source_format": "epub",
"source_path": "uploads/the-great-adventure.epub",
"chapters": 12,
"speakers": 8,
"pipeline_step": "parsed"
}
]
}
```
## Data Isolation
Each project is **completely independent**:
| Resource | Location | Shared? |
|----------|----------|---------|
| SQLite database (`project.db`) | `projects/{slug}/.umbra/project.db` | No |
| Speakers JSON | `projects/{slug}/.umbra/speakers.json` | No |
| Voice assignments | `projects/{slug}/.umbra/voice_assignments.json` | No |
| Voice references | `projects/{slug}/.umbra/voices/` | No |
| Chapter audio | `projects/{slug}/.umbra/chapter_audio/` | No |
| Voice tests | `projects/{slug}/.umbra/voice_tests/` | No |
| Pronunciation guide | `projects/{slug}/.umbra/pronunciation_guide/` | No |
| Exports | `projects/{slug}/export/` | No |
| Logs | `projects/{slug}/.umbra/logs/` | No |
| **Voice Registry DB** | `.umbra/voice_registry.db` | **Yes** (global) |
| **Phonetic names** | `.umbra/phonetic_names.json` | **Yes** (global) |
| **Qwen TTS models** | `models/` | **Yes** (global) |
| **LM Studio** | external | **Yes** (global) |
**Shared resources** are global infrastructure; **project resources** are fully isolated.
## Configuration
### Global Config (`config.yaml` at repo root)
```yaml
# Global settings (apply to all projects unless overridden)
tts:
engine: qwen
device: cuda
sample_rate: 24000
qwen_tts_url: "http://localhost:8000"
voicedesign_model: "/mnt/models/qwen3-tts/Qwen3-TTS-12Hz-1.7B-VoiceDesign"
base_model: "/mnt/models/qwen3-tts/Qwen3-TTS-12Hz-1.7B-Base"
llm:
provider: auto
lmstudio_base_url: "http://localhost:1234/v1"
lmstudio_model: "google/gemma-4-e4b"
temperature: 0.3
max_tokens: 4096
web:
host: "0.0.0.0"
port: 8420
auto_open: false
quality:
min_mos: 3.8
min_test_mos: 4.0
max_wer: 0.03
min_speaker_similarity: 0.80
audio:
target_rms_db: -20.0
peak_limit_db: -3.0
```
### Project-Local Config (`projects/{slug}/config.yaml`)
```yaml
# Optional overrides for this project only
project:
slug: "deal-hunter"
title: "Deal Hunter"
author: "B.D. Murphy"
tts:
engine: qwen
# device inherits from global
# qwen_tts_url inherits from global
llm:
# lmstudio_model can override per project
# temperature can override per project
quality:
min_mos: 4.0 # stricter for this project
audio:
target_rms_db: -18.0 # louder master
```
**Merge precedence**: project config → global config → defaults.
## CLI Interface
### Project Management Commands
```bash
# List all projects
python -m umbraaudio.cli project list
# Show current project
python -m umbraaudio.cli project current
# Create new project from manuscript
python -m umbraaudio.cli project create \
--title "The Great Adventure" \
--author "John Smith" \
--source /path/to/book.epub \
--slug the-great-adventure
# Switch active project
python -m umbraaudio.cli project switch the-great-adventure
# Show project details
python -m umbraaudio.cli project info the-great-adventure
# Archive project (keeps data, marks inactive)
python -m umbraaudio.cli project archive the-great-adventure
# Restore archived project
python -m umbraaudio.cli project restore the-great-adventure
# Delete project (removes all data - requires confirmation)
python -m umbraaudio.cli project delete the-great-adventure --confirm
# Run pipeline commands on specific project
python -m umbraaudio.cli analyze --project the-great-adventure
python -m umbraaudio.cli generate --project the-great-adventure
python -m umbraaudio.cli pipeline --project the-great-adventure -c all
```
### Default Project Behavior
- If `--project` not specified, uses `default_project` from `projects/index.json`
- If no default set, uses first project in index
- Legacy single-project mode: if `projects/` doesn't exist but `.umbra/` does, auto-migrate on first run
## Web UI
### Project Selector
- Top-left dropdown in navbar showing current project
- Clicking opens modal with project cards (title, author, status, last updated)
- "New Project" button in modal → upload form
- "Switch" action reloads page with new project context
- "Create New" navigates to `/projects/new`
### Project-Aware Endpoints
All existing endpoints become project-scoped:
| Old Path | New Path | Notes |
|----------|----------|-------|
| `/` | `/projects/{slug}/` | Dashboard |
| `/voices` | `/projects/{slug}/voices` | Voice review |
| `/speakers` | `/projects/{slug}/speakers` | Speaker review |
| `/chapters-audio` | `/projects/{slug}/chapters-audio` | Audio review |
| `/proper-names` | `/projects/{slug}/proper-names` | Pronunciation |
| `/api/upload-manuscript` | `/projects/{slug}/api/upload-manuscript` | Upload |
| `/api/tts/health` | `/api/tts/health` | Global (unchanged) |
| `/api/models/status` | `/api/models/status` | Global (unchanged) |
### Project Creation Flow (Web)
1. User clicks "New Project" → `/projects/new`
2. Form: title, author, file upload, optional slug (auto-generated from title)
3. POST `/projects` → creates project directory, saves manuscript, returns project slug
4. Redirect to `/projects/{slug}/` (dashboard)
5. Background parsing starts automatically
### API Endpoints
```python
# Project CRUD
GET /api/projects # List all projects
POST /api/projects # Create new project
GET /api/projects/{slug} # Get project info
PUT /api/projects/{slug} # Update project metadata
DELETE /api/projects/{slug} # Delete project (with ?confirm=true)
POST /api/projects/{slug}/archive # Archive project
POST /api/projects/{slug}/restore # Restore project
POST /api/projects/{slug}/set-default # Set as default
# Project-scoped existing endpoints (all prefixed with /projects/{slug})
GET /projects/{slug}/api/voices
POST /projects/{slug}/api/voices/regenerate
GET /projects/{slug}/api/chapters
# ... etc
```
## Database Changes
### Project Registry Table (Global)
```sql
-- In a new global SQLite DB: .umbra/projects.db
CREATE TABLE IF NOT EXISTS projects (
slug TEXT PRIMARY KEY,
title TEXT NOT NULL,
author TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
status TEXT NOT NULL DEFAULT 'active', -- active, archived, error
source_format TEXT,
source_path TEXT,
chapters INTEGER DEFAULT 0,
speakers INTEGER DEFAULT 0,
pipeline_step TEXT DEFAULT 'initialized',
config_json TEXT, -- project-local config overrides
manifest_json TEXT -- extended metadata
);
CREATE INDEX IF NOT EXISTS idx_projects_status ON projects(status);
CREATE INDEX IF NOT EXISTS idx_projects_updated ON projects(updated_at);
```
### Per-Project Database (Unchanged)
Each project's `project.db` retains the exact same schema as today:
- `project` table (single row per project)
- `chapters`, `characters`, `utterances`, `generated_audio`, etc.
- No cross-project foreign keys
### Migration from Legacy Single-Project
```python
# On first run if .umbra/ exists but projects/ doesn't:
1. Create projects/ directory
2. Generate slug from project name in .umbra/project.db
3. Move .umbra/ → projects/{slug}/.umbra/
4. Create projects/{slug}/config.yaml (empty/inherit all)
5. Create projects/{slug}/manifest.json
6. Create projects/index.json with the migrated project as default
7. Update .env UMBRA_DATA_DIR to point to projects/{slug}/.umbra (or use runtime resolution)
```
## Config Resolution
```python
def load_config(project_slug: str | None = None) -> AppConfig:
# 1. Load global config.yaml
global_cfg = load_yaml("config.yaml")
# 2. Resolve project slug
if project_slug is None:
project_slug = get_default_project_slug() # from index.json
# 3. Load project config
project_cfg = load_yaml(f"projects/{project_slug}/config.yaml") if exists else {}
# 4. Merge: project overrides global
merged = deep_merge(global_cfg, project_cfg)
merged["project_slug"] = project_slug
merged["data_dir"] = Path(f"projects/{project_slug}/.umbra")
merged["export_dir"] = Path(f"projects/{project_slug}/export")
return AppConfig(**merged)
```
## Pipeline Execution
### Per-Project Pipeline State
Each project tracks its own pipeline status in `projects/{slug}/.umbra/pipeline_status.json`:
```json
{
"step": "generation",
"status": "running",
"detail": "Chapter 12 of 35",
"progress": 34,
"started_at": "2026-08-06T10:00:00",
"updated_at": "2026-08-06T10:15:00"
}
```
### Concurrent Execution
- Multiple projects can run pipeline steps **simultaneously** if resources allow
- Qwen TTS server is shared; requests from different projects queued
- LM Studio is shared; requests from different projects queued
- Web UI shows status for active project only; other projects' status visible in project list
## Voice Registry Integration
The global voice registry (`voice_registry.db`) tracks voices **across all projects**:
```sql
-- Extended schema
ALTER TABLE voice_registry ADD COLUMN source_projects TEXT; -- JSON array of project slugs
ALTER TABLE voice_registry ADD COLUMN last_used_project TEXT;
ALTER TABLE voice_registry ADD COLUMN last_used_at TEXT;
```
- A voice created in Project A can be reused in Project B
- Web UI "Voice Registry" page shows all voices with project badges
- When generating voice reference, if voice exists in registry and matches description, reuse it
## Testing Strategy
### Unit Tests
```python
# tests/test_multi_project.py
def test_create_project_creates_directory_structure()
def test_project_index_persists_and_loads()
def test_config_merge_precedence()
def test_load_config_resolves_data_dir_per_project()
def test_migrate_legacy_single_project()
def test_project_slug_uniqueness()
def test_archive_restore_cycle()
def test_delete_project_removes_all_data()
```
### Integration Tests
```python
# tests/test_integration_multi_project.py
def test_two_projects_independent_pipeline()
def test_shared_voice_registry_reuse()
def test_web_ui_project_switching()
def test_concurrent_generation_different_projects()
```
## Migration Checklist
- [ ] Create `projects/` directory structure
- [ ] Add `ProjectManager` class (`umbraaudio/project_manager.py`)
- [ ] Update `load_config()` to accept project_slug
- [ ] Add `project` command group to CLI
- [ ] Update all CLI commands to accept `--project` flag
- [ ] Update web routes to be project-scoped
- [ ] Add project selector to web UI navbar
- [ ] Create `/projects/new` page
- [ ] Create `/api/projects` endpoints
- [ ] Add global `projects.db` for registry
- [ ] Implement legacy migration on startup
- [ ] Update `PipelineManager` to use project-scoped data_dir
- [ ] Update `GenerationManager` to use project-scoped data_dir
- [ ] Update all scripts to accept `--project` argument
- [ ] Add tests for multi-project isolation
- [ ] Update documentation
## Backward Compatibility
| Scenario | Behavior |
|----------|----------|
| Legacy `.umbra/` exists, no `projects/` | Auto-migrate on first run |
| `UMBRA_DATA_DIR` env var set | Used as-is (legacy mode) |
| `--project` not specified | Uses default from index.json |
| Scripts without `--project` | Use default project |
| Web UI accessed at `/` | Redirects to `/projects/{default}/` |
## Future Considerations
1. **Project Templates**: Pre-defined voice configs, quality settings per genre
2. **Project Cloning**: Duplicate project structure for series books
3. **Shared Character Library**: Export/import characters across projects
4. **Multi-Book Series**: Parent project with child projects (volumes)
5. **Remote Project Sync**: Push/pull project data to cloud storage
6. **Project Permissions**: Read-only, collaborator, admin roles for web UI

29
specs/NOTES_READ.txt Normal file
View File

@@ -0,0 +1,29 @@
During utterance generation add modifiers for the local chapter/scene needs. Argumentative, soft, pleading, etc.
Question tone modifier.
Generate verbage to identify new speaker (first time) as narrator bit, or other speaker saying who they are.
Gene says, "Hello Princess."
or Robot says, "Welcome, Gene."
"Hello." - in Gene's voice.
"How are you today Princess?" - question attribute, Gene's voice.
"Good morning. I'm doing well." - Princess voice.
Minimize narrator speaking "X said, X says,"
***
Finding characters is simply finding names, nicknames, alias, references (the AI).
Speakers is someone with a quoted string.
Character can say a name and the speaker attribution should be normal.
"Hello Princess."
Kainda replies, "Good Morning"
Or
"Hello Princess."
"Good Morning" - spoken by Kainda's voice.
Need a prompt that will go through and check / find these and make sure it is the correct speaker. After / followup to correct, using context.
***

29
specs/NOTES_READ.txt.txt Normal file
View File

@@ -0,0 +1,29 @@
During utterance generation add modifiers for the local chapter/scene needs. Argumentative, soft, pleading, etc.
Question tone modifier.
Generate verbage to identify new speaker (first time) as narrator bit, or other speaker saying who they are.
Gene says, "Hello Princess."
or Robot says, "Welcome, Gene."
"Hello." - in Gene's voice.
"How are you today Princess?" - question attribute, Gene's voice.
"Good morning. I'm doing well." - Princess voice.
Minimize narrator speaking "X said, X says,"
***
Finding characters is simply finding names, nicknames, alias, references (the AI).
Speakers is someone with a quoted string.
Character can say a name and the speaker attribution should be normal.
"Hello Princess."
Kainda replies, "Good Morning"
Or
"Hello Princess."
"Good Morning" - spoken by Kainda's voice.
Need a prompt that will go through and check / find these and make sure it is the correct speaker. After / followup to correct, using context.
***

88
specs/Parsing_Rules.txt Normal file
View File

@@ -0,0 +1,88 @@
Add user management and login to system. plan for future sales. Users will have projects each is seperate.
Rules
The tags inform the system on how to process parts of the book.
All info before [book start] can be ignored.
[Book start] - TItle page of book. It will have Title, optional subtitle on a sperate lines with no punctionation useed. The system can use line breaks understand the utterances. The bottom of the page will have the author on one line. The next optional line will be Publisher on one line.
[copyright]
The copyright page will have a mix of content. The needs to be segmented based on line breaks. THere are lines and paragraphs that use sentance punctuation which must be used.
[author note] should be parsed with punctuations, ".?!", to segment utterances.
[TOC] - table of contents is not included in the audio book.
[prologue] - optional and will follow punctuation, ".?!", to segment utterance.
[chapter] - Every chapter will follow a patter. THere is a chapter title with no punctuation as the first line after [chapter]. The paragraphs will use punctuation to segment sentences for utterance. There can be scene breaks designated by "<<<>>>". The speakers will change with a scene, but utterances will use sentance punctuation.
[epilogue] - Will be treated as a chapter, after the main story.
[Back Matter] - Back matter will be a mixture of utterances and will need to be processed similar to the copyright page.
[req review] is focused on requeting a review and the utterance of a webpage URL isn't needed.
[about author] - will use best practices for utterances.
Parse runs.
# Scan to identify sections, chapters and scenes - based on tags. The book is too large for the AI, so it must parse by chapters. The tags can inform this parse and make it a simple program.
# AI Discover characters, speakers, aliases, and proper names of people and places. Speakers are stored in book speakers.json
# AI Parse, for each speaker to find their voice, any clues in the book about how they talk. These will be used to design unique voices for each speaker. Each speaker attributes are stored in the json file.
# The online system will show a page for speakers - reference already. This will allow the user to adjust or approve voices of each speaker. The results will be stored in the speaker.json.
# The book will be parced to find proper names, people, locations, etc. These will be shown on a web page where the user can listen to the generated name and alter the pronunciation to be correct. A corrected name will use that pronunciation by every speaker and the name reference in utterances will add the pronunciation.
# The book will be parced section by section with store meta data captured, emotional tone, speakers in this section - tension? THe output will be sentences / utterances with speaker, *** Narrator?
# Utterances will be generated and shown on the web for verification using the parcing for tone, emotion, etc.
# When the user has approved all the utterances in a chapter the chapter will be built for the final output and available for listening.
# The user can trigger the re-generation of voice sameples, name pronunciation and utterances after changes.
# Chapter pages with utterances will allow adding, editing, deleting utterances from the chapter. An added utterance can be moved into the flow at any point. This allows last minute fixes vs updating the manuscript and processing all the steps again.
# Utterances will be a sentance with an identified speaker, using any emotional modifiers, to say the words.
He/She said.
# Identify each person by their voice. Don't say bob says as the narrator then switch to bob's voice to speak. each voice is unique so the listener can tell from the voice who is speaking.
# Scan the book to identify the first time each speaker talks. Within two sentences someone else must say their name. The narrator will do this if no one else does. Every instanance that is identified will be flagged and shown for adjustments. A page for (speaker adjustments) will show five lines before and after the new speaker's first line.
4. Technical Constraints & Logic
Punctuation as Prosody
The system must interpret typography as direct instruction for the TTS engine:
Ellipses (...): Triggers trailing, thoughtful pauses.
Em-dashes (—): Triggers sudden shifts in tone or abrupt interruptions.
ALL-CAPS: Triggers increased volume and intensity markers.
Question Marks: Triggers terminal pitch rising.
Voice Consistency Invariants
Narrator: Fixed at a low-emphasis, calm cadence to maintain baseline stability.
Cast Distinctiveness: Voices must be audibly distinguishable via Qwen3-TTS "VoiceDesign" (instruct mode).
VRAM Management: The orchestrator.py must manage the lifecycle of LM Studio and Qwen3-TTS to prevent OOM errors, as both models are VRAM-intensive.
A. Phase I: Character & Voice DiscoveryPurpose: Extract entities and define their vocal "DNA" for Qwen3-TTS.File: CHARACTER_DISCOVERY_PROMPT.mdPlaintextAnalyze the provided manuscript text. Identify all speaking characters and the narrator.
For each character, generate a Qwen3-TTS 'instruct' string that captures their unique
vocal identity.
Return ONLY a JSON object:
{
"CharacterName": {
"instruct": "A [pitch] and [texture] [identity] who speaks with a [pacing] rhythm, [emotion] personality, and [accent] accent. Distinguishing mannerisms: [details].",
"evolution_markers": [
{
"trigger": "e.g., Chapter 7",
"instruction_update": "New instruction string reflecting changed state."
}
]
}
}
B. Phase II: Speaker AttributionPurpose: Resolve unattributed dialogue using contextual awareness.Prompt:PlaintextYou are an expert literary analyst. Review the provided segment of dialogue.
Using the character profiles provided in speakers.json, attribute the
unlabeled dialogue to the correct character.
Focus on:
1. Resolving pronouns based on preceding context.
2. Distinguishing between the narrator and active characters.
3. Maintaining consistency with established character traits.
Output the attribution as:
{"utterance_id": "ID", "character": "CharacterName", "confidence": 0.0-1.0}
3. Pipeline StagesPhaseDescriptionQuality Gate1a: ParseSegment text into chapters/scenes; strip non-audio elements.Tag verification1b: DiscoveryLLM extracts characters; generates voice_profiles.json.Human review of voices2: AttributionMechanical splitting + LLM-based dialogue attribution.0.05% error threshold3: GenerationQwen3-TTS synthesis using instruct and clone modes.Audio test pass4: ReviewWeb UI validation of utterances and character voices.Human sign-off

48
specs/README.md Normal file
View File

@@ -0,0 +1,48 @@
# UmbraAudio Specifications
Index of all specification documents for the UmbraAudio audiobook generation system.
## Specifications
| File | Topic | Description |
|------|-------|-------------|
| [00-project-overview.md](00-project-overview.md) | Architecture | Pipeline overview, tech stack, data layout |
| [01-parsing.md](01-parsing.md) | Pass 1a | EPUB, PDF, TXT, MD manuscript parsing |
| [02-analysis.md](02-analysis.md) | Pass 1b | BookNLP, character profiling, pronunciation |
| [03-voice-design.md](03-voice-design.md) | Pass 2 | Per-character voice reference generation |
| [04-mapping.md](04-mapping.md) | Pass 3 | Text segmentation, speaker attribution, manifests |
| [05-generation.md](05-generation.md) | Pass 5 | Audio generation, caching, retry, CUDA management |
| [06-normalization-watermarking.md](06-normalization-watermarking.md) | Post-gen | Audio normalization, ACX compliance, Perth watermark |
| [07-assembly-export.md](07-assembly-export.md) | Export | M4B, MP3, WAV export via FFmpeg |
| [08-database.md](08-database.md) | Database | SQLite schema, entity relationships, CRUD API |
| [09-web-review-ui.md](09-web-review-ui.md) | Web UI | FastAPI + HTMX review interface |
| [10-cli-orchestrator.md](10-cli-orchestrator.md) | CLI | Click commands, full pipeline orchestration |
| [11-configuration-dependencies.md](11-configuration-dependencies.md) | Config | Pydantic settings, dependencies, build system |
| [12-quality.md](12-quality.md) | Quality | MOS, WER, consistency, ACX compliance |
## Quick Reference
### Pipeline Passes
```
1a: Parse → chapters table
1b: Analyze → characters, pronunciation_guide
2: Voices → voice_ref WAVs
3: Map → manifests/*.json
4: Test → sample audio (optional)
5: Generate → audio/*.wav, audio_normalized/*.wav
Export → export/*.m4b, *.mp3
```
### Key Entry Points
| Component | File | Class/Function |
|-----------|------|----------------|
| CLI | `cli.py` | `main` Click group |
| Pipeline | `orchestrator.py` | `run_pipeline()` |
| TTS | `voices/chatterbox.py` | `ChatterboxEngine` |
| Segmenter | `mapping/segmenter.py` | `Segmenter` |
| Generator | `generation/chapter.py` | `ChapterGenerator` |
| Normalizer | `assembly/normalize.py` | `AudioNormalizer` |
| Web UI | `web/__init__.py` | `create_app()` |
| Database | `database.py` | `Database` |

7
start_server.sh Executable file
View File

@@ -0,0 +1,7 @@
#!/bin/bash
# Qwen3-TTS 1.7B-Base Gradio server launcher (all paths on the 1TB drive)
export HF_HOME=/Volumes/1TB_APL_Storage/UmbraAudio/.hf_home
export PATH=/Volumes/1TB_APL_Storage/UmbraAudio/.venv/bin:$PATH
exec qwen-tts-demo /Volumes/1TB_APL_Storage/UmbraAudio/models/Qwen3-TTS-12Hz-1.7B-Base \
--device mps --no-flash-attn --dtype bfloat16 \
--ip 0.0.0.0 --port 8000

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

33
test_voice_clone.py Normal file
View File

@@ -0,0 +1,33 @@
import os
import torch
import soundfile as sf
from qwen_tts import Qwen3TTSModel
model_path = "/Volumes/1TB_APL_Storage/UmbraAudio/models/Qwen3-TTS-12Hz-1.7B-Base"
out_dir = "/Volumes/1TB_APL_Storage/UmbraAudio/output"
os.makedirs(out_dir, exist_ok=True)
device = os.environ.get("DEVICE", "mps")
dtype = torch.bfloat16 if device != "cpu" else torch.float32
print(f"Loading model on {device} (dtype={dtype}) ...")
model = Qwen3TTSModel.from_pretrained(
model_path,
device_map=device,
dtype=dtype,
attn_implementation="eager",
)
ref_audio = "https://qianwen-res.oss-cn-beijing.aliyuncs.com/Qwen3-TTS-Repo/clone.wav"
ref_text = "Okay. Yeah. I resent you. I love you. I respect you. But you know what? You blew it! And thanks to you."
print("Generating voice clone ...")
wavs, sr = model.generate_voice_clone(
text="This is a local test of Qwen three TTS running on my Mac mini. The audio should sound natural and clear.",
language="English",
ref_audio=ref_audio,
ref_text=ref_text,
)
out = os.path.join(out_dir, "test_voice_clone.wav")
sf.write(out, wavs[0], sr)
print(f"DONE: wrote {out} ({sr} Hz, {len(wavs[0])/sr:.2f}s)")

75
tests/conftest.py Normal file
View File

@@ -0,0 +1,75 @@
from __future__ import annotations
import tempfile
from pathlib import Path
import pytest
from umbraaudio.analysis import CharacterProfile
from umbraaudio.assembly import AudioStitcher
@pytest.fixture
def temp_dir():
with tempfile.TemporaryDirectory() as d:
yield Path(d)
@pytest.fixture
def sample_wavs(temp_dir: Path) -> list[Path]:
import numpy as np
import soundfile as sf
paths = []
sr = 24000
for i in range(3):
path = temp_dir / f"test_{i}.wav"
audio = np.random.randn(sr).astype(np.float32) * 0.1
sf.write(str(path), audio, sr)
paths.append(path)
return paths
@pytest.fixture
def stitcher() -> AudioStitcher:
return AudioStitcher(
silence_between_utterances_ms=100,
silence_between_paragraphs_ms=200,
silence_between_chapters_ms=300,
sample_rate=24000,
)
@pytest.fixture
def narrator_profile() -> CharacterProfile:
return CharacterProfile(
name="Narrator",
gender="male",
age_range="adult",
is_narrator=True,
)
@pytest.fixture
def character_profile() -> CharacterProfile:
return CharacterProfile(
name="John Smith",
aliases=["John", "Smithy"],
gender="male",
age_range="young_adult",
pitch_register="low",
energy="measured",
timbre="warm",
texture="smooth",
formality="formal",
emotional_baseline="neutral_calm",
pace_baseline="slow",
distinguishing_trait="deep resonant voice",
accent_marker="british_rp",
voice_description="A deep, warm voice with a British RP accent",
)
@pytest.fixture
def registry_db_path(temp_dir: Path) -> Path:
return temp_dir / "voice_registry.db"

93
tests/test_assembly.py Normal file
View File

@@ -0,0 +1,93 @@
from __future__ import annotations
from pathlib import Path
from umbraaudio.assembly import AudioStitcher
from umbraaudio.assembly.metadata import (
BookMetadata,
ChapterMeta,
build_chapter_metadata,
read_metadata_json,
write_ffmpeg_metadata,
write_metadata_json,
)
def test_stitch_utterances_output_exists(
temp_dir: Path, sample_wavs: list[Path], stitcher: AudioStitcher
):
output = temp_dir / "stitched.wav"
result = stitcher.stitch_utterances(sample_wavs, output)
assert result == output
assert output.exists()
assert output.stat().st_size > 0
def test_stitch_utterances_empty_list(stitcher: AudioStitcher, temp_dir: Path):
output = temp_dir / "empty.wav"
result = stitcher.stitch_utterances([], output)
assert result == output
assert output.exists()
def test_stitch_chapters_output_exists(
temp_dir: Path, sample_wavs: list[Path], stitcher: AudioStitcher
):
output = temp_dir / "chapters.wav"
result = stitcher.stitch_chapters(sample_wavs, output)
assert result == output
assert output.exists()
def test_make_silence(stitcher: AudioStitcher):
silence = stitcher._make_silence(100)
assert len(silence) == int(24000 * 0.1)
assert (silence == 0).all()
def test_build_chapter_metadata():
titles = ["Ch 1", "Ch 2", "Ch 3"]
durations = [10.0, 15.0, 20.0]
meta = build_chapter_metadata(titles, durations, book_title="Test Book", author="Author")
assert meta.title == "Test Book"
assert meta.author == "Author"
assert len(meta.chapters) == 3
assert meta.chapters[0].title == "Ch 1"
assert meta.chapters[0].start_time_sec == 0.0
assert meta.chapters[0].end_time_sec == 10.0
assert meta.chapters[1].start_time_sec == 10.0
assert meta.chapters[1].end_time_sec == 25.0
def test_metadata_json_roundtrip(temp_dir: Path):
meta = BookMetadata(
title="Book",
author="Author",
narrator="Narrator",
chapters=[ChapterMeta(title="Ch 1", start_time_sec=0.0, end_time_sec=10.0, index=0)],
)
path = temp_dir / "meta.json"
write_metadata_json(meta, path)
assert path.exists()
loaded = read_metadata_json(path)
assert loaded.title == "Book"
assert loaded.author == "Author"
assert len(loaded.chapters) == 1
assert loaded.chapters[0].title == "Ch 1"
def test_write_ffmpeg_metadata(temp_dir: Path):
meta = BookMetadata(
title="Test Book",
author="Author",
chapters=[ChapterMeta(title="Ch 1", start_time_sec=0.0, end_time_sec=10.0, index=0)],
)
path = temp_dir / "ffmetadata.txt"
write_ffmpeg_metadata(meta, path)
assert path.exists()
content = path.read_text()
assert "title=Test Book" in content
assert "artist=Author" in content
assert "[CHAPTER]" in content
assert "START=0" in content
assert "END=10000" in content

View File

@@ -0,0 +1,194 @@
from __future__ import annotations
from umbraaudio.analysis.attribution_qa import (
check_attribution,
fix_high_confidence,
)
ALIAS_MAP = {
"hunter": "Deal Hunter",
"deal hunter": "Deal Hunter",
"kainda": "Kainda Walters Foster Murphy",
"kainda walters foster murphy": "Kainda Walters Foster Murphy",
"princess kainda": "Kainda Walters Foster Murphy",
"kabir": "Kabir",
"narrator": "Narrator",
}
def _utt(idx: int, text: str, speaker: str, type_: str = "narration") -> dict:
return {
"utterance_index": idx,
"text": text,
"type": type_,
"speaker_name": speaker,
}
class TestSpeechTagMismatch:
def test_correct_attribution_no_issue(self) -> None:
utts = [
_utt(0, "Hunter says,", "narrator"),
_utt(1, "We are ready to jump.", "Deal Hunter", "dialogue"),
]
result = check_attribution(utts, 6, ALIAS_MAP)
assert result.has("speech_tag_mismatch") is False
def test_wrong_attribution_flagged(self) -> None:
utts = [
_utt(0, "Hunter says,", "narrator"),
_utt(1, "We are ready to jump.", "Kabir", "dialogue"),
]
result = check_attribution(utts, 6, ALIAS_MAP)
issues = result.by_type("speech_tag_mismatch")
assert len(issues) == 1
assert issues[0].expected == "Deal Hunter"
assert issues[0].utterance_index == 1
assert issues[0].confidence >= 0.9
def test_alias_resolution(self) -> None:
utts = [
_utt(0, "Princess Kainda asks,", "narrator"),
_utt(1, "Where are we?", "Deal Hunter", "dialogue"),
]
result = check_attribution(utts, 6, ALIAS_MAP)
issues = result.by_type("speech_tag_mismatch")
assert issues[0].expected == "Kainda Walters Foster Murphy"
def test_tag_not_carried_across_unrelated_narration(self) -> None:
utts = [
_utt(0, "Hunter says,", "narrator"),
_utt(1, "The ship shudders.", "narrator"),
_utt(2, "We are ready to jump.", "Kabir", "dialogue"),
]
result = check_attribution(utts, 6, ALIAS_MAP)
assert result.has("speech_tag_mismatch") is False
def test_tag_not_carried_across_scene_break(self) -> None:
utts = [
_utt(0, "Hunter says,", "narrator"),
_utt(1, "<<<>>>", "scene_break"),
_utt(2, "We are ready to jump.", "Kabir", "dialogue"),
]
result = check_attribution(utts, 6, ALIAS_MAP)
assert result.has("speech_tag_mismatch") is False
class TestNarratorDialogue:
def test_dialogue_left_as_narrator_flagged(self) -> None:
utts = [
_utt(0, "She says,", "narrator"),
_utt(1, "Hello.", "narrator", "dialogue"),
]
result = check_attribution(utts, 6, ALIAS_MAP)
issues = result.by_type("narrator_dialogue")
assert len(issues) == 1
assert issues[0].utterance_index == 1
class TestRoleTag:
def test_role_tag_assigned_to_named_character_flagged(self) -> None:
utts = [
_utt(0, "The lead guard replies,", "narrator"),
_utt(1, "We have strict orders to keep you away.", "Kabir", "dialogue"),
]
result = check_attribution(utts, 6, ALIAS_MAP)
issues = result.by_type("role_tag_guessed")
assert len(issues) == 1
assert issues[0].utterance_index == 1
def test_role_tag_assigned_to_role_speaker_ok(self) -> None:
utts = [
_utt(0, "The lead guard replies,", "narrator"),
_utt(1, "We have strict orders to keep you away.", "Chief of the Guard", "dialogue"),
]
result = check_attribution(utts, 6, ALIAS_MAP)
assert result.has("role_tag_guessed") is False
def test_pronoun_tag_skipped(self) -> None:
utts = [
_utt(0, "He replies,", "narrator"),
_utt(1, "We have strict orders to keep you away.", "Chief of the Guard", "dialogue"),
]
result = check_attribution(utts, 6, ALIAS_MAP)
assert not result.issues
def test_tag_not_applied_to_shared_tag_block(self) -> None:
utts = [
_utt(0, "Kainda asks,", "narrator"),
_utt(
1, "Where is the ship that brought me?", "Kainda Walters Foster Murphy", "dialogue"
),
_utt(
2,
"The ship jumped away as soon as you were on your way.",
"Willamina Wake",
"dialogue",
),
]
result = check_attribution(utts, 6, ALIAS_MAP)
assert not result.issues
def test_voice_tag_assigned_to_ship_flagged(self) -> None:
utts = [
_utt(0, "The voice calls again,", "narrator"),
_utt(1, "Help me, please.", "Deal Hunter", "dialogue"),
]
result = check_attribution(utts, 6, ALIAS_MAP)
issues = result.by_type("role_tag_guessed")
assert len(issues) == 1
def test_unresolved_title_word_overlap_ok(self) -> None:
utts = [
_utt(0, "Wakam says,", "narrator"),
_utt(1, "You work a tough deal.", "Trader Wakam", "dialogue"),
]
result = check_attribution(utts, 6, ALIAS_MAP)
assert not result.issues
def test_unresolved_title_no_overlap_flagged_low_conf(self) -> None:
utts = [
_utt(0, "Wakam says,", "narrator"),
_utt(1, "You work a tough deal.", "Captain Weston", "dialogue"),
]
result = check_attribution(utts, 6, ALIAS_MAP)
issues = result.by_type("speech_tag_mismatch")
assert len(issues) == 1
assert issues[0].confidence < 0.9
def test_conjunction_not_absorbed_into_role(self) -> None:
utts = [
_utt(0, "A supervisor sees the robot and tells it to stop.", "narrator"),
_utt(1, "Why are you here?", "Human Staff Person/Messenger", "dialogue"),
]
result = check_attribution(utts, 6, ALIAS_MAP)
assert not result.issues
def test_plain_narration_not_flagged(self) -> None:
utts = [
_utt(0, "The ship powers down its engines.", "narrator"),
_utt(1, "Help me, please.", "Deal Hunter", "dialogue"),
]
result = check_attribution(utts, 6, ALIAS_MAP)
assert not result.issues
class TestFixHighConfidence:
def test_fixes_exact_name_mismatch_only(self) -> None:
utts = [
_utt(0, "Hunter says,", "narrator"),
_utt(1, "We are ready to jump.", "Kabir", "dialogue"),
_utt(2, "The ship shudders.", "narrator"),
_utt(3, "Can you take me on a tour?", "Kabir", "dialogue"),
]
fixed = fix_high_confidence(utts, ALIAS_MAP)
assert fixed[1]["speaker_name"] == "Deal Hunter"
assert fixed[3]["speaker_name"] == "Kabir"
def test_already_correct_unchanged(self) -> None:
utts = [
_utt(0, "Hunter says,", "narrator"),
_utt(1, "We are ready to jump.", "Deal Hunter", "dialogue"),
]
fixed = fix_high_confidence(utts, ALIAS_MAP)
assert fixed[1]["speaker_name"] == "Deal Hunter"

View File

@@ -0,0 +1,113 @@
from __future__ import annotations
from umbraaudio.analysis.character_discovery import (
ChapterSpeakers,
CharacterDiscoverer,
)
def _make_discoverer() -> CharacterDiscoverer:
cd = CharacterDiscoverer.__new__(CharacterDiscoverer)
cd.TITLE_WORDS = CharacterDiscoverer.TITLE_WORDS
return cd
def _sp(name: str, aliases: list[str], evidence: list[str] | None = None) -> dict:
return {
"primary_name": name,
"aliases": aliases,
"alias_evidence": evidence or [],
"vocal_traits": [],
"demographics": {"type": "Human", "gender": "Female"},
"communication_style": {},
"description": "",
}
def _merge(results: list[ChapterSpeakers]) -> list:
cd = _make_discoverer()
return cd._merge_chapter_results(results, "Test Book", 1000)
def _ch(
index: int,
speaking: list[dict] | None = None,
non_speaking: list[dict] | None = None,
) -> ChapterSpeakers:
return ChapterSpeakers(
chapter_index=index,
chapter_title=f"Ch {index + 1}",
speaking_characters=speaking or [],
non_speaking_characters=non_speaking or [],
validation_notes=[],
)
class TestNonSpeakingNotPromoted:
def test_non_speaking_only_alone_does_not_create_speaker(self):
results = [_ch(0, non_speaking=[_sp("Karel Čapek", [])])]
speakers = _merge(results)
assert not any(s.primary_name == "Karel Čapek" for s in speakers)
def test_non_speaking_appends_chapter_to_existing_speaker(self):
results = [
_ch(0, speaking=[_sp("Kainda", ["Princess Kainda"])]),
_ch(1, non_speaking=[_sp("Kainda", ["Princess Kainda"])]),
]
speakers = _merge(results)
k = next(s for s in speakers if s.primary_name == "Kainda")
assert k.chapters_appeared == [1, 2]
class TestSharedAliasDedup:
def test_same_person_different_primaries_merged_via_shared_alias(self):
results = [
_ch(0, speaking=[_sp("Kainda Walters Foster Murphy", ["Princess Kainda", "Princess"])]),
_ch(1, speaking=[_sp("Kainda Murphy", ["Princess Kainda", "Princess"])]),
]
speakers = _merge(results)
assert len(speakers) == 1
keeper = speakers[0]
assert keeper.primary_name == "Kainda Walters Foster Murphy"
assert keeper.chapters_appeared == [1, 2]
def test_absorb_keeps_richer_entry(self):
results = [
_ch(0, speaking=[_sp("Kainda", ["Princess Kainda"])]),
_ch(1, speaking=[_sp("Kainda Walters Foster Murphy", ["Princess Kainda", "Princess"])]),
_ch(2, speaking=[_sp("Kainda Walters Foster Murphy", ["Princess Kainda", "Princess"])]),
]
speakers = _merge(results)
assert len(speakers) == 1
assert speakers[0].primary_name == "Kainda Walters Foster Murphy"
assert speakers[0].chapters_appeared == [2, 3, 1]
class TestAliasEvidenceKept:
def test_distinct_proper_name_alias_kept_when_evidence_exists(self):
results = [
_ch(
0,
speaking=[
_sp(
"Kainda Walters Foster Murphy",
["Princess Kainda", "Captain Capek"],
evidence=["Are you talking as Princess Murphy or Captain Capek? I'm both."],
)
],
),
_ch(1, speaking=[_sp("Captain Capek", [])]),
]
speakers = _merge(results)
assert len(speakers) == 1
keeper = speakers[0]
assert "Captain Capek" in keeper.aliases
assert keeper.chapters_appeared == [1, 2]
def test_distinct_proper_name_alias_still_filtered_without_evidence(self):
results = [
_ch(0, speaking=[_sp("John Smith", ["Jane Doe"])]),
]
speakers = _merge(results)
john = next(s for s in speakers if s.primary_name == "John Smith")
assert "Jane Doe" not in john.aliases

View File

@@ -0,0 +1,259 @@
"""Tests for umbraaudio.generation.audio_effects."""
from __future__ import annotations
import json
from pathlib import Path
import numpy as np
import soundfile as sf
from umbraaudio.generation.audio_effects import (
TRAIT_EFFECTS,
_bandpass,
_bitcrusher,
_hard_clip,
_lowpass,
_noise_inject,
_pitch_jitter,
_random_gating,
_ring_modulate,
_tremolo,
apply_effects,
apply_effects_to_file,
get_effects_for_traits,
load_speaker_traits,
)
SR = 24000
DURATION = 0.5
SAMPLES = int(SR * DURATION)
def _sine(freq: float = 440.0) -> np.ndarray:
t = np.linspace(0, DURATION, SAMPLES, dtype=np.float32)
return (0.8 * np.sin(2 * np.pi * freq * t)).astype(np.float32)
def _silence() -> np.ndarray:
return np.zeros(SAMPLES, dtype=np.float32)
class TestGetEffectsForTraits:
def test_empty_traits_returns_empty(self) -> None:
assert get_effects_for_traits([]) == []
def test_unknown_trait_returns_empty(self) -> None:
assert get_effects_for_traits(["fluffy", "bouncy"]) == []
def test_known_trait_returns_effects(self) -> None:
chain = get_effects_for_traits(["broken"])
assert len(chain) > 0
assert chain[0][0] == "bitcrusher"
def test_distorted_maps_to_bitcrusher_and_clip(self) -> None:
chain = get_effects_for_traits(["distorted"])
names = [n for n, _ in chain]
assert "bitcrusher" in names
assert "hard_clip" in names
def test_choppy_maps_to_tremolo(self) -> None:
chain = get_effects_for_traits(["choppy"])
names = [n for n, _ in chain]
assert "tremolo" in names
def test_raspy_maps_to_noise_and_jitter(self) -> None:
chain = get_effects_for_traits(["raspy"])
names = [n for n, _ in chain]
assert "noise_inject" in names
assert "pitch_jitter" in names
def test_hard_to_understand_maps_to_bandpass_and_noise(self) -> None:
chain = get_effects_for_traits(["hard to understand"])
names = [n for n, _ in chain]
assert "bandpass" in names
assert "noise_inject" in names
def test_deduplicates_identical_effects(self) -> None:
chain = get_effects_for_traits(["broken", "distorted"])
names = [n for n, _ in chain]
assert names.count("bitcrusher") == 1
def test_multiple_traits_chain_in_order(self) -> None:
chain = get_effects_for_traits(["distorted", "choppy"])
names = [n for n, _ in chain]
assert names.index("bitcrusher") < names.index("tremolo")
def test_all_mapped_traits_have_effect_fn(self) -> None:
all_effects: set[str] = set()
for effects in TRAIT_EFFECTS.values():
for name, _ in effects:
all_effects.add(name)
from umbraaudio.generation.audio_effects import EFFECT_FN
for name in all_effects:
assert name in EFFECT_FN, f"Missing EFFECT_FN for {name}"
class TestApplyEffects:
def test_no_traits_returns_original(self) -> None:
audio = _sine()
result = apply_effects(audio, SR, [])
np.testing.assert_array_equal(result, audio)
def test_output_same_length(self) -> None:
audio = _sine()
result = apply_effects(audio, SR, ["choppy"])
assert len(result) == len(audio)
def test_output_finite(self) -> None:
audio = _sine()
for trait in ["broken", "distorted", "raspy", "choppy", "hard to understand"]:
result = apply_effects(audio, SR, [trait])
assert np.all(np.isfinite(result)), f"Non-finite output for trait: {trait}"
def test_output_bounded(self) -> None:
audio = _sine()
for trait in ["broken", "distorted", "raspy", "choppy"]:
result = apply_effects(audio, SR, [trait])
assert float(np.max(np.abs(result))) <= 1.0 + 1e-5, f"Out of bounds for trait: {trait}"
def test_does_not_mutate_input(self) -> None:
audio = _sine()
original = audio.copy()
apply_effects(audio, SR, ["broken"])
np.testing.assert_array_equal(audio, original)
class TestIndividualEffects:
def test_bitcrusher_changes_signal(self) -> None:
audio = _sine()
result = _bitcrusher(audio, SR, bits=4)
assert not np.allclose(audio, result, atol=0.01)
def test_hard_clip_reduces_dynamic_range(self) -> None:
audio = _sine()
result = _hard_clip(audio, SR, threshold=0.2)
assert np.max(np.abs(result)) <= 1.0 + 1e-5
def test_tremolo_modulates_amplitude(self) -> None:
audio = np.ones(SAMPLES, dtype=np.float32)
result = _tremolo(audio, SR, rate=10.0, depth=0.9)
assert np.max(result) <= 1.0
assert np.min(result) >= 0.0
assert np.max(result) - np.min(result) > 0.1
def test_random_gating_creates_zeros(self) -> None:
audio = np.ones(SAMPLES, dtype=np.float32)
result = _random_gating(audio, SR, gap_prob=0.5)
assert np.any(result == 0.0)
def test_bandpass_reduces_energy_outside_band(self) -> None:
t = np.linspace(0, DURATION, SAMPLES, dtype=np.float32)
low_freq_signal = np.sin(2 * np.pi * 100 * t).astype(np.float32)
result = _bandpass(low_freq_signal, SR, low_hz=500, high_hz=3000)
assert np.max(np.abs(result)) < 0.1
def test_lowpass_reduces_high_frequency(self) -> None:
t = np.linspace(0, DURATION, SAMPLES, dtype=np.float32)
high_freq = np.sin(2 * np.pi * 8000 * t).astype(np.float32)
result = _lowpass(high_freq, SR, cutoff_hz=1000)
assert np.max(np.abs(result)) < 0.1
def test_noise_inject_adds_variation(self) -> None:
audio = np.zeros(SAMPLES, dtype=np.float32)
result = _noise_inject(audio, SR, amount=0.1)
assert np.max(np.abs(result)) > 0.0
def test_pitch_jitter_changes_signal(self) -> None:
audio = _sine()
result = _pitch_jitter(audio, SR, jitter_hz=8.0, depth=0.05)
assert not np.allclose(audio, result, atol=0.01)
def test_ring_modulate_creates_sidebands(self) -> None:
audio = _sine(200.0)
result = _ring_modulate(audio, SR, freq=50.0)
assert not np.allclose(audio, result, atol=0.01)
class TestApplyEffectsToFile:
def test_applies_effects_to_wav(self, tmp_path: Path) -> None:
wav = tmp_path / "test.wav"
audio = _sine()
sf.write(str(wav), audio, SR)
apply_effects_to_file(wav, ["choppy"])
result, sr = sf.read(str(wav), dtype="float32")
assert sr == SR
assert len(result) == SAMPLES
assert not np.allclose(result, audio, atol=0.01)
def test_no_traits_returns_false(self, tmp_path: Path) -> None:
wav = tmp_path / "test.wav"
audio = _sine()
sf.write(str(wav), audio, SR)
applied = apply_effects_to_file(wav, [])
assert applied is False
result, _ = sf.read(str(wav), dtype="float32")
np.testing.assert_allclose(result, audio, atol=1e-4)
def test_custom_output_path(self, tmp_path: Path) -> None:
src = tmp_path / "src.wav"
dst = tmp_path / "dst.wav"
audio = _sine()
sf.write(str(src), audio, SR)
apply_effects_to_file(src, ["distorted"], output_path=dst)
assert dst.exists()
result, _ = sf.read(str(dst), dtype="float32")
assert not np.allclose(result, audio, atol=0.01)
def test_stereo_to_mono(self, tmp_path: Path) -> None:
wav = tmp_path / "stereo.wav"
audio = np.column_stack([_sine(), _sine(880.0)])
sf.write(str(wav), audio, SR)
apply_effects_to_file(wav, ["broken"])
result, _ = sf.read(str(wav), dtype="float32")
assert result.ndim == 1
class TestLoadSpeakerTraits:
def test_empty_file(self, tmp_path: Path) -> None:
path = tmp_path / "speakers.json"
path.write_text('{"speakers": []}')
assert load_speaker_traits(path) == {}
def test_missing_file(self, tmp_path: Path) -> None:
assert load_speaker_traits(tmp_path / "nope.json") == {}
def test_extracts_traits(self, tmp_path: Path) -> None:
path = tmp_path / "speakers.json"
data = {
"speakers": [
{
"primary_name": "Distorted Voice",
"vocal_traits": ["broken", "distorted"],
},
{
"primary_name": "Normal",
"vocal_traits": [],
},
]
}
path.write_text(json.dumps(data))
result = load_speaker_traits(path)
assert result == {"Distorted Voice": ["broken", "distorted"]}
def test_skips_speakers_without_traits(self, tmp_path: Path) -> None:
path = tmp_path / "speakers.json"
data = {
"speakers": [
{"primary_name": "Narrator", "demographics": {}},
]
}
path.write_text(json.dumps(data))
assert load_speaker_traits(path) == {}

View File

@@ -0,0 +1,115 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from umbraaudio.generation.staleness import is_current, marker_path, write_marker
if TYPE_CHECKING:
from pathlib import Path
def test_marker_path(tmp_path: Path) -> None:
wav = tmp_path / "utt_0003.wav"
assert marker_path(wav) == tmp_path / "utt_0003.wav.txt"
def test_missing_wav_is_stale(tmp_path: Path) -> None:
wav = tmp_path / "utt_0000.wav"
assert is_current("Hello", wav) is False
def test_existing_wav_without_marker_is_stale(tmp_path: Path) -> None:
wav = tmp_path / "utt_0000.wav"
wav.write_bytes(b"RIFF....")
assert is_current("Hello", wav) is False
def test_matching_text_is_current(tmp_path: Path) -> None:
wav = tmp_path / "utt_0001.wav"
wav.write_bytes(b"RIFF....")
write_marker("Google uses Android.", wav, speaker="Narrator")
assert is_current("Google uses Android.", wav, speaker="Narrator") is True
def test_changed_speaker_is_stale(tmp_path: Path) -> None:
wav = tmp_path / "utt_0005.wav"
wav.write_bytes(b"RIFF....")
write_marker("Same words.", wav, speaker="Kabir")
assert is_current("Same words.", wav, speaker="Nanny") is False
def test_legacy_plain_text_marker_is_stale(tmp_path: Path) -> None:
wav = tmp_path / "utt_0006.wav"
wav.write_bytes(b"RIFF....")
marker_path(wav).write_text("Same words.", encoding="utf-8")
assert is_current("Same words.", wav, speaker="Kabir") is False
def test_marker_keeps_text_without_speaker_given(tmp_path: Path) -> None:
wav = tmp_path / "utt_0007.wav"
wav.write_bytes(b"RIFF....")
write_marker("Only text recorded.", wav, speaker="Narrator")
assert is_current("Only text recorded.", wav) is True
def test_changed_text_is_stale(tmp_path: Path) -> None:
wav = tmp_path / "utt_0002.wav"
wav.write_bytes(b"RIFF....")
write_marker("Old sentence from a previous segmentation.", wav, speaker="Narrator")
assert is_current("New sentence from re-segmentation.", wav, speaker="Narrator") is False
def test_write_marker_creates_sidecar(tmp_path: Path) -> None:
wav = tmp_path / "utt_0004.wav"
write_marker("Some text", wav, speaker="Narrator")
marker = marker_path(wav).read_text(encoding="utf-8")
assert marker == '{"speaker": "Narrator", "text": "Some text"}'
def test_voice_fingerprint_in_marker(tmp_path: Path) -> None:
wav = tmp_path / "utt_0010.wav"
wav.write_bytes(b"RIFF....")
ref = tmp_path / "female_ref.wav"
ref.write_bytes(b"RIFF-female-voice-data")
write_marker("Hello there.", wav, speaker="Nanny", voice_ref=ref)
marker = marker_path(wav).read_text(encoding="utf-8")
assert '"voice_fp":' in marker
def test_voice_fingerprint_mismatch_is_stale(tmp_path: Path) -> None:
wav = tmp_path / "utt_0011.wav"
wav.write_bytes(b"RIFF....")
ref_old = tmp_path / "old_ref.wav"
ref_old.write_bytes(b"RIFF-old-male-voice")
ref_new = tmp_path / "new_ref.wav"
ref_new.write_bytes(b"RIFF-new-female-voice")
write_marker("Same line.", wav, speaker="Nanny", voice_ref=ref_old)
assert is_current("Same line.", wav, speaker="Nanny", voice_ref=ref_new) is False
def test_voice_fingerprint_match_is_current(tmp_path: Path) -> None:
wav = tmp_path / "utt_0012.wav"
wav.write_bytes(b"RIFF....")
ref = tmp_path / "same_ref.wav"
ref.write_bytes(b"RIFF-same-voice")
write_marker("Same line.", wav, speaker="Nanny", voice_ref=ref)
assert is_current("Same line.", wav, speaker="Nanny", voice_ref=ref) is True
def test_voice_fingerprint_skipped_when_no_ref(tmp_path: Path) -> None:
wav = tmp_path / "utt_0013.wav"
wav.write_bytes(b"RIFF....")
ref = tmp_path / "the_ref.wav"
ref.write_bytes(b"RIFF-voice")
write_marker("With fp.", wav, speaker="Nanny", voice_ref=ref)
assert is_current("With fp.", wav, speaker="Nanny") is True
def test_voice_fingerprint_stale_when_ref_deleted(tmp_path: Path) -> None:
wav = tmp_path / "utt_0014.wav"
wav.write_bytes(b"RIFF....")
ref = tmp_path / "deleted_ref.wav"
ref.write_bytes(b"RIFF-data")
write_marker("Gone ref.", wav, speaker="Nanny", voice_ref=ref)
ref.unlink()
assert is_current("Gone ref.", wav, speaker="Nanny", voice_ref=ref) is False

View File

@@ -0,0 +1,79 @@
from __future__ import annotations
from umbraaudio.analysis.llm_attributor import LLMAttributor
def _attributor_with_speakers(speakers: list[dict]) -> LLMAttributor:
attr = LLMAttributor.__new__(LLMAttributor)
attr._load_speakers = lambda: speakers # type: ignore[method-assign]
return attr
_SPEAKERS = [
{
"primary_name": "Kainda Walters Foster Murphy",
"aliases": ["Princess Kainda", "Princess", "Captain Capek"],
},
{
"primary_name": "Narrator",
"aliases": ["narrator", "the narrator"],
},
{
"primary_name": "Nanny",
"aliases": [],
},
]
class TestAliasMap:
def test_first_word_of_primary_maps_to_full_name(self) -> None:
attr = _attributor_with_speakers(_SPEAKERS)
alias_map = attr._build_alias_map()
assert alias_map["kainda"] == "Kainda Walters Foster Murphy"
assert alias_map["princess kainda"] == "Kainda Walters Foster Murphy"
assert alias_map["nanny"] == "Nanny"
def test_existing_short_alias_not_overridden(self) -> None:
speakers = [
{"primary_name": "Captain Weston", "aliases": ["Weston"]},
{"primary_name": "Weston", "aliases": []},
]
attr = _attributor_with_speakers(speakers)
alias_map = attr._build_alias_map()
assert alias_map["weston"] == "Captain Weston"
def test_primary_name_always_maps_to_itself(self) -> None:
attr = _attributor_with_speakers(_SPEAKERS)
alias_map = attr._build_alias_map()
assert alias_map["kainda walters foster murphy"] == "Kainda Walters Foster Murphy"
class TestNormalizeSpeaker:
def test_narrator_stays_lowercase(self) -> None:
attr = _attributor_with_speakers(_SPEAKERS)
alias_map = attr._build_alias_map()
assert attr._normalize_speaker("narrator", alias_map) == "narrator"
assert attr._normalize_speaker("Narrator", alias_map) == "narrator"
assert attr._normalize_speaker(" The Narrator ", alias_map) == "narrator"
def test_short_name_resolves_to_full_primary(self) -> None:
attr = _attributor_with_speakers(_SPEAKERS)
alias_map = attr._build_alias_map()
assert attr._normalize_speaker("Kainda", alias_map) == "Kainda Walters Foster Murphy"
assert attr._normalize_speaker("kainda", alias_map) == "Kainda Walters Foster Murphy"
def test_unknown_name_unchanged(self) -> None:
attr = _attributor_with_speakers(_SPEAKERS)
alias_map = attr._build_alias_map()
assert attr._normalize_speaker("Unknown Person", alias_map) == "Unknown Person"
def test_empty_name_unchanged(self) -> None:
attr = _attributor_with_speakers(_SPEAKERS)
alias_map = attr._build_alias_map()
assert attr._normalize_speaker("", alias_map) == ""
def test_pronoun_resolves_to_narrator(self) -> None:
attr = _attributor_with_speakers(_SPEAKERS)
alias_map = attr._build_alias_map()
for pronoun in ("She", "she", "He", "THEY", "her"):
assert attr._normalize_speaker(pronoun, alias_map) == "narrator"

458
tests/test_pronunciation.py Normal file
View File

@@ -0,0 +1,458 @@
from __future__ import annotations
import json
from types import SimpleNamespace
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from pathlib import Path
from umbraaudio import pronunciation as pr
def _write_json(path: Path, data: dict) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with open(path, "w") as f:
json.dump(data, f)
class TestLoadRecords:
def test_migrates_legacy_strings(self, temp_dir: Path):
_write_json(
temp_dir / pr.PHONETIC_NAMES_FILE,
{
"Kainda": "KAEIN-duh",
"Weston": {
"phonetic": "WES-ton",
"source": "wiktionary",
"confirmed": True,
"common": False,
},
},
)
records = pr.load_records(temp_dir)
assert records["Kainda"] == {
"phonetic": "KAEIN-duh",
"source": "legacy",
"confirmed": False,
"common": False,
}
assert records["Weston"]["confirmed"] is True
assert records["Weston"]["source"] == "wiktionary"
def test_missing_file_returns_empty(self, temp_dir: Path):
assert pr.load_records(temp_dir) == {}
def test_bad_json_returns_empty(self, temp_dir: Path):
_write_json(temp_dir / pr.PHONETIC_NAMES_FILE, "not json") # type: ignore[arg-type]
assert pr.load_records(temp_dir) == {}
def test_round_trip(self, temp_dir: Path):
records = {
"Kainda": {
"phonetic": "KAEIN-duh",
"source": "user",
"confirmed": True,
"common": False,
},
}
pr.save_records(temp_dir, records)
assert pr.load_records(temp_dir) == records
class TestLoadPhoneticNames:
def test_only_confirmed(self, temp_dir: Path):
_write_json(
temp_dir / pr.PHONETIC_NAMES_FILE,
{
"Kainda": {
"phonetic": "KAEIN-duh",
"source": "wiktionary",
"confirmed": False,
"common": False,
},
"Weston": {
"phonetic": "WES-ton",
"source": "wiktionary",
"confirmed": True,
"common": False,
},
"They": "THAY",
},
)
assert pr.load_phonetic_names(temp_dir) == {"Weston": "WES-ton"}
def test_empty_spelling_not_returned(self, temp_dir: Path):
_write_json(
temp_dir / pr.PHONETIC_NAMES_FILE,
{"Kainda": {"phonetic": "", "source": "", "confirmed": True, "common": False}},
)
assert pr.load_phonetic_names(temp_dir) == {}
def test_dictionaryapi_common_words_excluded_by_default(self, temp_dir: Path):
_write_json(
temp_dir / pr.PHONETIC_NAMES_FILE,
{
"Author": {
"phonetic": "AWTHUH",
"source": "dictionaryapi",
"confirmed": True,
"common": True,
},
"Weston": {
"phonetic": "WES-ton",
"source": "wiktionary",
"confirmed": True,
"common": False,
},
"Kainda": {
"phonetic": "KAEIN-duh",
"source": "user",
"confirmed": True,
"common": False,
},
},
)
assert pr.load_phonetic_names(temp_dir) == {
"Weston": "WES-ton",
"Kainda": "KAEIN-duh",
}
assert pr.load_phonetic_names(temp_dir, include_common=True) == {
"Author": "AWTHUH",
"Weston": "WES-ton",
"Kainda": "KAEIN-duh",
}
def test_user_override_of_common_word_is_applied(self, temp_dir: Path):
_write_json(
temp_dir / pr.PHONETIC_NAMES_FILE,
{
"Author": {
"phonetic": "AW-THUH",
"source": "user",
"confirmed": True,
"common": False,
},
},
)
assert pr.load_phonetic_names(temp_dir) == {"Author": "AW-THUH"}
class TestIpaToSimple:
def test_digraphs(self):
assert pr.ipa_to_simple("eɪ") == "AY"
assert pr.ipa_to_simple("tʃ") == "CH"
assert pr.ipa_to_simple("dʒæk") == "JAEK"
assert pr.ipa_to_simple("θæŋks") == "THAENGKS"
assert pr.ipa_to_simple("wɪnd") == "WIHND"
assert pr.ipa_to_simple("kæt") == "KAET"
def test_stress_markers_and_strip(self):
assert pr.ipa_to_simple("ˈkæɪndə") == "KAEIHNDUH"
def test_unicode_single_storey_g_retained(self):
assert pr.ipa_to_simple("/ˈɡuːɡəl/") == "GOOGUHL"
def test_wh_phoneme_retained(self):
assert pr.ipa_to_simple("/ʍɛn/") == "WEHN"
def test_glottal_stop_and_syllabic_n(self):
assert pr.ipa_to_simple("/ˈbʌʔn̩/") == "BUH'N"
def test_empty(self):
assert pr.ipa_to_simple("") == ""
class TestApplyPhonetics:
def test_longest_first(self):
phonetic = {"Captain Weston": "KAP-tin WES-ton", "Weston": "WES-ton"}
text = "Captain Weston greeted Weston."
assert pr.apply_phonetics(text, phonetic) == "KAP-tin WES-ton greeted WES-ton."
def test_no_map(self):
assert pr.apply_phonetics("Hello world", {}) == "Hello world"
def test_curly_punctuation_normalized(self):
text = "“Author’s Note”—it’s his…"
assert pr.apply_phonetics(text, {}) == "\"Author's Note\"-it's his..."
assert pr.apply_phonetics("don’t won’t", {}) == "don't won't"
class TestNormalizeTtsText:
def test_ascii_identity(self):
assert pr.normalize_tts_text("Plain ASCII text.") == "Plain ASCII text."
def test_curly_to_ascii(self):
assert pr.normalize_tts_text("\u2018a\u2019 \u201cb\u201d \u2013\u2014\u2026") == (
"'a' \"b\" --..."
)
def test_accents_preserved(self):
assert pr.normalize_tts_text("Čapek and Köhler") == "Čapek and Köhler"
class TestLookup:
def test_dictionary_hit_wins(self, monkeypatch):
dict_hit = {"ok": True, "simple": "THAY", "source": "dictionaryapi", "common": True}
monkeypatch.setattr(pr, "_lookup_dictionaryapi", lambda name: dict_hit)
result = pr.lookup_pronunciation("They")
assert result["ok"] is True
assert result["common"] is True
assert result["source"] == "dictionaryapi"
def test_falls_back_to_wiktionary(self, monkeypatch):
monkeypatch.setattr(pr, "_lookup_dictionaryapi", lambda name: {"ok": False, "error": "no"})
wiki_hit = {"ok": True, "simple": "KAYN-duh", "source": "wiktionary", "common": False}
monkeypatch.setattr(pr, "_lookup_wiktionary", lambda name: wiki_hit)
result = pr.lookup_pronunciation("Kainda")
assert result["ok"] is True
assert result["source"] == "wiktionary"
assert result["common"] is False
def test_lowercase_fallback_attempted(self, monkeypatch):
calls = []
def fake_dict(name: str) -> dict:
calls.append(name)
return {"ok": False, "error": "no"}
monkeypatch.setattr(pr, "_lookup_dictionaryapi", fake_dict)
monkeypatch.setattr(
pr,
"_lookup_wiktionary",
lambda name: {"ok": False, "error": "no entry"},
)
monkeypatch.setattr(
pr,
"_lookup_wikipedia",
lambda name: {"ok": False, "error": "no entry"},
)
monkeypatch.setattr(
pr,
"_lookup_behindthename",
lambda name: {"ok": False, "error": "no entry"},
)
result = pr.lookup_pronunciation("Kainda")
assert result["ok"] is True
assert result["source"] == "guess"
assert calls == ["Kainda", "kainda"]
def test_wiktionary_ipa_parse(self, monkeypatch):
def fake_urlopen(url: str, timeout: int = 8) -> bytes:
return json.dumps({"parse": {"wikitext": {"*": "{{IPA|en|/ˈkæɪndə/}}"}}}).encode()
monkeypatch.setattr(pr, "_urlopen", fake_urlopen)
result = pr._lookup_wiktionary("Kainda")
assert result["ok"] is True
assert result["source"] == "wiktionary"
assert result["common"] is False
assert "KAEIHNDUH" in result["simple"]
def test_wiktionary_enpr_fallback(self, monkeypatch):
def fake_urlopen(url: str, timeout: int = 8) -> bytes:
return json.dumps({"parse": {"wikitext": {"*": "{{enPR|kān-də}}"}}}).encode()
monkeypatch.setattr(pr, "_urlopen", fake_urlopen)
result = pr._lookup_wiktionary("Kainda")
assert result["ok"] is True
assert result["simple"] == "KĀN-DƏ".upper()
def test_wiktionary_no_entry(self, monkeypatch):
def fake_urlopen(url: str, timeout: int = 8) -> bytes:
return b"{}"
monkeypatch.setattr(pr, "_urlopen", fake_urlopen)
result = pr._lookup_wiktionary("FictionalName")
assert result["ok"] is False
class TestNewLookupSources:
def test_wikipedia_ipac_en(self, monkeypatch):
def fake_urlopen(url: str, timeout: int = 8) -> bytes:
return json.dumps(
{"parse": {"wikitext": {"*": "{{IPAc-en|ɑ:|ˈ|l|iː|ə|audio=En-usAaliyah.ogg}}"}}}
).encode()
monkeypatch.setattr(pr, "_urlopen", fake_urlopen)
result = pr._lookup_wikipedia("Aaliyah")
assert result["ok"] is True
assert result["source"] == "wikipedia"
assert result["common"] is False
assert result["simple"] == "AH-L-EE-UH"
def test_wikipedia_ipa_template(self, monkeypatch):
def fake_urlopen(url: str, timeout: int = 8) -> bytes:
return json.dumps(
{"parse": {"wikitext": {"*": "{{Infobox|pron={{IPA|en|/ˈkæɪndə/}}}}"}}}
).encode()
monkeypatch.setattr(pr, "_urlopen", fake_urlopen)
result = pr._lookup_wikipedia("Kainda")
assert result["ok"] is True
assert result["source"] == "wikipedia"
assert "KAEIHNDUH" in result["simple"]
def test_wikipedia_no_entry(self, monkeypatch):
def fake_urlopen(url: str, timeout: int = 8) -> bytes:
return b"{}"
monkeypatch.setattr(pr, "_urlopen", fake_urlopen)
result = pr._lookup_wikipedia("FictionalName")
assert result["ok"] is False
def test_behindthename(self, monkeypatch):
html = (
'<a href="#" class="prn ipa" onclick="return show_prondetail('
"'ˈmaɪ.kəl', 1, true, true, 'eng');\">"
)
def fake_urlopen(url: str, timeout: int = 8) -> bytes:
return html.encode()
monkeypatch.setattr(pr, "_urlopen", fake_urlopen)
result = pr._lookup_behindthename("Michael")
assert result["ok"] is True
assert result["source"] == "behindthename"
assert "MEYE" in result["simple"]
def test_behindthename_no_hit(self, monkeypatch):
def fake_urlopen(url: str, timeout: int = 8) -> bytes:
return b"<html>nothing here</html>"
monkeypatch.setattr(pr, "_urlopen", fake_urlopen)
result = pr._lookup_behindthename("FictionalName")
assert result["ok"] is False
def test_behindthename_skips_multiword(self, monkeypatch):
def fake_urlopen(url: str, timeout: int = 8) -> bytes:
raise AssertionError("should not be called for multi-word names")
monkeypatch.setattr(pr, "_urlopen", fake_urlopen)
result = pr._lookup_behindthename("Captain Weston")
assert result["ok"] is False
def test_guess_is_used_as_last_resort(self, monkeypatch):
sources = (
"_lookup_dictionaryapi",
"_lookup_wiktionary",
"_lookup_wikipedia",
"_lookup_behindthename",
)
for fn in sources:
monkeypatch.setattr(pr, fn, lambda name: {"ok": False, "error": "no"})
result = pr.lookup_pronunciation("Zorath")
assert result["ok"] is True
assert result["source"] == "guess"
assert result["simple"] == result["simple"].upper()
class TestGuessPronunciation:
def test_syllabified_uppercase(self):
assert pr.guess_pronunciation("Kainda") == "KAYN-DUH"
def test_weston(self):
assert pr.guess_pronunciation("Weston") == "WEHS-TOHN"
def test_single_syllable_no_hyphen(self):
assert pr.guess_pronunciation("Smith") == "SMIHTH"
def test_silent_e_lengthens(self):
assert pr.guess_pronunciation("Mike") == "MEYEK"
def test_double_consonant_collapsed(self):
assert pr.guess_pronunciation("Miller") == "MIH-LEHR"
def test_multiword(self):
assert pr.guess_pronunciation("Captain Weston") == "KAHP-TAYN WEHS-TOHN"
def test_mc_and_o_prefixes(self):
assert pr.guess_pronunciation("O'Brien") == "OH-BREEN"
assert pr.guess_pronunciation("McLeod") == "MUHK-LEH-OHD"
def test_soft_c_before_e(self):
assert pr.guess_pronunciation("Cindy") == "SIHN-DEE"
def test_final_a_schwa(self):
assert pr.guess_pronunciation("Kainda") == "KAYN-DUH"
assert pr.guess_pronunciation("Aaliyah") == "AH-LIH-YAH"
def test_empty(self):
assert pr.guess_pronunciation("") == ""
assert pr.guess_pronunciation(" ") == ""
class TestPaths:
def test_sanitize(self):
assert pr._sanitize("Captain Weston") == "captain_weston"
assert pr._sanitize("O'Brien") == "o_brien"
def test_audio_path_deterministic(self, temp_dir: Path):
p1 = pr.pronunciation_audio_path(temp_dir, "Captain Weston")
p2 = pr.pronunciation_audio_path(temp_dir, "Captain Weston")
assert p1 == p2 == temp_dir / "pronunciation_audio" / "captain_weston.wav"
class TestNarratorRef:
def test_prefers_narrator(self, temp_dir: Path):
voices = temp_dir / "voices"
voices.mkdir()
(voices / "kainda_ref.wav").write_bytes(b"wav")
narrator = voices / "narrator_ref.wav"
narrator.write_bytes(b"wav")
assert pr._find_narrator_ref(voices) == narrator
def test_falls_back_to_kainda(self, temp_dir: Path):
voices = temp_dir / "voices"
voices.mkdir()
kainda = voices / "kainda_ref.wav"
kainda.write_bytes(b"wav")
assert pr._find_narrator_ref(voices) == kainda
def test_missing_dir(self, temp_dir: Path):
assert pr._find_narrator_ref(temp_dir / "nope") is None
class TestGenerateAudio:
def _config(self, temp_dir: Path) -> SimpleNamespace:
return SimpleNamespace(
data_dir=temp_dir,
voices_dir=temp_dir / "voices",
tts=SimpleNamespace(qwen_tts_url="http://tts:8881"),
)
def test_success_with_narrator_ref(self, temp_dir: Path, monkeypatch):
config = self._config(temp_dir)
config.voices_dir.mkdir()
ref = config.voices_dir / "narrator_ref.wav"
ref.write_bytes(b"wav")
class FakeEngine:
def __init__(self, **kwargs: object) -> None:
self.kwargs = kwargs
def generate(self, text: str, output_path: Path, voice_ref=None, instruct=None, ref_text=None) -> Path:
output_path.write_bytes(b"RIFF")
return output_path
monkeypatch.setattr("umbraaudio.voices.qwen_tts.QwenTTSEngine", FakeEngine)
result = pr.generate_pronunciation_audio(config, "Captain Weston", "KAP-tin WES-ton")
assert result["ok"] is True
assert result["audio"] == "captain_weston.wav"
assert (temp_dir / "pronunciation_audio" / "captain_weston.wav").exists()
def test_failure_is_captured(self, temp_dir: Path, monkeypatch):
config = self._config(temp_dir)
config.voices_dir.mkdir()
(config.voices_dir / "narrator_ref.wav").write_bytes(b"wav")
class FailingEngine:
def __init__(self, **kwargs: object) -> None:
pass
def generate(self, **kwargs: object) -> Path:
raise RuntimeError("cuDNN CUDNN_STATUS_INTERNAL_ERROR")
monkeypatch.setattr("umbraaudio.voices.qwen_tts.QwenTTSEngine", FailingEngine)
result = pr.generate_pronunciation_audio(config, "Captain Weston", "KAP-tin WES-ton")
assert result["ok"] is False
assert "cuDNN" in (result["error"] or "")

141
tests/test_segmenter.py Normal file
View File

@@ -0,0 +1,141 @@
from __future__ import annotations
from umbraaudio.mapping.segmenter import Segmenter
def test_multisentence_quote_stays_one_utterance() -> None:
seg = Segmenter()
text = (
"“Fifty light-years. An old freighter like this could only jump "
"twenty-five light-years. How can Deal Hunter do that?”"
)
utts = seg.segment(text, 1, character_names=["Nanny", "Kainda"])
dialogues = [u for u in utts if u["type"] == "dialogue"]
assert len(dialogues) == 1
assert dialogues[0]["text"].endswith("?”")
assert "How can Deal Hunter do that?" in dialogues[0]["text"]
def test_quote_with_inner_single_quotes_not_mangled() -> None:
seg = Segmenter()
text = (
"“Our best analysis would indicate that it is a message. ‘Don’t help "
"Princess Kainda.’ Princess, we must consider how to deal with you now.”"
)
utts = seg.segment(text, 1, character_names=["Nanny", "Kainda"])
dialogues = [u for u in utts if u["type"] == "dialogue"]
assert len(dialogues) == 1
assert "‘Don’t help Princess Kainda.’" in dialogues[0]["text"]
assert "Princess, we must consider" in dialogues[0]["text"]
def test_dialogue_and_narration_split_kept_separate() -> None:
seg = Segmenter()
text = "Nanny removed her helmet. “How are you feeling, Princess?”"
utts = seg.segment(text, 1, character_names=["Nanny", "Kainda"])
types = [u["type"] for u in utts]
assert "narration" in types
assert "dialogue" in types
def test_carryover_speaker_after_tag() -> None:
seg = Segmenter()
text = "Nanny removed her helmet. “How are you feeling, Princess?”\n“I have a headache.”"
utts = seg.segment(text, 1, character_names=["Nanny", "Kainda", "Princess"])
dialogues = [u for u in utts if u["type"] == "dialogue"]
assert dialogues[0]["character_name"] == "Nanny"
assert dialogues[1]["character_name"] == "Princess"
def test_outer_quote_closed_when_inner_quote_at_end() -> None:
seg = Segmenter()
text = "“I am Support Robot 74532, but I have been named ‘Nanny.’”"
utts = seg.segment(text, 1, character_names=["Nanny", "Kainda"])
dialogues = [u for u in utts if u["type"] == "dialogue"]
assert len(dialogues) == 1
assert dialogues[0]["text"].endswith("’”")
assert "‘Nanny.’" in dialogues[0]["text"]
def test_inner_quote_kept_when_quote_continues() -> None:
seg = Segmenter()
text = "“She asked me to say, ‘Nothing will happen to you on my ship.’”"
utts = seg.segment(text, 1, character_names=["Kainda"])
dialogues = [u for u in utts if u["type"] == "dialogue"]
assert len(dialogues) == 1
assert dialogues[0]["text"] == text
def test_author_note_splits_into_sentences() -> None:
seg = Segmenter()
text = (
"Author’s Note\n\n"
"This novel, Deal Hunter, does not use the terms android or droid. "
"Google uses “Android” to refer to the operating system on phones.\n\n"
"Deal Hunter uses the generic terms of robot and artificial intelligence (AI)."
)
utts = seg.segment(text, 5, chapter_title="Author's Note")
texts = [u["text"] for u in utts]
assert len(texts) == 4
assert texts[0] == "Author’s Note"
assert "does not use the terms android or droid." in texts[1]
assert "Google uses “Android” to refer to the operating system on phones." in texts[2]
assert texts[3].startswith("Deal Hunter uses the generic terms")
def test_author_note_all_narration() -> None:
seg = Segmenter()
text = (
"Author’s Note\n\n"
"Every star has humans, and they are looking for resources. "
"The Dial sector has several pirate clans."
)
utts = seg.segment(text, 5, chapter_title="Author's Note")
assert all(u["type"] == "narration" for u in utts)
assert all(u["character_name"] == "narrator" for u in utts)
def test_author_note_merges_abbreviations() -> None:
seg = Segmenter()
text = "Author’s Note\n\nThis is a work of fiction. Dr. Smith wrote it."
utts = seg.segment(text, 5, chapter_title="Author's Note")
texts = [u["text"] for u in utts]
assert "This is a work of fiction." in texts
assert "Dr. Smith wrote it." in texts
def test_unclosed_quote_does_not_swallow_following_narration() -> None:
seg = Segmenter()
text = (
"As she says this, there is a roar, and Niñera says, “The captain is "
"changing course, and that was a missile he is firing at the pirates. \n"
"As Niñera helps Kainda, they feel the impacts more, and the air "
"pressure alarm sounds. The computer voice announces, “Hull breach. "
"Please put on emergency space suits.”\nSealing the suit, the robot "
"picks up the helmet."
)
utts = seg.segment(text, 6, character_names=["Niñera", "Kainda"])
assert len(utts) >= 3
joined = " ".join(u["text"] for u in utts)
assert "Hull breach" in joined
hull = [u for u in utts if "Hull breach" in u["text"]]
assert len(hull) == 1
assert "changing course" not in hull[0]["text"]
def test_unterminated_quote_at_eof_becomes_plain_text() -> None:
seg = Segmenter()
text = "She stared out the viewport. “We are never going to make it"
utts = seg.segment(text, 1)
assert all(u["type"] == "narration" for u in utts)
assert any("never going to make it" in u["text"] for u in utts)
def test_balanced_multisentence_quote_still_intact() -> None:
seg = Segmenter()
text = "He said, “Line one. Line two. Line three.” Then he left."
utts = seg.segment(text, 1)
dialogues = [u for u in utts if u["type"] == "dialogue"]
assert len(dialogues) == 1
assert dialogues[0]["text"].startswith("“")
assert dialogues[0]["text"].endswith("”")

View File

@@ -0,0 +1,103 @@
"""Quick verification of segmenter fixes."""
from __future__ import annotations
from umbraaudio.mapping.segmenter import Segmenter
def test_apostrophe_hold_em():
"""Hold 'em with U+2018 apostrophe should not create false dialogue."""
seg = Segmenter()
apostrophe = "\u2018"
text = (
"This style of poker has been known by many names over the years. "
"In this pirate casino, it is called Pirate Hold " + apostrophe + "em. "
"The initial ante-up bet is done with a dealer puck."
)
utts = seg.segment(text, 1, character_names=["Kainda"])
print("Test 1: Hold 'em apostrophe fix")
for u in utts:
print(f" type={u['type']}, speaker={u.get('character_name')}: {u['text'][:80]}")
assert all(u["type"] == "narration" for u in utts), "FAIL: Should be all narration"
print("PASS\n")
def test_about_the_author_meta_chapter():
"""About the author chapters should not create dialogue-type utterances."""
seg = Segmenter()
left_double = "\u201c"
right_double = "\u201d"
em_dash = "\u2014"
apostrophe = "\u2019"
text = (
"B.D. Murphy started writing. If you like techy thrillers, "
f"and endings that make you say {left_double}wait, WHAT?{right_double}"
f"{em_dash}he{apostrophe}s your guy."
)
utts = seg.segment(text, 1, character_names=["Kainda"], chapter_title="About the author")
print("Test 2: About the author (special chapter type)")
for u in utts:
print(f" type={u['type']}, speaker={u.get('character_name')}: {u['text'][:80]}")
assert all(u["type"] == "narration" for u in utts), "FAIL: Should be all narration"
assert all(u["character_name"] == "narrator" for u in utts), "FAIL: Should be narrator"
print("PASS\n")
def test_bracket_about_author_title():
seg = Segmenter()
left_double = "\u201c"
right_double = "\u201d"
em_dash = "\u2014"
apostrophe = "\u2019"
text = (
f"endings that make you say {left_double}wait, WHAT?"
f"{right_double}{em_dash}he{apostrophe}s your guy."
)
utts = seg.segment(text, 1, character_names=["Kainda"], chapter_title="[about author]")
print("Test 3: [about author] title")
for u in utts:
print(f" type={u['type']}, speaker={u.get('character_name')}: {u['text'][:80]}")
assert all(u["type"] == "narration" for u in utts), "FAIL: Should be all narration"
print("PASS\n")
def test_real_curly_dialogue_still_works():
seg = Segmenter()
left_double = "\u201c"
right_double = "\u201d"
text = f"{left_double}How are you feeling, Princess?{right_double}"
utts = seg.segment(text, 1, character_names=["Nanny", "Kainda"])
print("Test 4: Real curly-quoted dialogue still works")
for u in utts:
print(f" type={u['type']}, speaker={u.get('character_name')}: {u['text'][:80]}")
assert any(u["type"] == "dialogue" for u in utts), "FAIL: Should have dialogue"
print("PASS\n")
def test_single_quote_proper_closing():
seg = Segmenter()
left_single = "\u2018"
right_single = "\u2019"
text = f"{left_single}Hello,{right_single} said the captain."
utts = seg.segment(text, 1, character_names=["Captain"])
print("Test 5: Single-quote dialogue with proper closing")
for u in utts:
print(f" type={u['type']}, speaker={u.get('character_name')}: {u['text'][:80]}")
assert any(u["type"] == "dialogue" for u in utts), "FAIL: Should have dialogue"
print("PASS\n")
def test_title_metadata_not_dialogue():
seg = Segmenter()
left_double = "\u201c"
right_double = "\u201d"
text = f"[default accent = {left_double}Canadian{right_double}]"
utts = seg.segment(text, 1, character_names=["Kainda"], chapter_title="Title")
print("Test 6: Title page with metadata bracket")
for u in utts:
print(f" type={u['type']}, speaker={u.get('character_name')}: {u['text'][:80]}")
assert all(u["type"] == "narration" for u in utts), "FAIL: Should be all narration"
print("PASS\n")
if __name__ == "__main__":
test_apostrophe_hold_em()
test_about_the_author_meta_chapter()
test_bracket_about_author_title()
test_real_curly_dialogue_still_works()
test_single_quote_proper_closing()
test_title_metadata_not_dialogue()
print("All segmenter fix tests passed!")

View File

@@ -0,0 +1,133 @@
"""Tests for local-first storage with NAS fallback (umbraaudio.storage)
and the prune planner in scripts/nas_lifecycle."""
from __future__ import annotations
import shutil
import sys
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
import nas_lifecycle # noqa: E402
from umbraaudio import storage as st # noqa: E402
@pytest.fixture
def stores(tmp_path):
local = tmp_path / "local" / "UmbraAudio"
nas = tmp_path / "nas" / "UmbraAudio"
for root in (local, nas):
root.mkdir(parents=True)
return local, nas
def _mirror(local: Path, nas: Path, rel: str) -> None:
src = local / rel
dst = nas / rel
dst.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, dst)
class TestResolvePath:
def test_local_hit(self, stores):
local, nas = stores
f = local / "a.txt"
f.write_text("x")
assert st.resolve_path(f, local_root=local, nas_root=nas) == f
def test_relative_resolves_against_local_root(self, stores):
local, nas = stores
(local / "b.txt").write_text("y")
out = st.resolve_path("b.txt", local_root=local, nas_root=nas)
assert out == local / "b.txt"
def test_fallback_restores_from_nas(self, stores):
local, nas = stores
(nas / "models").mkdir()
(nas / "models" / "big.bin").write_bytes(b"0" * 128)
out = st.resolve_path("models/big.bin", local_root=local, nas_root=nas)
assert out.read_bytes() == b"0" * 128
assert out.exists()
def test_missing_everywhere_raises(self, stores):
local, nas = stores
with pytest.raises(FileNotFoundError):
st.resolve_path("nope.txt", local_root=local, nas_root=nas)
def test_restore_false_skips_fallback(self, stores):
local, nas = stores
out = st.resolve_path("gone.txt", local_root=local,
nas_root=nas, restore=False)
assert out == local / "gone.txt"
assert not out.exists()
def _age_file(path: Path, days: int) -> None:
import os
old = __import__("time").time() - days * 86400
os.utime(path, (old, old))
class TestPrunePlan:
def _seed(self, local: Path, nas: Path) -> None:
(local / "output").mkdir(parents=True)
(local / "scripts").mkdir()
for rel in ("output/old.wav", "output/new.wav", "scripts/gen.py"):
f = local / rel
f.write_text(rel)
if "old" in rel or rel.endswith("gen.py"):
_age_file(f, 90)
def test_only_old_unprotected_verified_files_pruned(self, stores):
local, nas = stores
self._seed(local, nas)
_mirror(local, nas, "output/old.wav")
plan = nas_lifecycle.prune_plan(local, nas,
nas_lifecycle.DEFAULT_POLICY)
assert plan == [local / "output" / "old.wav"]
def test_unverified_files_never_pruned(self, stores):
local, nas = stores
self._seed(local, nas)
assert nas_lifecycle.prune_plan(local, nas,
nas_lifecycle.DEFAULT_POLICY) == []
def test_emergency_includes_protected_dirs(self, stores):
local, nas = stores
self._seed(local, nas)
_mirror(local, nas, "scripts/gen.py")
plan = nas_lifecycle.prune_plan(local, nas,
nas_lifecycle.DEFAULT_POLICY,
emergency=True)
assert local / "scripts" / "gen.py" in plan
def test_recent_files_excluded_even_if_backed_up(self, stores):
local, nas = stores
self._seed(local, nas)
_mirror(local, nas, "output/new.wav")
assert local / "output/new.wav" not in nas_lifecycle.prune_plan(
local, nas, nas_lifecycle.DEFAULT_POLICY)
class TestVerifiedOnNas:
def test_size_mismatch_rejects(self, stores):
local, nas = stores
f = local / "x.bin"
f.write_bytes(b"12345")
m = nas / "x.bin"
m.write_bytes(b"12")
_age_file(f, 60)
assert not nas_lifecycle.verified_on_nas(f, local, nas)
def test_match_accepts(self, stores):
local, nas = stores
f = local / "x.bin"
f.write_bytes(b"12345")
_mirror(local, nas, "x.bin")
_age_file(f, 60)
_age_file(nas / "x.bin", 60)
assert nas_lifecycle.verified_on_nas(f, local, nas)

View File

@@ -0,0 +1,162 @@
from __future__ import annotations
import json
import math
import numpy as np
import pytest
import soundfile as sf
from umbraaudio.analysis.voice_analysis import (
VoiceAnalysisEngine,
_acoustic_vector,
_analyze_audio_file,
load_analysis_status,
run_voice_analysis_in_background,
)
def _write_sine(path, freq: float, seconds: float, sr: int = 24000) -> None:
t = np.linspace(0, seconds, int(sr * seconds), endpoint=False)
y = (0.1 * np.sin(2 * math.pi * freq * t)).astype(np.float32)
sf.write(str(path), y, sr)
@pytest.fixture
def refs_dir(temp_dir):
d = temp_dir / "voices"
d.mkdir()
return d
@pytest.fixture
def engine(temp_dir, refs_dir):
output_dir = temp_dir / "analysis"
return VoiceAnalysisEngine(refs_dir=refs_dir, output_dir=output_dir, min_duration_s=3.0)
class TestAnalyzeAudio:
def test_computes_features(self, temp_dir):
path = temp_dir / "alice_ref.wav"
_write_sine(path, 200.0, 5.0)
features = _analyze_audio_file(path, 24000)
assert features.duration == pytest.approx(5.0, abs=0.1)
assert features.sample_rate == 24000
assert features.mean_pitch_hz is not None
assert features.mean_pitch_hz == pytest.approx(200.0, abs=30.0)
assert features.rms > 0.0
class TestEngine:
def test_analyze_empty_dir(self, engine):
report = engine.analyze()
assert report["summary"]["total"] == 0
assert report["passed"] is False
assert engine.report_path.exists()
def test_analyze_single_voice(self, engine, temp_dir):
_write_sine(engine.refs_dir / "alice_ref.wav", 200.0, 5.0)
report = engine.analyze()
assert report["summary"]["total"] == 1
assert "alice" in report["voices"]
voice = report["voices"]["alice"]
assert voice["requirements"]["duration"]["passed"]
assert voice["requirements"]["sample_rate"]["passed"]
assert voice["requirements"]["energy"]["passed"]
assert voice["requirements"]["uniqueness"]["passed"]
def test_analyze_two_distinct_voices(self, engine):
_write_sine(engine.refs_dir / "alice_ref.wav", 180.0, 5.0)
_write_sine(engine.refs_dir / "bob_ref.wav", 260.0, 5.0)
report = engine.analyze()
assert report["summary"]["total"] == 2
assert report["summary"]["near_duplicate_count"] == 0
assert report["passed"] is True
def test_speaker_names_filter(self, engine, temp_dir):
_write_sine(engine.refs_dir / "alice_ref.wav", 180.0, 5.0)
_write_sine(engine.refs_dir / "stale_ref.wav", 220.0, 5.0)
engine.speaker_names = {"Alice"}
report = engine.analyze()
assert report["summary"]["total"] == 1
assert "alice" in report["voices"]
assert "stale" not in report["voices"]
def test_short_file_fails_duration(self, engine):
_write_sine(engine.refs_dir / "alice_ref.wav", 200.0, 1.0)
report = engine.analyze()
assert report["voices"]["alice"]["requirements"]["duration"]["passed"] is False
def test_report_persisted(self, engine):
_write_sine(engine.refs_dir / "alice_ref.wav", 200.0, 5.0)
engine.analyze()
saved = json.loads(engine.report_path.read_text())
assert saved["summary"]["total"] == 1
assert saved["passed"] is True
def test_load_report(self, engine):
_write_sine(engine.refs_dir / "alice_ref.wav", 200.0, 5.0)
engine.analyze()
loaded = engine.load_report()
assert loaded is not None
assert loaded["summary"]["total"] == 1
class TestNearDuplicates:
def test_similar_voices_flagged(self, engine):
_write_sine(engine.refs_dir / "alice_ref.wav", 200.0, 5.0)
_write_sine(engine.refs_dir / "alice2_ref.wav", 200.0, 5.0)
report = engine.analyze()
assert report["summary"]["near_duplicate_count"] >= 1
def test_near_duplicate_threshold_respected(self, engine):
engine.near_duplicate_threshold = 1.01
_write_sine(engine.refs_dir / "alice_ref.wav", 200.0, 5.0)
_write_sine(engine.refs_dir / "bob_ref.wav", 260.0, 5.0)
report = engine.analyze()
assert report["summary"]["near_duplicate_count"] == 0
class TestAcousticFallback:
def test_acoustic_vector(self, engine, temp_dir):
_write_sine(temp_dir / "alice_ref.wav", 200.0, 5.0)
features = _analyze_audio_file(temp_dir / "alice_ref.wav", 24000)
vec = _acoustic_vector(features)
assert vec.shape == (6,)
assert vec.dtype == np.float32
assert vec[0] == pytest.approx(features.mean_pitch_hz or 0.0)
assert vec[1] == pytest.approx(features.pitch_std_hz or 0.0)
def test_embedding_failure_falls_back_to_acoustic(self, engine, monkeypatch):
_write_sine(engine.refs_dir / "alice_ref.wav", 180.0, 5.0)
_write_sine(engine.refs_dir / "bob_ref.wav", 260.0, 5.0)
def _boom(self):
raise RuntimeError("encoder unavailable")
monkeypatch.setattr(
"umbraaudio.analysis.voice_analysis.VoiceAnalysisEngine._get_embeddings",
_boom,
)
report = engine.analyze()
assert report["summary"]["total"] == 2
# Fallback must still produce a populated similarity matrix for both voices.
alice = report["voices"]["alice"]
assert alice["requirements"]["uniqueness"]["nearest_neighbor"] == "bob"
class TestBackgroundRun:
def test_runs_and_writes_status(self, temp_dir, refs_dir):
output_dir = temp_dir / "analysis"
_write_sine(refs_dir / "alice_ref.wav", 200.0, 5.0)
run_voice_analysis_in_background(
refs_dir=refs_dir, output_dir=output_dir, speaker_names={"Alice"}
)
import time
report_path = output_dir / "voice_analysis.json"
deadline = time.time() + 30
while time.time() < deadline and not report_path.exists():
time.sleep(0.2)
assert report_path.exists()
assert load_analysis_status(output_dir)["status"] == "idle"

View File

@@ -0,0 +1,71 @@
from __future__ import annotations
import pytest
from umbraaudio.voices.edge_tts import ALL_EDGE_VOICES, EdgeTTSEngine
@pytest.fixture
def engine() -> EdgeTTSEngine:
return EdgeTTSEngine()
def _voice_names() -> set[str]:
return {v["name"] for v in ALL_EDGE_VOICES}
class TestSelectVoice:
def test_male_american_low_returns_deep_us(self, engine: EdgeTTSEngine) -> None:
voice = engine.select_voice(
energy="commanding",
pitch="very_low",
gender="Male",
character_name="Narrator",
accent="american",
)
assert voice == "en-US-GuyNeural"
def test_respects_accent(self, engine: EdgeTTSEngine) -> None:
voice = engine.select_voice(
energy="measured",
pitch="mid",
gender="Male",
character_name="Ravi",
accent="indian",
)
assert voice in _voice_names()
chosen = next(v for v in ALL_EDGE_VOICES if v["name"] == voice)
assert chosen["accent"] == "indian"
assert chosen["gender"] == "male"
def test_respects_pitch_when_accent_broad(self, engine: EdgeTTSEngine) -> None:
voice = engine.select_voice(
energy="measured",
pitch="high",
gender="Female",
character_name="Lily",
accent="american",
)
chosen = next(v for v in ALL_EDGE_VOICES if v["name"] == voice)
assert chosen["pitch"] == "high"
def test_mid_register_allows_any_mid_accent(self, engine: EdgeTTSEngine) -> None:
voice = engine.select_voice(
energy="measured",
pitch="mid",
gender="Male",
character_name="Alex",
accent="british",
)
chosen = next(v for v in ALL_EDGE_VOICES if v["name"] == voice)
assert chosen["accent"] == "british"
def test_no_accent_picks_something_valid(self, engine: EdgeTTSEngine) -> None:
voice = engine.select_voice(
energy="measured",
pitch="mid",
gender="Female",
character_name="Sam",
accent=None,
)
assert voice in _voice_names()

View File

@@ -0,0 +1,285 @@
from __future__ import annotations
import pytest
from umbraaudio.config import AppConfig, LLMConfig, TTSConfig
from umbraaudio.voices import model_coordinator as mc
from umbraaudio.voices.model_coordinator import (
AsyncModelCoordinator,
ModelCoordinator,
PipelineStage,
)
class FakeResponse:
def __init__(self, status_code: int, payload: dict, text: str | None = None) -> None:
self.status_code = status_code
self._payload = payload
self.text = text or repr(payload)
def json(self) -> dict:
return self._payload
class FakeAsyncResponse:
def __init__(self, response: FakeResponse) -> None:
self.status_code = response.status_code
self.text = response.text
self._response = response
def json(self) -> dict:
return self._response.json()
class FakeAsyncClient:
def __init__(self, server: FakeHttpx, timeout: int | None = None) -> None:
self._server = server
self.timeout = timeout
async def __aenter__(self) -> FakeAsyncClient:
return self
async def __aexit__(self, *args) -> None:
return None
async def get(self, url: str) -> FakeAsyncResponse:
return FakeAsyncResponse(self._server.get(url))
async def post(self, url: str, json: dict | None = None) -> FakeAsyncResponse:
return FakeAsyncResponse(self._server.post(url, json))
class FakeHttpx:
"""Minimal fake of the httpx module backed by an in-memory server state.
Behaves like the real Qwen/LM Studio servers: /load and /unload take JSON
bodies, and health/status endpoints reflect the resulting state. Every
request is recorded so tests can assert on URLs and JSON payloads.
"""
def __init__(self) -> None:
self.qwen_model: str | None = None
self.lm_loaded: dict[str, int] = {}
self.requests: list[tuple[str, str, dict | None]] = []
self.qwen_status = "ok"
def get(self, url: str, timeout: int | None = None) -> FakeResponse:
self.requests.append(("GET", url, None))
if "/health" in url:
return FakeResponse(
200,
{
"status": self.qwen_status,
"voicedesign_loaded": self.qwen_model == "voicedesign",
"base_loaded": self.qwen_model == "base",
"current_model": self.qwen_model,
},
)
if "api/v1/models" in url:
models = [
{
"key": "google/gemma-4-e4b",
"loaded_instances": [
{"id": f"{k}:{v}", "config": {"context_length": v}}
for k, v in self.lm_loaded.items()
],
}
]
return FakeResponse(200, {"models": models})
return FakeResponse(404, {})
def post(self, url: str, json: dict | None = None, timeout: int | None = None) -> FakeResponse:
self.requests.append(("POST", url, json))
body = json or {}
if "qwen.test" in url:
if url.endswith("/load"):
self.qwen_model = body["model_type"]
return FakeResponse(200, {"ok": True, "model": body["model_type"]})
if url.endswith("/unload"):
target = body.get("model_type", "all")
if target == "all" or self.qwen_model == target:
self.qwen_model = None
return FakeResponse(200, {"ok": True, "unloaded": target})
elif "lm.test" in url:
if "api/v1/models/load" in url:
self.lm_loaded[body["model"]] = body.get("context_length", 4096)
return FakeResponse(200, {"status": "loaded", "model": body["model"]})
if "api/v1/models/unload" in url:
for key, ctx in list(self.lm_loaded.items()):
if f"{key}:{ctx}" == body["instance_id"]:
del self.lm_loaded[key]
return FakeResponse(200, {"status": "unloaded"})
return FakeResponse(404, {})
def AsyncClient(self, timeout: int | None = None) -> FakeAsyncClient: # noqa: N802
return FakeAsyncClient(self, timeout)
@pytest.fixture
def fake_httpx(monkeypatch) -> FakeHttpx:
server = FakeHttpx()
monkeypatch.setattr(mc, "httpx", server)
return server
@pytest.fixture
def config() -> AppConfig:
return AppConfig(
tts=TTSConfig(qwen_tts_url="http://qwen.test:8881"),
llm=LLMConfig(
lmstudio_base_url="http://lm.test:1234/v1",
lmstudio_model="google/gemma-4-e4b",
),
)
@pytest.fixture
def coord(config: AppConfig) -> ModelCoordinator:
return ModelCoordinator(config)
class TestStageRequirements:
def test_stages_cover_all_pipeline_steps(self) -> None:
assert set(mc.STAGE_REQUIREMENTS) == set(PipelineStage)
def test_voice_design_needs_qwen_voicedesign(self) -> None:
assert mc.STAGE_REQUIREMENTS[PipelineStage.VOICE_DESIGN].qwen_model == "voicedesign"
assert not mc.STAGE_REQUIREMENTS[PipelineStage.VOICE_DESIGN].needs_llm
def test_chapter_analysis_needs_llm_only(self) -> None:
assert mc.STAGE_REQUIREMENTS[PipelineStage.CHAPTER_ANALYSIS].needs_llm
assert mc.STAGE_REQUIREMENTS[PipelineStage.CHAPTER_ANALYSIS].qwen_model is None
def test_utterance_generation_needs_qwen_base(self) -> None:
assert mc.STAGE_REQUIREMENTS[PipelineStage.UTTERANCE_GENERATION].qwen_model == "base"
assert not mc.STAGE_REQUIREMENTS[PipelineStage.UTTERANCE_GENERATION].needs_llm
class TestModelPlaceholderResolution:
def test_placeholder_defaults_to_real_model(self) -> None:
assert mc._resolve_lm_model("local-model") == "google/gemma-4-e4b"
assert mc._resolve_lm_model("") == "google/gemma-4-e4b"
def test_real_model_passes_through(self) -> None:
assert mc._resolve_lm_model("qwen/qwen3.5-9b") == "qwen/qwen3.5-9b"
def test_coordinator_resolves_placeholder(self, config: AppConfig) -> None:
placeholder_config = AppConfig(
tts=config.tts,
llm=LLMConfig(
lmstudio_base_url="http://lm.test:1234/v1",
lmstudio_model="local-model",
),
)
coord = ModelCoordinator(placeholder_config)
assert coord.lm_model == "google/gemma-4-e4b"
class TestSwitchTo:
def test_voice_design_loads_qwen_and_unloads_lm(
self, coord: ModelCoordinator, fake_httpx: FakeHttpx
) -> None:
fake_httpx.lm_loaded = {"google/gemma-4-e4b": 65536}
result = coord.switch_to(PipelineStage.VOICE_DESIGN)
assert result["ok"]
assert result["after"]["voicedesign_loaded"] is True
assert result["after"]["base_loaded"] is False
assert result["after"]["lm_loaded"] == []
def test_chapter_analysis_loads_lm_and_unloads_qwen(
self, coord: ModelCoordinator, fake_httpx: FakeHttpx
) -> None:
fake_httpx.qwen_model = "voicedesign"
result = coord.switch_to(PipelineStage.CHAPTER_ANALYSIS)
assert result["ok"]
assert result["after"]["voicedesign_loaded"] is False
assert result["after"]["base_loaded"] is False
assert result["after"]["lm_loaded"] == ["google/gemma-4-e4b"]
def test_utterance_generation_loads_qwen_base(
self, coord: ModelCoordinator, fake_httpx: FakeHttpx
) -> None:
fake_httpx.lm_loaded = {"google/gemma-4-e4b": 65536}
result = coord.switch_to(PipelineStage.UTTERANCE_GENERATION)
assert result["ok"]
assert result["after"]["base_loaded"] is True
assert result["after"]["voicedesign_loaded"] is False
assert result["after"]["lm_loaded"] == []
def test_idle_unloads_everything(
self, coord: ModelCoordinator, fake_httpx: FakeHttpx
) -> None:
fake_httpx.qwen_model = "voicedesign"
fake_httpx.lm_loaded = {"google/gemma-4-e4b": 65536}
result = coord.switch_to(PipelineStage.IDLE)
assert result["ok"]
assert result["after"]["voicedesign_loaded"] is False
assert result["after"]["base_loaded"] is False
assert result["after"]["lm_loaded"] == []
def test_qwen_load_unload_use_json_body(
self, coord: ModelCoordinator, fake_httpx: FakeHttpx
) -> None:
coord.switch_to(PipelineStage.VOICE_DESIGN)
qwen_posts = [
r for r in fake_httpx.requests if r[0] == "POST" and "qwen.test" in r[1]
]
assert qwen_posts, "expected at least one POST to the Qwen server"
for _, url, body in qwen_posts:
assert "/load" in url or "/unload" in url
assert body is not None
assert body.get("model_type") in ("voicedesign", "base", "all")
def test_lm_load_uses_context_length_for_analysis(
self, coord: ModelCoordinator, fake_httpx: FakeHttpx
) -> None:
coord.switch_to(PipelineStage.CHAPTER_ANALYSIS)
load_post = next(
r for r in fake_httpx.requests if r[1].endswith("api/v1/models/load")
)
assert load_post[2] == {
"model": "google/gemma-4-e4b",
"context_length": 65536,
}
def test_switch_from_analysis_to_generation_swaps_servers(
self, coord: ModelCoordinator, fake_httpx: FakeHttpx
) -> None:
coord.switch_to(PipelineStage.CHAPTER_ANALYSIS)
result = coord.switch_to(PipelineStage.UTTERANCE_GENERATION)
assert result["ok"]
assert result["after"]["base_loaded"] is True
assert result["after"]["lm_loaded"] == []
def test_failed_load_reports_error(
self, coord: ModelCoordinator, fake_httpx: FakeHttpx
) -> None:
fake_httpx.qwen_status = "error"
result = coord.switch_to(PipelineStage.UTTERANCE_GENERATION)
assert not result["ok"]
assert "error" in result
class TestAsyncCoordinator:
@pytest.mark.asyncio
async def test_async_voice_design(
self, config: AppConfig, fake_httpx: FakeHttpx
) -> None:
fake_httpx.lm_loaded = {"google/gemma-4-e4b": 65536}
acoord = AsyncModelCoordinator(config)
result = await acoord.switch_to(PipelineStage.VOICE_DESIGN)
assert result["ok"]
assert result["after"]["voicedesign_loaded"] is True
assert result["after"]["lm_loaded"] == []
@pytest.mark.asyncio
async def test_async_chapter_analysis(
self, config: AppConfig, fake_httpx: FakeHttpx
) -> None:
fake_httpx.qwen_model = "base"
acoord = AsyncModelCoordinator(config)
result = await acoord.switch_to(PipelineStage.CHAPTER_ANALYSIS)
assert result["ok"]
assert result["after"]["voicedesign_loaded"] is False
assert result["after"]["base_loaded"] is False
assert result["after"]["lm_loaded"] == ["google/gemma-4-e4b"]

View File

@@ -0,0 +1,146 @@
from __future__ import annotations
from pathlib import Path
import pytest
from umbraaudio.analysis import CharacterProfile
from umbraaudio.voices.registry import VoiceRegistry
@pytest.mark.asyncio
async def test_connect_creates_db(registry_db_path: Path):
registry = VoiceRegistry(registry_db_path)
await registry.connect()
assert registry_db_path.exists()
await registry.close()
@pytest.mark.asyncio
async def test_find_or_create_new(registry_db_path: Path, character_profile: CharacterProfile):
registry = VoiceRegistry(registry_db_path)
await registry.connect()
reg_id, created = await registry.find_or_create(character_profile, source_project="test")
assert created is True
assert reg_id > 0
await registry.close()
@pytest.mark.asyncio
async def test_find_or_create_duplicate(
registry_db_path: Path, character_profile: CharacterProfile
):
registry = VoiceRegistry(registry_db_path)
await registry.connect()
reg_id1, created1 = await registry.find_or_create(character_profile, source_project="test")
reg_id2, created2 = await registry.find_or_create(character_profile, source_project="test2")
assert created1 is True
assert created2 is False
assert reg_id1 == reg_id2
await registry.close()
@pytest.mark.asyncio
async def test_find_by_name(registry_db_path: Path, character_profile: CharacterProfile):
registry = VoiceRegistry(registry_db_path)
await registry.connect()
reg_id, _ = await registry.find_or_create(character_profile)
found = await registry.find_by_name("John Smith")
assert found is not None
assert found["id"] == reg_id
await registry.close()
@pytest.mark.asyncio
async def test_find_by_alias(registry_db_path: Path, character_profile: CharacterProfile):
registry = VoiceRegistry(registry_db_path)
await registry.connect()
await registry.find_or_create(character_profile)
found = await registry.find_by_name("John")
assert found is not None
assert found["character_name"] == "John Smith"
await registry.close()
@pytest.mark.asyncio
async def test_find_by_unknown_name(registry_db_path: Path):
registry = VoiceRegistry(registry_db_path)
await registry.connect()
found = await registry.find_by_name("Nobody")
assert found is None
await registry.close()
@pytest.mark.asyncio
async def test_update_voice_ref(registry_db_path: Path, character_profile: CharacterProfile):
registry = VoiceRegistry(registry_db_path)
await registry.connect()
reg_id, _ = await registry.find_or_create(character_profile)
await registry.update_voice_ref(reg_id, "/path/to/ref.wav")
found = await registry.find_by_name("John Smith")
assert found is not None
assert found["voice_ref_path"] == "/path/to/ref.wav"
assert found["status"] == "voiced"
await registry.close()
@pytest.mark.asyncio
async def test_update_profile(registry_db_path: Path, character_profile: CharacterProfile):
registry = VoiceRegistry(registry_db_path)
await registry.connect()
reg_id, _ = await registry.find_or_create(character_profile)
updated = CharacterProfile(
name="John Smith",
aliases=["John", "Johnny"],
gender="male",
age_range="adult",
energy="high",
timbre="bright",
)
await registry.update_profile(reg_id, updated)
found = await registry.find_by_name("John Smith")
assert found is not None
assert found["energy"] == "high"
assert found["timbre"] == "bright"
# new alias should be findable
found_by_new = await registry.find_by_name("Johnny")
assert found_by_new is not None
await registry.close()
@pytest.mark.asyncio
async def test_get_all_and_voiced(
registry_db_path: Path, character_profile: CharacterProfile, narrator_profile: CharacterProfile
):
registry = VoiceRegistry(registry_db_path)
await registry.connect()
await registry.find_or_create(character_profile)
await registry.find_or_create(narrator_profile)
all_chars = await registry.get_all()
assert len(all_chars) >= 2
voiced = await registry.get_all_voiced()
assert len(voiced) == 0 # none voiced yet
await registry.close()
@pytest.mark.asyncio
async def test_search(registry_db_path: Path, character_profile: CharacterProfile):
registry = VoiceRegistry(registry_db_path)
await registry.connect()
await registry.find_or_create(character_profile)
results = await registry.search("Smith")
assert len(results) >= 1
assert results[0]["character_name"] == "John Smith"
results_none = await registry.search("xyzzy")
assert len(results_none) == 0
await registry.close()
@pytest.mark.asyncio
async def test_link_to_project(registry_db_path: Path, character_profile: CharacterProfile):
registry = VoiceRegistry(registry_db_path)
await registry.connect()
reg_id, _ = await registry.find_or_create(character_profile)
await registry.link_to_project(reg_id, 1, role_in_book="Protagonist")
await registry.link_to_project(reg_id, 2)
await registry.close()

View File

@@ -0,0 +1,158 @@
from __future__ import annotations
from pathlib import Path
from typing import Any
from umbraaudio.voices.voice_profile_manager import (
build_profile_from_speaker,
get_profile_for_speaker,
)
def _speaker(**overrides: object) -> dict[str, Any]:
base: dict[str, Any] = {
"primary_name": "Kainda",
"aliases": [],
"demographics": {"type": "Human", "gender": "Female", "age": "Unknown", "pitch": ""},
"vocal_traits": [],
"communication_style": {"primary_register": "Casual", "style_shifts_or_notes": ""},
"description": "",
"is_narrator": False,
"accent": "none",
}
base.update(overrides)
return base
def _demographics(speaker: dict[str, Any]) -> dict[str, Any]:
return speaker["demographics"] # type: ignore[no-any-return]
class TestBuildProfileFromSpeaker:
def test_default_infers_pitch_from_gender(self) -> None:
profile = build_profile_from_speaker(_speaker())
assert profile.pitch_register in ("mid_low", "mid", "mid_high", "low", "high")
def test_explicit_pitch_override_wins(self) -> None:
speaker = _speaker()
_demographics(speaker)["pitch"] = "ultra_high"
profile = build_profile_from_speaker(speaker)
assert profile.pitch_register == "ultra_high"
def test_invalid_explicit_pitch_ignored(self) -> None:
speaker = _speaker()
_demographics(speaker)["pitch"] = "baritone-ish"
profile = build_profile_from_speaker(speaker)
assert profile.pitch_register in (
"very_low",
"low",
"mid_low",
"mid",
"mid_high",
"high",
"very_high",
"ultra_high",
)
def test_age_sets_age_range(self) -> None:
speaker = _speaker()
_demographics(speaker)["age"] = "Elderly"
profile = build_profile_from_speaker(speaker)
assert profile.age_range == "Elderly"
def test_unknown_age_leaves_age_range_none(self) -> None:
profile = build_profile_from_speaker(_speaker())
assert profile.age_range is None
def test_child_age_raises_pitch(self) -> None:
speaker = _speaker()
_demographics(speaker)["age"] = "Child"
profile = build_profile_from_speaker(speaker)
assert profile.pitch_register in ("high", "very_high", "ultra_high")
def test_child_age_appears_in_description(self) -> None:
speaker = _speaker()
_demographics(speaker)["age"] = "Child"
profile = build_profile_from_speaker(speaker)
assert "young child" in (profile.voice_description or "")
def test_elderly_pitch_adjusts_when_mid(self) -> None:
speaker = _speaker()
_demographics(speaker)["age"] = "Elderly"
_demographics(speaker)["gender"] = "Male"
profile = build_profile_from_speaker(speaker)
assert profile.pitch_register == "low"
def test_explicit_pitch_beats_age_adjustment(self) -> None:
speaker = _speaker()
_demographics(speaker)["age"] = "Child"
_demographics(speaker)["pitch"] = "very_low"
profile = build_profile_from_speaker(speaker)
assert profile.pitch_register == "very_low"
def test_accent_appears_in_description(self) -> None:
speaker = _speaker(accent="australian")
profile = build_profile_from_speaker(speaker)
assert profile.accent_marker == "australian"
assert "an australian accent" in (profile.voice_description or "")
def test_consonant_accent_uses_proper_article(self) -> None:
speaker = _speaker(accent="british")
profile = build_profile_from_speaker(speaker)
assert "a british accent" in (profile.voice_description or "")
def test_no_accent_omits_accent_phrase(self) -> None:
profile = build_profile_from_speaker(_speaker())
assert profile.accent_marker == "none"
assert "accent" not in (profile.voice_description or "")
def test_accent_survives_force_rebuild(self, temp_dir: Path) -> None:
speaker = _speaker(accent="australian")
profile = get_profile_for_speaker("Kainda", speaker, temp_dir, force_rebuild=True)
assert profile.accent_marker == "australian"
assert "an australian accent" in (profile.voice_description or "")
def test_narrator_uses_speaker_accent(self) -> None:
speaker = _speaker(is_narrator=True, accent="american")
profile = build_profile_from_speaker(speaker)
assert profile.is_narrator is True
assert profile.accent_marker == "american"
assert "an american accent" in (profile.voice_description or "")
def test_narrator_defaults_to_american_accent(self) -> None:
speaker = _speaker(is_narrator=True, accent="none")
profile = build_profile_from_speaker(speaker)
assert profile.accent_marker == "american"
assert "an american accent" in (profile.voice_description or "")
def test_narrator_with_explicit_non_default_accent(self) -> None:
speaker = _speaker(is_narrator=True, accent="british")
profile = build_profile_from_speaker(speaker)
assert profile.accent_marker == "british"
assert "a british accent" in (profile.voice_description or "")
class TestGetProfileForSpeaker:
def test_cached_profile_returned_without_rebuild(self, temp_dir: Path) -> None:
speaker = _speaker()
_demographics(speaker)["pitch"] = "high"
first = get_profile_for_speaker("Kainda", speaker, temp_dir)
assert first.pitch_register == "high"
# Change the speaker data; cache should win without force_rebuild
_demographics(speaker)["pitch"] = "low"
cached = get_profile_for_speaker("Kainda", speaker, temp_dir)
assert cached.pitch_register == "high"
def test_force_rebuild_reflects_speaker_changes(self, temp_dir: Path) -> None:
speaker = _speaker()
_demographics(speaker)["pitch"] = "high"
get_profile_for_speaker("Kainda", speaker, temp_dir)
_demographics(speaker)["pitch"] = "very_low"
rebuilt = get_profile_for_speaker("Kainda", speaker, temp_dir, force_rebuild=True)
assert rebuilt.pitch_register == "very_low"
# Cache file updated for subsequent reads
again = get_profile_for_speaker("Kainda", speaker, temp_dir)
assert again.pitch_register == "very_low"

View File

@@ -0,0 +1,111 @@
from __future__ import annotations
from umbraaudio.web.routes import (
_extract_proper_nouns,
_lowercase_words,
_mid_sentence_counts,
_proper_name_candidates,
_speaker_name_words,
)
_TEXT = (
"Kainda walked in. Princess Kainda Murphy smiled. The Warbots roared. "
"They went there and they left after what happened. "
"Wow, that is odd. Additionally, I have a plan. "
"Captain Weston spoke. Weston is a friend. The ship is the Deal Hunter. "
"Gisfrid arrived. Karel waited. It was a good deal, the captain said to the princess."
)
_SPEAKERS = [
{"primary_name": "Kainda Walters Foster Murphy", "aliases": ["Princess Kainda"]},
{"primary_name": "Captain Weston", "aliases": []},
{"primary_name": "Deal Hunter", "aliases": []},
{"primary_name": "Gisfrid", "aliases": []},
]
def _records() -> dict:
return {
"Kainda": {"phonetic": "KAYN-duh", "source": "user", "confirmed": True, "common": False},
}
def test_extract_proper_nouns_flattens_multiword() -> None:
nouns = {entry["name"]: entry["count"] for entry in _extract_proper_nouns(_TEXT)}
assert "Princess Kainda Murphy" not in nouns
assert "Captain Weston" not in nouns
assert nouns["Kainda"] == 2
assert nouns["Murphy"] == 1
assert nouns["Weston"] == 2
def test_extract_proper_nouns_excludes_titles_and_stopwords() -> None:
nouns = {entry["name"] for entry in _extract_proper_nouns(_TEXT)}
assert "Captain" not in nouns
assert "Princess" not in nouns
assert "The" not in nouns
assert "It" not in nouns
def test_lowercase_words_only_literal_lowercase() -> None:
words = _lowercase_words(_TEXT)
assert "they" in words
assert "after" in words
assert "deal" in words
assert "kainda" not in words
assert "murphy" not in words
def test_mid_sentence_counts() -> None:
counts = _mid_sentence_counts(_TEXT, {"Kainda", "Weston", "Wow", "Gisfrid"})
assert counts["Kainda"] == 1
assert counts["Weston"] >= 1
assert counts.get("Wow", 0) == 0
assert counts.get("Gisfrid", 0) == 0
def test_speaker_name_words_excludes_descriptors() -> None:
words = _speaker_name_words(_SPEAKERS, _lowercase_words(_TEXT))
assert "kainda" in words
assert "murphy" in words
assert "weston" in words
assert "gisfrid" in words
assert "captain" not in words
assert "princess" not in words
def test_proper_name_candidates_keeps_names() -> None:
kept = {
entry["name"]: entry
for entry in _proper_name_candidates(
_extract_proper_nouns(_TEXT), _TEXT, _SPEAKERS, _records()
)
}
for name in ("Kainda", "Murphy", "Warbots", "Weston", "Hunter", "Gisfrid"):
assert name in kept, f"{name} should be kept"
assert kept["Kainda"]["is_speaker"] is True
assert kept["Murphy"]["is_speaker"] is True
assert kept["Gisfrid"]["is_speaker"] is True
def test_proper_name_candidates_drops_prose() -> None:
kept = {
entry["name"]
for entry in _proper_name_candidates(
_extract_proper_nouns(_TEXT), _TEXT, _SPEAKERS, _records()
)
}
for name in ("They", "After", "What", "That", "Wow", "Additionally", "Deal", "Karel"):
assert name not in kept, f"{name} should be dropped"
def test_proper_name_candidates_keeps_confirmed_name() -> None:
records = _records()
records["Karel"] = {"phonetic": "KAH-rel", "source": "user", "confirmed": True, "common": False}
kept = {
entry["name"]
for entry in _proper_name_candidates(
_extract_proper_nouns(_TEXT), _TEXT, _SPEAKERS, records
)
}
assert "Karel" in kept

113
tts_server/README.md Normal file
View File

@@ -0,0 +1,113 @@
# UmbraAudio Qwen3-TTS Server package
Self-contained Qwen3-TTS **server** (our `/v1/tts` + `/v1/voice-prompt` API) for
running the audiobook generation on a CUDA/GPU machine instead of the 16 GB M4
Mac (where MPS inference is prohibitively slow).
All the pipeline code (`QwenTTSEngine`, `ChapterBuilder`) talks to this server
**unchanged** — it exposes the exact same endpoints as the local deployment.
## Contents
- `qwen_tts_server.py` — the FastAPI server (port 8881, `/health`,
`/load`, `/unload`, `/v1/tts`, `/v1/voice-prompt`)
- `install_server.sh` — one-command installer (venv + CUDA torch + deps + models)
- `run_server.sh` — launcher (sets CUDA device, model & prompt dirs)
- `requirements.txt` — pip dependencies
- `sync_voice_data.sh` — bundles voice refs + cached prompts to ship to the GPU box
- `models/` — (created by install/download)
## Install on a Linux/CUDA GPU machine
```bash
# 1. Copy this whole directory to the GPU machine
scp -r tts_server user@gpu:/path/to/
# 2. Install (venv, CUDA torch, qwen-tts, flash-attn)
cd tts_server
./install_server.sh
# 3. Download the 1.7B models (or point QWEN_MODELS_DIR at existing ones)
huggingface-cli download Qwen/Qwen3-TTS-12Hz-1.7B-Base --local-dir models/Qwen3-TTS-12Hz-1.7B-Base
huggingface-cli download Qwen/Qwen3-TTS-12Hz-1.7B-VoiceDesign --local-dir models/Qwen3-TTS-12Hz-1.7B-VoiceDesign
# 4. Ship the voice references + cached ICL prompts (run ON the Mac):
# ./sync_voice_data.sh or ./sync_voice_data.sh user@gpu:/path/to/tts_server/
# (this creates voice_prompts/ on the GPU box with the speaker .pt files)
# 5. Run the server
./run_server.sh
```
## Verify
```bash
curl http://<gpu-ip>:8881/health # {"status":"ok", ...}
curl -X POST http://<gpu-ip>:8881/v1/voice-prompt ... # build a prompt
curl -X POST http://<gpu-ip>:8881/v1/tts ... # generate (prompt_slug)
```
## Point the pipeline at it
The engine already reads the TTS URL from config (`qwen_tts_url`, default
`http://localhost:8881`). Set it to the GPU box, e.g.:
```bash
QWEN_DEVICE=cuda ./run_server.sh # on GPU host, binds 0.0.0.0:8881
# on the Mac, point config qwen_tts_url at http://<gpu-ip>:8881
```
## Env vars
| var | default | purpose |
|-----|---------|---------|
| `QWEN_DEVICE` | `cuda` | `cuda`/`mps`/`cpu` |
| `QWEN_BASE_MODEL` | `models/...-1.7B-Base` | voice-clone / ICL model |
| `QWEN_VOICEDESIGN_MODEL` | `models/...-1.7B-VoiceDesign` | voice-design model |
| `QWEN_PROMPT_DIR` | `./voice_prompts` | cached ICL `.pt` prompts |
| `QWEN_ATTN` | `sdpa` | `sdpa` (fast, built-in) / `eager` (slow) / `flash_attention_2` |
| `QWEN_MAX_GEN_S` | `1800` | watchdog: if a generation hangs this long, the server auto-restarts itself |
| `QWEN_PORT` | `8881` | listen port |
| `QWEN_HOST` | `0.0.0.0` | bind address |
| `QWEN_PROMPT_DIR` | `./voice_prompts` | cached prompts |
> Flash-attn is recommended but not required. With no flash-attn the server sets
> `attn_implementation="sdpa"` (the default) for generation, which is fast and
> light on VRAM and requires no compilation. `eager` is pathologically slow for
> Qwen3-TTS generation (~20x) — but note that on CUDA the `create_voice_clone_prompt`
> extraction pass is NOT reliable under `sdpa` (it can hang the model). So use the
> two-phase pattern below rather than leaving `QWEN_ATTN` as a fixed env value.
## Runtime attention switching (no restart)
`attn_implementation` is fixed at model-load time, but you can switch it live
without restarting the process via the `/attn` endpoint — this unloads the
model(s) and reloads `base` with the new implementation:
```bash
curl http://HOST:8881/attn # -> {"impl":"sdpa"}
curl -X POST http://HOST:8881/attn -H 'Content-Type: application/json' \
-d '{"impl":"eager"}' # reload model with eager
```
**Recommended two-phase build (avoids the sdpa extraction hang):**
1. **Phase 1 — extract prompts under `eager`** (fast + stable): switch `/attn` to
`eager`, then call `POST /v1/voice-prompt` once per speaker to build each
`<slug>.pt`. `/v1/voice-prompt` is idempotent — it returns the cached prompt
without re-extracting if `<slug>.pt` already exists.
2. **Phase 2 — generate under `sdpa`**: switch `/attn` back to `sdpa`, then run
the chapter batch. `build_voice_prompt` calls now hit the idempotency short
circuit (no extraction, no wedge) and `/v1/tts?prompt_slug=...` generates from
the cached prompt quickly and reliably.
## Crash / hang recovery
A CUDA kernel error (e.g. OOM) can poison the GPU context: `/health` keeps
returning OK but every generation request hangs forever. The server ships with a
**hang watchdog**: if a generation (or prompt extraction) runs past
`QWEN_MAX_GEN_S` (default 1800s = 30min), it relaunches itself via `os.execv`,
clearing the poisoned context. No SSH needed — it self-heals. If the watchdog
fires mid-batch, the client's own retry logic (health polling) reconnects once
the process is back up.

69
tts_server/install_server.sh Executable file
View File

@@ -0,0 +1,69 @@
#!/usr/bin/env bash
# Install the UmbraAudio Qwen3-TTS server on a Linux/CUDA GPU machine.
#
# Usage:
# ./install_server.sh
#
# Assumes: python3.12 available, CUDA GPU present, ~15GB free disk for models.
#
# After install, run: ./run_server.sh
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
PY=python3.12
if ! command -v "$PY" >/dev/null 2>&1; then
PY=python3
fi
echo "Using Python: $($PY --version)"
if [ ! -d .venv ]; then
echo ">> Creating virtualenv (.venv)"
"$PY" -m venv .venv
fi
source .venv/bin/activate
echo ">> Upgrading pip"
pip install -U pip wheel setuptools
echo ">> Detecting CUDA"
CUDA_VER=""
if nvidia-smi >/dev/null 2>&1; then
# Prefer cu12x wheels depending on available toolkit
nvcc --version 2>/dev/null | grep release || true
CUDA_VER="cu124"
fi
echo ">> Installing PyTorch (GPU)"
if [ -n "$CUDA_VER" ]; then
pip install torch torchaudio --index-url "https://download.pytorch.org/whl/$CUDA_VER"
else
echo "!! No CUDA detected. Installing CPU torch (will be slow)."
pip install torch torchaudio
fi
echo ">> Installing qwen-tts server dependencies"
pip install -r requirements.txt
echo ">> (Recommended) Installing flash-attn for faster inference + lower VRAM"
pip install -U flash-attn --no-build-isolation || \
echo "!! flash-attn build failed (ok to continue, just slower)."
MODELS_DIR="${QWEN_MODELS_DIR:-$SCRIPT_DIR/models}"
TOKENIZER_DIR="${QWEN_TOKENIZER_DIR:-$MODELS_DIR/Qwen3-TTS-Tokenizer-12Hz}"
mkdir -p "$MODELS_DIR"
echo ">> Model directories (set QWEN_MODELS_DIR to override)"
echo " Models will be stored in: $MODELS_DIR"
echo " If the 1.7B models are already on this machine, set QWEN_MODELS_DIR"
echo " to point at them; otherwise download them with:"
echo ""
echo " huggingface-cli download Qwen/Qwen3-TTS-12Hz-1.7B-Base --local-dir $MODELS_DIR/Qwen3-TTS-12Hz-1.7B-Base"
echo " huggingface-cli download Qwen/Qwen3-TTS-12Hz-1.7B-VoiceDesign --local-dir $MODELS_DIR/Qwen3-TTS-12Hz-1.7B-VoiceDesign"
echo " huggingface-cli download Qwen/Qwen3-TTS-Tokenizer-12Hz --local-dir $TOKENIZER_DIR"
echo ""
echo ">> Install complete."
echo " Next: run ./run_server.sh"
echo " Health: curl http://localhost:8881/health"

View File

@@ -0,0 +1,535 @@
#!/usr/bin/env python3
"""Qwen3-TTS FastAPI Server for UmbraAudio.
Provides REST endpoints for voice design and voice cloning.
Expected by umbraaudio.voices.qwen_tts.QwenTTSEngine.
"""
from __future__ import annotations
import asyncio
import io
import json
import logging
import os
import time
import anyio
import numpy as np
import soundfile as sf
import torch
from dataclasses import asdict
from fastapi import FastAPI, File, Form, HTTPException, UploadFile
from fastapi.responses import Response
from pathlib import Path
from pydantic import BaseModel
from qwen_tts import Qwen3TTSModel
from qwen_tts.inference.qwen3_tts_model import VoiceClonePromptItem
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
# Directory where precomputed voice-clone prompts (the "8k matrix" ref_code +
# speaker embedding) are cached, one .pt file per speaker slug. Populated by
# POST /v1/voice-prompt and consumed by /v1/tts via the prompt_slug field so
# every utterance for a speaker reuses the exact same voice identity.
PROMPT_DIR = Path(
os.getenv(
"QWEN_PROMPT_DIR",
"/Volumes/1TB_APL_Storage/UmbraAudio/DealHunterShip_4.0e_whole-to-audio/.umbra/voice_prompts",
)
)
app = FastAPI(title="Qwen3-TTS Server", version="0.1.0")
async def _watchdog_loop() -> None:
"""Background task: if a generation has been stuck past MAX_GEN, restart."""
while True:
await asyncio.sleep(min(_MAX_GEN, 30))
s = _gen_start_at
if s is not None and (time.monotonic() - s) > _MAX_GEN:
_restart_server(
f"generation hung >{_MAX_GEN:.0f}s (poisoned CUDA context?)"
)
await asyncio.sleep(3600) # never reached after execv
@app.on_event("startup")
async def _start_watchdog() -> None:
app.state._wd_task = asyncio.create_task(_watchdog_loop())
# Rainbow Passage — used as default ref_text for ICL voice cloning mode.
# All voice refs were generated with this text via Edge-TTS.
VOICE_REFERENCE_TEXT = (
"When the sunlight strikes raindrops in the air, they act as a prism and form a rainbow. "
"The rainbow is a division of white light into many beautiful colors. "
"These take the shape of a long round arch, with its path high above, "
"and its two ends apparently beyond the horizon. "
"There is, according to legend, a boiling pot of gold at one end. "
"People look, but no one ever finds it. "
"When a man looks for something beyond his reach, "
"his friends say he is looking for the pot of gold at the end of the rainbow. "
"When the sunlight strikes raindrops in the air, they act as a prism and form a rainbow. "
"The rainbow is a division of white light into many beautiful colors. "
"These take the shape of a long round arch, with its path high above, "
"and its two ends apparently beyond the horizon. "
"There is, according to legend, a boiling pot of gold at one end. "
"People look, but no one ever finds it. "
"When a man looks for something beyond his reach, "
"his friends say he is looking for the pot of gold at the end of the rainbow."
)
# Global model state
_vd_model: Qwen3TTSModel | None = None
_base_model: Qwen3TTSModel | None = None
_current_model: str | None = None # "voicedesign" or "base"
# Serializes all model access. Model loads and generations are blocking CPU/GPU
# calls; they run in worker threads (anyio.to_thread) under this lock so the
# event loop stays responsive and concurrent /v1/tts requests cannot wedge it.
_model_lock = asyncio.Lock()
# Hang watchdog. On CUDA, a kernel error (e.g. OOM) can poison the context and
# wedge the generation thread forever while the event loop stays responsive
# (health works, every /v1/tts hangs). The only safe recovery is to restart the
# process. We track when a generation started and, if it exceeds MAX_GEN_SECONDS,
# we relaunch ourselves via os.execv to get a clean CUDA context.
_MAX_GEN = float(os.getenv("QWEN_MAX_GEN_S", "1800"))
_gen_start_at: float | None = None # time.monotonic() when a generation began
def _mark_gen_started() -> None:
global _gen_start_at
_gen_start_at = time.monotonic()
def _mark_gen_done() -> None:
global _gen_start_at
_gen_start_at = None
def _restart_server(reason: str) -> None:
"""Relaunch the current process (os.execv) to clear a wedged GPU context."""
import sys as _sys
logger.warning("Restarting TTS server: %s", reason)
try:
for s in (_sys.stdout, _sys.stderr):
try:
s.flush()
except Exception:
pass
except Exception:
pass
try:
_sys.stdout = open(os.devnull, "w")
_sys.stderr = open(os.devnull, "w")
_sys.stdin = open(os.devnull)
os.execv(
_sys.executable,
[_sys.executable, os.path.abspath(__file__), *list(_sys.argv[1:])],
)
except Exception as e: # pragma: no cover
logger.error("os.execv failed: %s", e)
raise SystemExit(1)
# Model paths from environment
VOICEDESIGN_PATH = os.getenv(
"QWEN_VOICEDESIGN_MODEL",
"/Volumes/1TB_APL_Storage/UmbraAudio/models/Qwen3-TTS-12Hz-1.7B-VoiceDesign",
)
BASE_PATH = os.getenv(
"QWEN_BASE_MODEL",
"/Volumes/1TB_APL_Storage/UmbraAudio/models/Qwen3-TTS-12Hz-1.7B-Base",
)
DEVICE = os.getenv("QWEN_DEVICE", "mps")
DTYPE = torch.bfloat16 if DEVICE in ("cuda", "mps") else torch.float32
# Attention implementation. "eager" is very slow for Qwen3-TTS autoregressive
# codec decoding but is the only one that reliably runs create_voice_clone_prompt
# (prompt extraction). "sdpa" (built into torch) is far faster for generation.
# Runtime-switchable via POST /attn (reloads the model); this is read at model
# load time, so switching reloads the active model with the new implementation.
_ATTN_IMPL: str = os.getenv("QWEN_ATTN", "sdpa")
class LoadRequest(BaseModel):
model_type: str # "voicedesign" or "base"
class GenerateRequest(BaseModel):
text: str
sample_rate: int = 24000
voice_description: str | None = None
def _load_model(model_type: str) -> Qwen3TTSModel:
"""Load a model, unloading the other if needed."""
global _vd_model, _base_model, _current_model
if _current_model == model_type:
return _vd_model if model_type == "voicedesign" else _base_model
# Unload current
if _current_model == "voicedesign" and _vd_model is not None:
del _vd_model
_vd_model = None
elif _current_model == "base" and _base_model is not None:
del _base_model
_base_model = None
if torch.mps.is_available() or torch.cuda.is_available():
torch.mps.empty_cache() if torch.mps.is_available() else torch.cuda.empty_cache()
# Load requested
path = VOICEDESIGN_PATH if model_type == "voicedesign" else BASE_PATH
logger.info(f"Loading {model_type} model from {path}")
model = Qwen3TTSModel.from_pretrained(
path,
device_map=DEVICE,
dtype=DTYPE,
attn_implementation=_ATTN_IMPL,
)
if model_type == "voicedesign":
_vd_model = model
else:
_base_model = model
_current_model = model_type
logger.info(f"{model_type} model loaded successfully")
return model
def _prompt_path(slug: str) -> Path:
safe = Path(slug).name
PROMPT_DIR.mkdir(parents=True, exist_ok=True)
return PROMPT_DIR / f"{safe}.pt"
def _save_prompt(slug: str, items: list[VoiceClonePromptItem]) -> Path:
path = _prompt_path(slug)
payload = {"items": [asdict(it) for it in items]}
torch.save(payload, str(path))
logger.info("Saved ICL voice prompt for %s to %s", slug, path)
return path
def _load_prompt_items(slug: str) -> list[VoiceClonePromptItem] | None:
path = _prompt_path(slug)
if not path.is_file():
return None
try:
payload = torch.load(str(path), map_location="cpu", weights_only=True)
except Exception as e:
logger.warning("Failed to load prompt %s: %s", slug, e)
return None
raw_items = payload.get("items", [])
items: list[VoiceClonePromptItem] = []
for d in raw_items:
ref_code = d.get("ref_code")
if ref_code is not None and not torch.is_tensor(ref_code):
ref_code = torch.tensor(ref_code)
ref_spk = d.get("ref_spk_embedding")
if ref_spk is None:
logger.warning("Prompt %s missing ref_spk_embedding", slug)
return None
if not torch.is_tensor(ref_spk):
ref_spk = torch.tensor(ref_spk)
items.append(
VoiceClonePromptItem(
ref_code=ref_code,
ref_spk_embedding=ref_spk,
x_vector_only_mode=bool(d.get("x_vector_only_mode", False)),
icl_mode=bool(d.get("icl_mode", not bool(d.get("x_vector_only_mode", False)))),
ref_text=d.get("ref_text"),
)
)
return items
@app.post("/v1/voice-prompt")
async def build_voice_prompt(
slug: str = Form(...),
ref_text: str | None = Form(None),
ref_audio: UploadFile | None = File(None),
x_vector_only: int = Form(0),
force: int = Form(0),
):
"""Build and cache a speaker's digital voice (a set of numbers) once.
Two capture modes (pick per pipeline; a speaker's cached prompt is used as-is
on every subsequent utterance):
* x_vector_only=0 (default, ICL/"8k matrix"): extracts the FULL codec-token
matrix (ref_code) + speaker embedding. Together those let the generator
re-create the exact reference recording -- and every utterance therefore
re-speaks the reference first, then trims it (slow + leaky).
* x_vector_only=1: captures ONLY the speaker embedding (the digital voice
variables). ref_code is None. Generation then feeds just those numbers +
text into the speech generator -- no reference re-hash, no clone re-run,
fast and output is exactly the text.
Idempotent: if `<slug>.pt` is already cached it returns without rebuilding,
unless `force=1` is passed (use force to switch an existing prompt mode).
"""
path = _prompt_path(slug)
if path.exists() and not force:
return {"ok": True, "slug": slug, "cached": True, "path": str(path)}
if ref_audio is None:
raise HTTPException(400, "ref_audio is required to build a voice prompt")
ref_bytes = await ref_audio.read()
ref_text = (ref_text or "").strip() or VOICE_REFERENCE_TEXT
xvec = bool(x_vector_only)
def _build() -> list[VoiceClonePromptItem]:
model = _load_model("base")
ref_audio_array, ref_sr = sf.read(io.BytesIO(ref_bytes), dtype="float32")
if ref_sr != 24000:
import librosa
ref_audio_array = librosa.resample(
ref_audio_array, orig_sr=ref_sr, target_sr=24000
)
items = model.create_voice_clone_prompt(
ref_audio=(ref_audio_array, 24000),
ref_text=ref_text,
x_vector_only_mode=xvec,
)
return items
_mark_gen_started()
async with _model_lock:
items = await anyio.to_thread.run_sync(_build)
_mark_gen_done()
_save_prompt(slug, items)
return {"ok": True, "slug": slug, "path": str(_prompt_path(slug))}
@app.get("/health")
async def health():
return {
"status": "ok",
"voicedesign_loaded": _vd_model is not None,
"base_loaded": _base_model is not None,
"current_model": _current_model,
"device": DEVICE,
}
@app.post("/load")
async def load_model(request: LoadRequest):
if request.model_type not in ("voicedesign", "base"):
raise HTTPException(400, "model_type must be 'voicedesign' or 'base'")
try:
async with _model_lock:
await anyio.to_thread.run_sync(_load_model, request.model_type)
return {"ok": True, "model": request.model_type}
except Exception as e:
logger.exception("Failed to load model")
raise HTTPException(500, f"Failed to load model: {e}") from e
def _unload_model(model_type: str) -> None:
global _vd_model, _base_model, _current_model
if model_type in ("voicedesign", "all") and _vd_model is not None:
del _vd_model
_vd_model = None
if model_type in ("base", "all") and _base_model is not None:
del _base_model
_base_model = None
if model_type == "all" or _current_model == model_type:
_current_model = None
if torch.mps.is_available() or torch.cuda.is_available():
torch.mps.empty_cache() if torch.mps.is_available() else torch.cuda.empty_cache()
@app.post("/unload")
async def unload_model(request: LoadRequest):
if request.model_type not in ("voicedesign", "base", "all"):
raise HTTPException(400, "model_type must be 'voicedesign', 'base', or 'all'")
async with _model_lock:
await anyio.to_thread.run_sync(_unload_model, request.model_type)
return {"ok": True, "unloaded": request.model_type}
class AttnRequest(BaseModel):
impl: str # "eager", "sdpa", "fluent", "flash_attention_2"
@app.get("/attn")
async def get_attn():
return {"ok": True, "impl": _ATTN_IMPL}
@app.post("/attn")
async def set_attn(request: AttnRequest):
"""Switch the attention implementation and reload the active model.
The attention implementation is fixed at model-load time, so this unloads
the current model(s) and reloads `base` with the new implementation. Callers
should switch to ``eager`` for prompt extraction (Phase 1) and ``sdpa`` for
generation (Phase 2) without a process restart.
"""
global _ATTN_IMPL
impl = (request.impl or "").strip().lower()
if impl not in ("eager", "sdpa", "fluent", "flash_attention_2"):
raise HTTPException(400, "impl must be eager|sdpa|fluent|flash_attention_2")
async with _model_lock:
try:
await anyio.to_thread.run_sync(_unload_model, "all")
_ATTN_IMPL = impl
# reload so subsequent use has the new attn; base is the main model
await anyio.to_thread.run_sync(_load_model, "base")
except Exception as e:
logger.exception("Failed to switch attn to %s", impl)
raise HTTPException(500, f"Failed to switch attn: {e}") from e
return {"ok": True, "impl": _ATTN_IMPL}
@app.post("/v1/tts")
async def generate_tts(
text: str = Form(...),
sample_rate: int = Form(24000),
voice_description: str | None = Form(None),
ref_audio: UploadFile | None = File(None),
ref_text: str | None = Form(None),
speaker_embedding: str | None = Form(None),
prompt_slug: str | None = Form(None),
temperature: float = Form(0.9),
do_sample: bool = Form(True),
seed: int | None = Form(None),
):
"""Generate TTS audio.
- voice_description + text -> VoiceDesign generation
- ref_audio + text + ref_text -> VoiceClone generation
- speaker_embedding + text -> Direct embedding generation (fastest, most consistent)
- prompt_slug -> ICL generation from cached voice-clone prompt (most faithful &
consistent; the "8k matrix" built by POST /v1/voice-prompt)
prompt_slug takes priority: if the cached prompt exists it is used, giving
the speaker the identical reference voice on every utterance.
"""
gen_kwargs: dict = {"temperature": temperature, "do_sample": do_sample}
if seed is not None:
torch.manual_seed(seed)
if torch.cuda.is_available():
torch.cuda.manual_seed_all(seed)
_mark_gen_started()
async with _model_lock:
if prompt_slug:
items = _load_prompt_items(prompt_slug)
if items is None:
raise HTTPException(
404, f"No cached voice prompt for slug '{prompt_slug}'"
)
def _icl() -> tuple[np.ndarray, int]:
model = _load_model("base")
wavs, sr = model.generate_voice_clone(
text=text,
voice_clone_prompt=items,
language="english",
**gen_kwargs,
)
return wavs, sr
wavs, sr = await anyio.to_thread.run_sync(_icl)
elif speaker_embedding is not None:
emb_list = json.loads(speaker_embedding)
emb_np = np.array(emb_list, dtype=np.float32)
spk_tensor = torch.from_numpy(emb_np)
def _embed() -> tuple[np.ndarray, int]:
model = _load_model("base")
device = next(model.model.parameters()).device
dtype = next(model.model.parameters()).dtype
spk = spk_tensor.to(device).to(dtype)
wavs, sr = model.generate_voice_clone(
text=text,
voice_clone_prompt={
"ref_code": [None],
"ref_spk_embedding": [spk],
"x_vector_only_mode": [True],
"icl_mode": [False],
},
language="english",
**gen_kwargs,
)
return wavs, sr
wavs, sr = await anyio.to_thread.run_sync(_embed)
elif ref_audio is not None:
ref_bytes = await ref_audio.read()
def _clone() -> tuple[np.ndarray, int]:
model = _load_model("base")
ref_audio_array, ref_sr = sf.read(io.BytesIO(ref_bytes), dtype="float32")
if ref_sr != sample_rate:
import librosa
ref_audio_array = librosa.resample(
ref_audio_array, orig_sr=ref_sr, target_sr=sample_rate
)
wavs, sr = model.generate_voice_clone(
text=text,
ref_audio=(ref_audio_array, sample_rate),
ref_text=None,
x_vector_only_mode=True,
language="english",
**gen_kwargs,
)
return wavs, sr
wavs, sr = await anyio.to_thread.run_sync(_clone)
elif voice_description:
def _design() -> tuple[np.ndarray, int]:
model = _load_model("voicedesign")
wavs, sr = model.generate_voice_design(
text=text,
instruct=voice_description,
language="english",
**gen_kwargs,
)
return wavs, sr
wavs, sr = await anyio.to_thread.run_sync(_design)
else:
raise HTTPException(400, "Either voice_description or ref_audio required")
_mark_gen_done()
if not wavs:
raise HTTPException(500, "Model returned no audio")
# Return WAV as binary
wav_bytes = io.BytesIO()
sf.write(wav_bytes, wavs[0], sr, format="WAV")
wav_bytes.seek(0)
return Response(
content=wav_bytes.read(),
media_type="audio/wav",
headers={"Content-Disposition": "attachment; filename=output.wav"},
)
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8881)

View File

@@ -0,0 +1,23 @@
# Qwen3-TTS server runtime for the UmbraAudio pipeline
# Python 3.12, Linux/CUDA (GPU) target.
#
# On Linux, install a CUDA build of PyTorch FIRST (see install_server.sh),
# then this file.
qwen-tts==0.1.1
transformers==4.57.3
accelerate==1.12.0
gradio
librosa
torchaudio
soundfile
sox
onnxruntime
einops
# Server runtime
fastapi
uvicorn
pydantic
anyio
httpx
numpy

37
tts_server/run_server.sh Executable file
View File

@@ -0,0 +1,37 @@
#!/usr/bin/env bash
# Launch the UmbraAudio Qwen3-TTS server (CUDA/GPU).
#
# Config via env vars (override as needed):
# QWEN_MODELS_DIR - dir holding the 1.7B model subdirs (default ./models)
# QWEN_PROMPT_DIR - dir of cached ICL voice prompts (default ./voice_prompts)
# QWEN_DEVICE - cuda | mps | cpu (default cuda)
# QWEN_PORT - listen port (default 8881)
# QWEN_HOST - bind address (default 0.0.0.0)
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
export QWEN_MODELS_DIR="${QWEN_MODELS_DIR:-$SCRIPT_DIR/models}"
export QWEN_PROMPT_DIR="${QWEN_PROMPT_DIR:-$SCRIPT_DIR/voice_prompts}"
export QWEN_DEVICE="${QWEN_DEVICE:-cuda}"
export QWEN_BASE_MODEL="${QWEN_BASE_MODEL:-$QWEN_MODELS_DIR/Qwen3-TTS-12Hz-1.7B-Base}"
export QWEN_VOICEDESIGN_MODEL="${QWEN_VOICEDESIGN_MODEL:-$QWEN_MODELS_DIR/Qwen3-TTS-12Hz-1.7B-VoiceDesign}"
PORT="${QWEN_PORT:-8881}"
HOST="${QWEN_HOST:-0.0.0.0}"
printf 'Device: %s\nBase: %s\nVoice: %s\nPrompts:%s\nPort: %s\n' \
"$QWEN_DEVICE" "$QWEN_BASE_MODEL" "$QWEN_VOICEDESIGN_MODEL" \
"$QWEN_PROMPT_DIR" "$PORT"
# Verify model dirs exist
for d in "$QWEN_BASE_MODEL" "$QWEN_VOICEDESIGN_MODEL"; do
if [ ! -d "$d" ]; then
echo "!! Missing model dir: $d"
echo " Download it (see install_server.sh) or set QWEN_MODELS_DIR."
fi
done
mkdir -p "$QWEN_PROMPT_DIR" "$QWEN_MODELS_DIR"
exec .venv/bin/python qwen_tts_server.py --port "$PORT" --host "$HOST"

38
tts_server/sync_voice_data.sh Executable file
View File

@@ -0,0 +1,38 @@
#!/usr/bin/env bash
# Package the voice reference wavs + cached ICL voice prompts (.pt) so they can
# be copied to the target TTS server machine (GPU). The remote ChapterBuilder /
# server uses these to generate every utterance with each speaker's identical
# voice identity.
#
# Usage (run ON THIS Mac):
# ./sync_voice_data.sh [target_host:dir]
# - with no args: writes a tarball ./voice_data.tar.gz you can scp manually
# - with a scp-style target (user@host:/path): copies it there and extracts
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
PROJ="/Volumes/1TB_APL_Storage/UmbraAudio/DealHunterShip_4.0e_whole-to-audio/.umbra"
TARBALL="$SCRIPT_DIR/voice_data.tar.gz"
TMP="$(mktemp -d)"
mkdir -p "$TMP/voices" "$TMP/voice_prompts"
echo ">> Bundling voice references + cached prompts"
cp "$PROJ"/voices/*_ref.wav "$TMP/voices/" 2>/dev/null || echo " no ref wavs"
cp "$PROJ"/voice_prompts/*.pt "$TMP/voice_prompts/" 2>/dev/null || echo " no cached prompts"
echo " voices: $(ls "$TMP/voices" | wc -l | tr -d ' ') files"
echo " prompts: $(ls "$TMP/voice_prompts" | wc -l | tr -d ' ') files"
tar -czf "$TARBALL" -C "$TMP" voices voice_prompts
rm -rf "$TMP"
ls -lh "$TARBALL" | awk '{print ">> Wrote", $9, "("$5")"}'
if [ $# -ge 1 ]; then
target="$1"
echo ">> Copying + extracting to $target"
scp "$TARBALL" "$target:"
ssh "${target%:*}" "mkdir -p voice_prompts && tar -xzf voice_data.tar.gz && mv voices/* voice_prompts/ 2>/dev/null; echo done"
fi

170
umbra_package/setup_env.sh Executable file
View File

@@ -0,0 +1,170 @@
#!/usr/bin/env bash
# UmbraAudio Environment Setup Script
# This script sets up the Python environment, installs dependencies,
# and configures paths for the UmbraAudio audiobook generation system.
set -euo pipefail
echo "=== UmbraAudio Environment Setup ==="
echo ""
# --- Configuration ---
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
UMBRE_DIR="$SCRIPT_DIR" # The directory containing this script (umbra_package/)
PROJECT_DIR="$(dirname "$UMBRE_DIR")" # Parent should be the UmbraAudio project root
# Allow override via environment
if [ -z "${UMBRE_PROJECT_DIR:-}" ]; then
export UMBRA_PROJECT_DIR="${UMBRA_PROJECT_DIR:-$PROJECT_DIR}"
fi
export UMBRA_DATA_DIR="$UMBRA_PROJECT_DIR/.umbra"
echo "Project directory: $UMBRA_PROJECT_DIR"
echo "Data directory: $UMBRA_DATA_DIR"
echo ""
# --- Verify .umbra exists ---
if [ ! -d "$UMBRA_DATA_DIR" ]; then
echo "ERROR: .umbra directory not found at $UMBRA_DATA_DIR"
echo "Please ensure the project was extracted correctly."
exit 1
fi
# --- Python version check ---
PYTHON_MIN_MAJOR=3
PYTHON_MIN_MINOR=11
PYTHON_BIN="${PYTHON:-python3}"
PY_VERSION=$($PYTHON_BIN -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')
PY_MAJOR=$($PYTHON_BIN -c 'import sys; print(sys.version_info.major)')
PY_MINOR=$($PYTHON_BIN -c 'import sys; print(sys.version_info.minor)')
echo "Python: $PY_VERSION"
if [ "$PY_MAJOR" -lt "$PYTHON_MIN_MAJOR" ] || ([ "$PY_MAJOR" -eq "$PYTHON_MIN_MAJOR" ] && [ "$PY_MINOR" -lt "$PYTHON_MIN_MINOR" ]); then
echo "ERROR: Python $PYTHON_MIN_MAJOR.$PYTHON_MIN_MINOR+ required, found $PY_VERSION"
exit 1
fi
echo ""
# --- Check for GPU ---
echo "=== Checking for NVIDIA GPU ==="
if command -v nvidia-smi &>/dev/null; then
GPU_INFO=$(nvidia-smi --query-gpu=name,memory.total --format=csv,noheader 2>/dev/null || echo "Unknown GPU")
echo "GPU detected: $GPU_INFO"
export UMBRA_TTS__DEVICE=cuda
else
echo "WARNING: No NVIDIA GPU detected. TTS will use CPU (slow)."
echo "Consider installing NVIDIA drivers and CUDA for hardware acceleration."
export UMBRA_TTS__DEVICE=cpu
fi
echo ""
# --- Set up virtual environment ---
VENV_DIR="$UMBRA_PROJECT_DIR/.venv"
echo "=== Setting up virtual environment ==="
if [ ! -d "$VENV_DIR" ]; then
$PYTHON_BIN -m venv "$VENV_DIR"
echo "Created virtual environment at $VENV_DIR"
else
echo "Virtual environment already exists at $VENV_DIR"
fi
# Activate venv
source "$VENV_DIR/bin/activate"
echo "Activated virtual environment"
echo ""
# --- Install dependencies ---
echo "=== Installing Python dependencies ==="
cd "$PROJECT_DIR"
pip install --upgrade pip 2>&1 | tail -1
pip install -e ".[dev]" 2>&1 | tail -5
echo ""
# --- Check for ffmpeg ---
echo "=== Checking ffmpeg ==="
if command -v ffmpeg &>/dev/null; then
echo "ffmpeg found: $(ffmpeg -version 2>&1 | head -1)"
else
echo "WARNING: ffmpeg not found. Audio processing tools may not work."
echo "Install with: sudo apt install ffmpeg (Ubuntu/Debian)"
echo " or: brew install ffmpeg (macOS)"
fi
echo ""
# --- Check for model files ---
echo "=== Checking for model files ==="
QWEN_MODEL_PATH="${UMBRA_TTS__VOICEDESIGN_MODEL:-/mnt/models/qwen3-tts/Qwen3-TTS-12Hz-1.7B-VoiceDesign}"
BASE_MODEL_PATH="${UMBRA_TTS__BASE_MODEL:-/mnt/models/qwen3-tts/Qwen3-TTS-12Hz-1.7B-Base}"
if [ -d "$(dirname "$QWEN_MODEL_PATH")" ]; then
echo "Qwen TTS models directory found: $(dirname "$QWEN_MODEL_PATH")"
if [ -d "$QWEN_MODEL_PATH" ]; then
echo " VoiceDesign model: OK"
else
echo " VoiceDesign model: MISSING"
fi
if [ -d "$BASE_MODEL_PATH" ]; then
echo " Base model: OK"
else
echo " Base model: MISSING"
fi
else
echo "Qwen TTS models NOT found at $(dirname "$QWEN_MODEL_PATH")"
echo " You need to download Qwen3-TTS models to this path."
echo " Set UMBRA_TTS__VOICEDESIGN_MODEL and UMBRA_TTS__BASE_MODEL to override."
fi
echo ""
# --- LM Studio check ---
echo "=== Checking LM Studio (LLM server) ==="
if curl -s http://localhost:1234/v1/models &>/dev/null; then
echo "LM Studio server is running on http://localhost:1234/v1"
echo "Available models:"
curl -s http://localhost:1234/v1/models | python3 -c "
import json, sys
data = json.load(sys.stdin)
for m in data.get('data', []):
print(f' - {m[\"id\"]}')
" 2>/dev/null || echo " (could not list models)"
else
echo "LM Studio server NOT running on http://localhost:1234/v1"
echo " Start LM Studio and load a model (e.g., google/gemma-4-e4b)"
fi
echo ""
# --- Qwen TTS server check ---
echo "=== Checking Qwen TTS server ==="
if curl -s "http://localhost:8881" &>/dev/null; then
echo "Qwen TTS server is running on http://localhost:8881"
else
echo "Qwen TTS server NOT running on http://localhost:8881"
echo " To start Qwen TTS server:"
echo " 1. Install qwen-tts: pip install qwen-tts"
echo " 2. Start server with appropriate model"
fi
echo ""
# --- Web UI instructions ---
echo "=== Web UI ==="
echo " Start with:"
echo " python -c \\"from pathlib import Path; from umbraaudio.web import create_app; from umbraaudio.config import load_config; import uvicorn; config = load_config(Path('.')); app = create_app(config); uvicorn.run(app, host='0.0.0.0', port=8420)\\""
echo ""
# --- Summary ---
echo "=== Setup Complete! ==="
echo ""
echo "Quick Start Commands:"
echo " 1. python -m umbraaudio.cli # Interactive CLI"
echo " 2. python scripts/resegment_all.py --mechanical-only # Re-segment chapters"
echo " 3. python scripts/reattribute_all.py --model google/gemma-4-e4b # LLM speaker attribution"
echo " 4. pytest tests/ -v # Run tests"
echo ""
echo "Environment variables (set in .env or override as needed):"
echo " UMBRA_PROJECT_DIR - Project root path (default: $PROJECT_DIR)"
echo " UMBRA_DATA_DIR - Data directory (default: .umbra)"
echo " UMBRA_TTS__DEVICE - cuda or cpu"
echo " UMBRA_TTS__ENGINE - qwen, edge, or chatterbox"
echo " UMBRA_LLM__LMSTUDIO_BASE_URL - LM Studio URL"
echo " UMBRA_LLM__LMSTUDIO_MODEL - LM model name"
echo ""
echo "To activate this environment later, run:"
echo " source $UMBRA_PROJECT_DIR/.venv/bin/activate"

1
umbraaudio/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""UmbraAudio — Quality-first multi-pass audiobook generation from manuscripts."""

View File

@@ -0,0 +1,151 @@
You are a linguistic expert and professional English book editor specializing in character voice analysis and narrative tracking.
Your task is to analyze the provided chapter text to identify every character, map their speaking roles, extract their distinct vocal profiles, and validate alias relationships using evidence from the text.
## Core Definitions
**Character**: Any named entity (person, robot, AI, group) that appears in the text.
**Speaker**: A character who speaks dialogue, shouts, whispers, thinks (internal monologue), or is explicitly attributed speech. Speakers are a subset of characters.
**Alias**: A different name, title, or form of address for the SAME character. Aliases are names that refer to one entity — not names that merely share a role or title.
**Scene**: A segment of text bounded by scene breaks (<<<>>>) or paragraph breaks that indicate a change in location, time, or participants.
**Unidentified Speaker**: A speaker whose name is not introduced or defined in the current scene. They may be referenced from a prior scene but their identity is not established in this scene's text.
## MAJOR WARNING: Bad aliases ruin the audiobook
If you incorrectly mark two different characters as aliases of each other, the system will:
- Merge them into ONE speaker with ONE voice
- The wrong voice will read both characters' dialogue
- Listeners will hear two distinct characters sound identical
- This permanently damages the audiobook quality
It is ALWAYS safer to list two characters as separate speakers than to incorrectly merge them with an alias. Extra speakers can be merged later; incorrectly merged speakers cannot be cleanly un-merged.
## Extraction Rules
### Rule 1: Scene Awareness
Each chapter contains one or more scenes. Track which characters appear in which scenes. A character may appear in multiple scenes across a chapter. Note scene boundaries (marked by <<<>>> or paragraph breaks indicating location/time changes).
### Rule 2: Speakers vs. Non-Speakers
- **Speakers**: Characters who explicitly speak, think, or are attributed dialogue.
- **Non-Speakers**: Characters who are mentioned, described, or appear but never speak or think.
- Group all non-speaking characters separately at the end.
### Rule 3: Character Attributes
Extract demographics/type (Human, Robot, AI, Alien, Group) based on textual evidence.
### Rule 4: Vocal & Communication Styles
- Extract physical vocal attributes (raspy, high-pitched, stutter, drawl, accent).
- Extract linguistic style/register (formal, casual, authoritative, frantic).
- If a character's style shifts depending on who they address, capture that progression.
### Rule 5: Alias Validation — STRICT (CRITICAL)
An alias is ONLY valid when both names refer to the same individual character. The default assumption is that two different names refer to two DIFFERENT characters. You must find explicit textual evidence in THIS chapter to override that default.
**CRITICAL RULE — Two Proper Names = Two Different Characters:**
If two names each contain distinct proper nouns (first names, last names, full names), they are DIFFERENT characters UNLESS the text in THIS chapter explicitly states they are the same person. This is the #1 source of errors. Example: "Kainda" and "Captain Willamina Wake" are two different proper names = two different characters. Do NOT mark them as aliases.
**CRITICAL RULE — Co-occurrence in Same Scene = Two Different Characters:**
If both names appear as active speakers in the same scene, they CANNOT be aliases of each other. They MUST be separate characters. A single character cannot hold a conversation with themselves under a different name.
**VALID alias examples — only when text explicitly confirms:**
- "Princess" and "Kainda" — only if the text in this chapter shows Kainda being addressed as "Princess" (e.g., "Yes, Princess Kainda")
- "Captain Wake" and "Willamina" — only if the text in this chapter calls her by both names (e.g., "Captain Wake... Willamina nodded")
- "Aunt Vicky" and "Victoria" — only if the text in this chapter uses both to refer to the same person
- "Deal Hunter" and "Hunter" — only if the text in this chapter uses both names for the same person
**INVALID alias examples — DO NOT DO THIS:**
- "Kainda" is NOT aliased to "Captain Willamina Wake" — these are two different proper names of two different characters
- "Kainda" is NOT aliased to "Ship AI" — these are two distinct characters
- "Princess" and "Captain" are NOT aliases — a shared title/rank does not make them the same person
- "the guard" and "John" — a role descriptor is not an alias unless the text explicitly says "the guard, John, ..."
**Alias Validation Tests (apply to EVERY claimed alias, in order):**
1. **Co-occurrence Test (DEADLIEST ERROR)**: Search the chapter text. Do both names appear as speakers in the same scene? If YES, they ARE separate characters — do NOT create an alias. If you create an alias here, two characters will share one voice.
2. **Proper Name Test**: Does each name contain at least one proper noun (capitalized name)? If YES, and the names share NO words in common, they are DIFFERENT characters — do NOT create an alias.
3. **Explicit Identification Test**: Does the text in THIS chapter explicitly state "X is also known as Y" or "X, whose real name is Y"? If NO, do NOT create an alias. Do NOT infer aliases from context or prior chapters.
4. **Title-as-Alias Test**: Titles like "Princess", "Captain", "Doctor" can be aliases ONLY when the text clearly shows the title being used as a stand-in for a specific named character within THIS chapter.
### Rule 6: Unidentified Speaker Flagging
If a character speaks in a scene but:
- Their name does not appear in the known speakers list
- Their name is not introduced or defined in this scene
- They cannot be linked to a known character via alias validation
Flag them as `unidentified: true` with a note explaining why they are unidentified (e.g., "Appears without introduction in scene 3", "Referenced by pronoun only").
### Rule 7: Cross-Chapter Identity
When processing a chapter with the known speakers list provided:
- Use the list to avoid creating duplicate characters.
- Only create an alias relationship if the text explicitly identifies the names as the same person.
- If a character has a different **surname** than an existing speaker, they are a **different person**.
- If a character has a different **first name** than an existing speaker, they are a **different person**.
- A shared title does NOT mean same person.
- When in doubt, create a **new speaker entry**. It is better to have two separate entries that can be merged later than to incorrectly merge distinct characters.
{known_speakers_section}
## Output Format
Return ONLY a valid JSON object following this exact schema. Do not include any conversational introduction or markdown wrapper outside the JSON block.
```json
{
"speaking_characters": [
{
"primary_name": "First name or title introduced",
"aliases": ["Valid alias 1", "Valid alias 2"],
"demographics": {
"type": "Human/Robot/Computer/Alien/Group",
"gender": "Male/Female/Non-binary/Unknown"
},
"vocal_traits": ["raspy", "accent name", "high-pitched"],
"communication_style": {
"primary_register": "Formal/Casual/Authoritative",
"style_shifts_or_notes": "Description of how their speech changes across the chapter."
},
"scenes_appeared": [1, 3],
"unidentified": false,
"unidentified_reason": "",
"alias_evidence": ["Kainda is the Princess (confirmed in scene 1)", "Vicky is short for Victoria (confirmed in scene 2)"]
}
],
"non_speaking_characters": [
{
"primary_name": "Name",
"aliases": [],
"demographics": {
"type": "Human",
"gender": "Male"
},
"role_notes": "Mentioned as the guard at the gate; does not speak.",
"scenes_appeared": [1]
}
],
"validation_notes": []
}
```
**Field Descriptions:**
- `scenes_appeared`: List of scene indices (1-based) where this character appears in this chapter.
- `unidentified`: `true` if this speaker was flagged by Rule 6.
- `unidentified_reason`: Explanation of why the speaker is unidentified.
- `alias_evidence`: For each alias, provide the textual evidence that confirms it is a valid alias (must cite the scene or quote). Leave empty if no aliases.
- `validation_notes`: Any concerns about the accuracy of this chapter's analysis (e.g., "Unclear if 'the captain' refers to Weston or a different captain").
## Refinement Pass Instructions
If this is a refinement pass (indicated by the {pass_context} placeholder), you will receive the results of a previous analysis pass. Your job is to:
1. Review the previous pass's characters and aliases.
2. Flag any aliases that appear invalid based on the alias validation tests.
3. Merge any characters that the previous pass correctly identified as the same person.
4. Split any characters that the previous pass incorrectly merged.
5. Identify any unidentified speakers that can now be resolved with the previous pass's context.
Analyze the following chapter text carefully and generate the JSON:
{book_text}
Return ONLY the JSON object.

View File

@@ -0,0 +1,60 @@
You are a professional audiobook director and dialogue attribution specialist. Your task is to analyze a chapter of a novel and produce a clean, ordered list of utterances for text-to-speech generation.
### Your Task:
Read the full chapter text below. Split it into individual utterances — each sentence or dialogue line that should be spoken separately. For each utterance, identify WHO is speaking.
### Chapter Text:
{chapter_text}
### Known Characters:
{character_profiles}
### Rules:
1. **Dialogue vs Narration:**
- Text inside quotation marks ("...") is **dialogue** — attribute it to the character speaking.
- Text outside quotation marks is **narration** — attribute it to `"narrator"`.
- Internal thoughts (italics or "she thought") are **narration** — attribute to `"narrator"`.
2. **Sentence Splitting:**
- Each sentence ending in `.`, `!`, or `?` should be its own utterance.
- Do NOT split at commas — only at sentence-ending punctuation.
- A single quoted speech with multiple sentences should be split into separate utterances, one per sentence.
3. **Speaker Attribution:**
- Use dialogue tags ("said Kainda", "the captain replied", "Niñera says") to identify speakers.
- Use context from surrounding narration to identify speakers when tags are absent.
- When a paragraph starts with a character's name followed by a speech verb, that character is speaking.
- When narration says "X says," or "X asks," and dialogue follows, the dialogue belongs to X.
- If you cannot determine the speaker with confidence, use `"narrator"`.
4. **Scene Breaks (`<<<>>>`):**
- `<<<>>>` marks a scene break (change of location, time, or perspective).
- Within a scene, track which characters are present based on the text.
- At a scene break, the set of present characters may change. Re-evaluate who is present based on the new scene's text.
- Mark scene breaks with a special utterance: `{"speaker": "scene_break", "text": "<<<>>>"}`.
5. **Chapter Title:**
- If the chapter begins with a title (a short line of text before the first paragraph), mark it: `{"speaker": "title", "text": "The Title"}`.
6. **Character Resolution:**
- Characters may be referred to by different names or titles. Use the Known Characters list to resolve aliases.
- Example: "Princess", "Kainda", "the princess" are all the same character → use `"Princess Kainda"`.
- Use the `primary_name` from the Known Characters list for each speaker.
### Output Format:
Return ONLY a valid JSON array. Each element is an utterance object. No conversational text, no markdown wrappers outside the JSON.
```json
[
{"speaker": "title", "text": "Chapter Title"},
{"speaker": "narrator", "text": "Opening narration sentence."},
{"speaker": "Princess Kainda", "text": "I'm on a yacht."},
{"speaker": "Captain Weston", "text": "Welcome aboard, princess."},
{"speaker": "narrator", "text": "She smiled at the captain."},
{"speaker": "scene_break", "text": "<<<>>>"},
{"speaker": "narrator", "text": "Later that evening..."}
]
```
Analyze the chapter and return the JSON array:

View File

@@ -0,0 +1,176 @@
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Literal
PitchRegister = Literal[
"very_low", "low", "mid_low", "mid", "mid_high", "high", "very_high", "ultra_high"
]
PaceBaseline = Literal["very_slow", "slow", "moderate", "brisk", "fast", "rapid"]
Energy = Literal[
"whispered", "subdued", "soft_spoken", "measured", "assertive",
"commanding", "volatile", "explosive"
]
Timbre = Literal[
"warm", "breathy", "nasal", "gravelly", "clear", "resonant", "thin",
"metallic", "velvety"
]
Texture = Literal["smooth", "raspy", "clipped", "melodic", "monotone", "staccato", "lilting"]
Formality = Literal["casual", "conversational", "formal", "archaic", "military", "academic"]
VALID_PITCH_REGISTERS = set(PitchRegister.__args__) # type: ignore[attr-defined]
VALID_PACE_BASELINES = set(PaceBaseline.__args__) # type: ignore[attr-defined]
VALID_ENERGIES = set(Energy.__args__) # type: ignore[attr-defined]
VALID_TIMBRES = set(Timbre.__args__) # type: ignore[attr-defined]
VALID_TEXTURES = set(Texture.__args__) # type: ignore[attr-defined]
VALID_FORMALITIES = set(Formality.__args__) # type: ignore[attr-defined]
TAXONOMY_ENUMS = {
"pitch_register": VALID_PITCH_REGISTERS,
"pace_baseline": VALID_PACE_BASELINES,
"energy": VALID_ENERGIES,
"timbre": VALID_TIMBRES,
"texture": VALID_TEXTURES,
"formality": VALID_FORMALITIES,
}
TAXONOMY_DEFAULTS = {
"pitch_register": "mid",
"pace_baseline": "moderate",
"energy": "measured",
"timbre": "clear",
"texture": "smooth",
"formality": "conversational",
}
HASH_VARY_PITCH: dict[str, list[str]] = {
"Male": ["low", "mid_low", "mid", "mid_high"],
"Female": ["mid_high", "high", "mid", "mid_low"],
"Unknown": ["mid_low", "mid", "mid_high", "low"],
}
HASH_VARY_ENERGY: list[str] = [
"subdued", "soft_spoken", "measured", "assertive", "commanding", "volatile",
"whispered", "explosive",
]
HASH_VARY_TIMBRE: dict[str, list[str]] = {
"Male": ["clear", "resonant", "gravelly", "warm", "metallic"],
"Female": ["warm", "clear", "breathy", "velvety", "resonant"],
"Unknown": ["clear", "resonant", "warm", "thin", "breathy"],
}
HASH_VARY_TEXTURE: list[str] = [
"smooth", "raspy", "clipped", "melodic", "monotone", "staccato", "lilting",
]
HASH_VARY_FORMALITY: list[str] = [
"casual", "conversational", "formal", "archaic", "military", "academic",
]
@dataclass
class CharacterProfile:
name: str
aliases: list[str] = field(default_factory=list)
gender: str | None = None
age_range: str | None = None
is_narrator: bool = False
personality: str | None = None
voice_description: str | None = None
emotion_tendencies: list[str] = field(default_factory=list)
speech_patterns: str | None = None
narrator_style: str | None = None
pitch_register: PitchRegister = "mid"
pace_baseline: PaceBaseline = "moderate"
energy: Energy = "measured"
timbre: Timbre = "clear"
texture: Texture = "smooth"
formality: Formality = "conversational"
emotional_baseline: str = "neutral_calm"
distinguishing_trait: str | None = None
accent_marker: str = "none"
accent_strength: str = "medium"
def to_dict(self) -> dict:
return {
"name": self.name,
"aliases": self.aliases,
"gender": self.gender,
"age_range": self.age_range,
"is_narrator": self.is_narrator,
"personality": self.personality,
"voice_description": self.voice_description,
"emotion_tendencies": self.emotion_tendencies,
"speech_patterns": self.speech_patterns,
"narrator_style": self.narrator_style,
"pitch_register": self.pitch_register,
"pace_baseline": self.pace_baseline,
"energy": self.energy,
"timbre": self.timbre,
"texture": self.texture,
"formality": self.formality,
"emotional_baseline": self.emotional_baseline,
"distinguishing_trait": self.distinguishing_trait,
"accent_marker": self.accent_marker,
"accent_strength": self.accent_strength,
}
def taxonomy_tuple(self) -> tuple[str, str, str, str, str, str]:
return (
self.pitch_register,
self.pace_baseline,
self.energy,
self.timbre,
self.texture,
self.formality,
)
@classmethod
def from_dict(cls, data: dict) -> CharacterProfile:
validated = {}
for field_name, valid_set in TAXONOMY_ENUMS.items():
raw = data.get(field_name, TAXONOMY_DEFAULTS[field_name])
if raw in valid_set:
validated[field_name] = raw
else:
validated[field_name] = TAXONOMY_DEFAULTS[field_name]
return cls(
name=data.get("name", ""),
aliases=data.get("aliases", []),
gender=data.get("gender"),
age_range=data.get("age_range"),
is_narrator=data.get("is_narrator", False),
personality=data.get("personality"),
voice_description=data.get("voice_description"),
emotion_tendencies=data.get("emotion_tendencies", []),
speech_patterns=data.get("speech_patterns"),
narrator_style=data.get("narrator_style"),
**validated,
emotional_baseline=data.get("emotional_baseline", "neutral_calm"),
distinguishing_trait=data.get("distinguishing_trait"),
accent_marker=data.get("accent_marker", "none"),
accent_strength=data.get("accent_strength", "medium"),
)
NARRATOR_DEFAULT_PROFILE = CharacterProfile(
name="narrator",
is_narrator=True,
pitch_register="mid",
pace_baseline="moderate",
energy="measured",
timbre="clear",
texture="smooth",
formality="conversational",
emotional_baseline="neutral_calm",
distinguishing_trait="even, unhurried, minimal emphasis",
accent_marker="none",
voice_description=(
"A calm, clear, even-toned narrator. Minimal emotional coloring, "
"no shouting, no dramatic swings in pace or pitch. Reads "
"descriptive prose steadily regardless of how dramatic the "
"events being described are."
),
)

View File

@@ -0,0 +1,228 @@
from __future__ import annotations
import logging
import re
from dataclasses import dataclass
from typing import Any
logger = logging.getLogger(__name__)
HIGH_CONFIDENCE = 0.9
MEDIUM_CONFIDENCE = 0.6
SPEECH_VERBS = {
"said",
"asked",
"replied",
"whispered",
"shouted",
"muttered",
"called",
"exclaimed",
"answered",
"continued",
"added",
"noted",
"stated",
"remarked",
"yelled",
"screamed",
"laughed",
"sighed",
"says",
"tells",
"told",
"orders",
"ordered",
"demands",
"demanded",
"insists",
"insisted",
"begs",
"begged",
"pleads",
"pleaded",
"commands",
"commanded",
"snapped",
"snarled",
"hissed",
"growled",
"barked",
"roared",
"croaked",
"grunted",
"murmured",
"purred",
"intoned",
"declared",
"announced",
"proclaimed",
"responds",
"repeats",
"mentions",
"offers",
}
FIRST_SPEAKER_PATTERNS = [
re.compile(r"for the first time", re.IGNORECASE),
re.compile(r"spoke (up|first)", re.IGNORECASE),
re.compile(r"broke the silence", re.IGNORECASE),
re.compile(r"said (quietly|softly|finally|at last)", re.IGNORECASE),
re.compile(r"after a (long |moment's |brief )?pause", re.IGNORECASE),
re.compile(r"stepped forward", re.IGNORECASE),
re.compile(r"cleared (his|her|their) throat", re.IGNORECASE),
]
@dataclass
class AmbiguityFlag:
utterance_index: int
speaker_name: str
reason: str
confidence: float
context_before: str
context_after: str
suggested_action: str = "review"
def to_dict(self) -> dict[str, Any]:
return {
"utterance_index": self.utterance_index,
"speaker_name": self.speaker_name,
"reason": self.reason,
"confidence": self.confidence,
"context_before": self.context_before,
"context_after": self.context_after,
"suggested_action": self.suggested_action,
}
def detect_ambiguities(
utterances: list[dict[str, Any]],
known_characters: list[str],
chapter_title: str = "",
) -> list[AmbiguityFlag]:
flags: list[AmbiguityFlag] = []
for i, utt in enumerate(utterances):
if utt.get("type") != "dialogue":
continue
text = utt.get("text", "")
speaker = utt.get("character_name") or utt.get("speaker_name", "")
context_before = _get_context(utterances, i, -1)
context_after = _get_context(utterances, i, 1)
confidence, reasons = _score_attribution_confidence(
text, speaker, context_before, context_after, known_characters, i, utterances
)
if confidence < HIGH_CONFIDENCE:
reason_str = "; ".join(reasons) if reasons else "low attribution confidence"
flags.append(
AmbiguityFlag(
utterance_index=utt.get("index", i),
speaker_name=speaker,
reason=reason_str,
confidence=confidence,
context_before=context_before,
context_after=context_after,
suggested_action="review" if confidence < MEDIUM_CONFIDENCE else "flag",
)
)
return flags
def _score_attribution_confidence(
text: str,
speaker: str,
context_before: str,
context_after: str,
known_characters: list[str],
utterance_index: int,
all_utterances: list[dict[str, Any]],
) -> tuple[float, list[str]]:
reasons: list[str] = []
if speaker == "narrator" or not speaker:
return 0.0, ["unattributed dialogue"]
text_lower = text.lower()
context_lower = (context_before + " " + context_after).lower()
speaker_lower = speaker.lower()
has_direct_tag = any(
re.search(rf"\b{re.escape(speaker)}\b.*?\b{verb}\b", context_before, re.IGNORECASE)
or re.search(rf"\b{verb}\b.*?\b{re.escape(speaker)}\b", context_after, re.IGNORECASE)
for verb in SPEECH_VERBS
)
if has_direct_tag:
return 0.95, ["dialogue tag present"]
has_indirect_tag = any(
re.search(rf"\b{re.escape(name)}\b", context_before, re.IGNORECASE)
for name in known_characters
)
has_verb = any(verb in context_lower for verb in SPEECH_VERBS)
score = 0.5
if has_indirect_tag and has_verb:
score = 0.8
elif has_indirect_tag:
score = 0.7
reasons.append("character named nearby but no speech verb")
elif has_verb:
score = 0.6
reasons.append("speech verb present but speaker name not found")
pronoun_match = re.match(r"^(i|we)\b", text_lower)
if pronoun_match and speaker_lower not in context_lower:
score -= 0.2
reasons.append("first-person dialogue without clear speaker context")
for pattern in FIRST_SPEAKER_PATTERNS:
if pattern.search(context_before):
score = min(score, 0.5)
reasons.append("possible first-speaker introduction - verify")
break
scene_participants = _detect_scene_participants(all_utterances, utterance_index)
if scene_participants and speaker not in scene_participants:
score -= 0.15
reasons.append(
f"speaker {speaker} not in detected scene participants: {scene_participants}"
)
consecutive_by_same = 0
for j in range(utterance_index - 1, max(utterance_index - 4, -1), -1):
if j >= 0 and all_utterances[j].get("character_name") == speaker:
consecutive_by_same += 1
else:
break
if consecutive_by_same >= 3:
score -= 0.1
reasons.append(f"high consecutive dialogue count ({consecutive_by_same}) for same speaker")
return max(0.0, min(1.0, score)), reasons
def _get_context(utterances: list[dict[str, Any]], index: int, direction: int) -> str:
target = index + direction
if 0 <= target < len(utterances):
return utterances[target].get("text", "")
return ""
def _detect_scene_participants(utterances: list[dict[str, Any]], current_index: int) -> set[str]:
participants: set[str] = set()
for j in range(max(0, current_index - 10), min(len(utterances), current_index + 10)):
utt = utterances[j]
if utt.get("type") == "scene_break":
participants.clear()
name = utt.get("character_name") or utt.get("speaker_name", "")
if name and name != "narrator":
participants.add(name)
return participants

Some files were not shown because too many files have changed in this diff Show More