- Add cross-project voice/name library (library.py + webUI library tabs) - chapter_builder: embedding-mode generation + auto-load TTS base model in preflight - qwen_tts: vmatrix capture, embedding/voice-prompt refinements - registry: vmatrix_path column + migration for approved voices - webUI: speakers list, chapter audio detail, proper names updates - docs: update voice generation and tts_server README
347 lines
12 KiB
Markdown
347 lines
12 KiB
Markdown
# 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 **vmatrix** — the
|
|
set of numbers inside Qwen3-TTS that *is* the voice:
|
|
|
|
1. the **ICL "8k matrix"** (`ref_code`) — a `(T, 16)` tensor of codec tokens rebuilt by
|
|
re-speaking the reference WAV through the model's codec encoder, AND
|
|
2. the **speaker embedding** (`ref_spk_embedding`) — a 2048-dim float32/bfloat16 vector
|
|
extracted from Qwen3-TTS's speaker encoder.
|
|
|
|
The vmatrix is captured ONCE per approved voice and cached server-side as a `.pt` prompt
|
|
(in `voice_prompts/{slug}.pt`). Every utterance reuses those exact cached numbers, so the
|
|
voice is bit-identical in identity across every utterance and chapter. **An approved voice
|
|
is therefore two artifacts: a WAV to listen to + the captured vmatrix for consistency.**
|
|
|
|
> Historical note: an earlier pipeline stored ONLY the 2048-dim speaker embedding
|
|
> (`x_vector_only=True`) as the single source of truth. That was the "nearly identical /
|
|
> drift-prone" coarse capture. The canonical capture is the **full vmatrix** (ref_code +
|
|
> embedding, `x_vector_only=False`).
|
|
|
|
## Architecture
|
|
|
|
### Voice Identity Pipeline
|
|
|
|
```
|
|
Approved reference WAV (per speaker: the author-approved "*_ref.wav")
|
|
│
|
|
▼
|
|
POST /v1/voice-prompt (x_vector_only=0)
|
|
│ model re-speaks the reference: codec encoder + speaker encoder
|
|
▼
|
|
vmatrix { ref_code: (T,16) int64 ICL "8k matrix"
|
|
ref_spk_embedding: (2048,) speaker x-vector
|
|
x_vector_only_mode: False, icl_mode: True }
|
|
│
|
|
▼
|
|
cached as .pt under voice_prompts/{slug}.pt (PROMPT_DIR)
|
|
│
|
|
▼
|
|
every utterance → generate(prompt_slug={slug}) reuses the SAME cached vmatrix
|
|
```
|
|
|
|
### Key Insight
|
|
|
|
The previous approach (WAV ref → extract a bare x-vector at generation time, or use the
|
|
embedding-only `x_vector_only=True` mode) produced **drift-prone / coarse** voices,
|
|
because neither kept the exact numbers the model conditions on. The **vmatrix** captures
|
|
the exact ICL codec-token matrix (`ref_code`) **plus** the speaker embedding the model
|
|
uses, caches it once, and reuses it for every utterance. This gives:
|
|
|
|
- **Perfect consistency**: every utterance of a character uses the exact same cached
|
|
numbers → the voice can never drift or "change" between chapters
|
|
- **Faithful identity**: regeneration re-speaks the reference, so the voice is the
|
|
reference voice, not a lossy summary of it
|
|
- **One-time capture**: the `.pt` is built once per approved voice (on Approve) and at
|
|
the start of each generation run if missing
|
|
- **Distinct voices**: narrator / nanny / kainda measured F0s remain distinct
|
|
|
|
## File Structure
|
|
|
|
```
|
|
.umbra/
|
|
├── voice_prompts/ # VMXATRIX cache (.pt): the canonical voice identity
|
|
│ ├── kainda_walters_foster_murphy.pt # ref_code (T,16) + ref_spk_embedding (2048,)
|
|
│ ├── nanny.pt
|
|
│ ├── narrator.pt
|
|
│ └── ... # one .pt per approved voice
|
|
├── voice_embeddings/ # LEGACY speaker-embedding cache (x_vector_only mode)
|
|
│ ├── narrator.npy # 2048-dim float32 (8KB)
|
|
│ └── ... # kept only for fallback/experiments
|
|
├── voices/ # Approved reference WAVs (the "listen to" artifact)
|
|
│ ├── 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 is the CANONICAL mode (since the vmatrix fix):
|
|
# client captures the FULL vmatrix once, then generates via prompt_slug.
|
|
|
|
### Mode 1: Cached Vmatrix via prompt_slug (Preferred — Canonical)
|
|
|
|
Full voice identity (ICL "8k matrix" ref_code + speaker embedding) captured once and
|
|
cached as `.pt`. Every utterance reuses the same cached numbers.
|
|
|
|
```python
|
|
# Client captures the vmatrix once per approved voice (equivalent to Approve):
|
|
prompt_slug = engine.build_voice_prompt(
|
|
ref_path, # the approved *_ref.wav
|
|
ref_text=VOICE_REFERENCE_TEXT, # Rainbow Passage
|
|
x_vector_only=False, # FULL vmatrix (ref_code + embedding)
|
|
force=False, # True only if the ref wav changed
|
|
)
|
|
|
|
# Server: POST /v1/voice-prompt → cached voice_prompts/{prompt_slug}.pt
|
|
# containing { ref_code: (T,16), ref_spk_embedding: (2048,),
|
|
# x_vector_only_mode: False, icl_mode: True }
|
|
|
|
# Every utterance then references the same identity:
|
|
engine.generate(text, output_path, prompt_slug=prompt_slug)
|
|
```
|
|
|
|
**Consistency**: every utterance of a character uses the exact same cached vmatrix → the
|
|
voice is identical across all chapters and can never silently change.
|
|
|
|
### Mode 2: Embedding-only (x_vector_only=1)
|
|
|
|
Captures ONLY `ref_spk_embedding` (`ref_code=None`). Coarse, drift-prone; this was the
|
|
pre-fix default and caused the "voice changed / inconsistent" problem. Keep for
|
|
experiments or machines without ICL VRAM headroom.
|
|
|
|
```python
|
|
POST /v1/voice-prompt x_vector_only=1
|
|
# → cached voice_prompts/{slug}.pt with ref_code=None, icl_mode=False
|
|
```
|
|
|
|
### Mode 3: WAV Reference (fallback)
|
|
|
|
Used only when no cached prompt exists and no slug is passed — server extracts the
|
|
x-vector at generation time.
|
|
|
|
```python
|
|
POST /v1/tts
|
|
ref_audio: <WAV file upload> # 50-200KB
|
|
text: "The captain nodded."
|
|
temperature: 0.5
|
|
```
|
|
|
|
### Mode 4: VoiceDesign (New Voice Creation)
|
|
|
|
Used to create new character voices from text descriptions (also used to generate the
|
|
initial `*_ref.wav` for each character).
|
|
|
|
```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
|
|
|
|
Every utterance of a character passes the **same cached vmatrix** (`prompt_slug` → the
|
|
`.pt` with ref_code + embedding) → identical voice identity. Variation comes only from:
|
|
- Different text (codec-token sequence)
|
|
- Temperature (controlled per-chapter)
|
|
|
|
### Cross-Chapter Consistency
|
|
|
|
Same cached vmatrix used across all chapters → same voice identity.
|
|
The narrator always sounds like the narrator. Kainda always sounds like Kainda —
|
|
the voice can never silently change between chapters.
|
|
|
|
### Capture Discipline
|
|
|
|
A voice is only the SAME voice while its cached `.pt` matches its approved reference WAV.
|
|
When a `*_ref.wav` is regenerated/approved, the `.pt` MUST be rebuilt (the Approve flow
|
|
does this; `build_voice_prompt(..., force=True)` forces it) — otherwise chapter
|
|
generation silently uses the stale old voice. This is why "Kainda still had the old
|
|
voice": the `.pt` was built Aug 27, before the Aug 31 reference replacements.
|
|
|
|
## Chapter Generation Flow
|
|
|
|
1. **Preflight**: Verify TTS server is running, Base model loaded
|
|
2. **Validate**: Check which utterances need (re)generation
|
|
3. **Capture vmatrix**: For each distinct speaker with a missing/stale `.pt`,
|
|
`build_voice_prompt(vref, x_vector_only=False)` → cached `voice_prompts/{slug}.pt`
|
|
4. **Generate**: For each utterance:
|
|
- Resolve speaker → prompt_slug (the cached vmatrix)
|
|
- Send to server with text + temperature + seed + prompt_slug
|
|
- Save WAV to `.umbra/chapter_audio/ch{N}/utt{M}.wav`
|
|
5. **Stitch**: Concatenate utterance WAVs into chapter WAV
|
|
6. **Export**: Convert chapter WAV to MP3 via ffmpeg
|
|
7. **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 / "it's still the old voice"
|
|
|
|
1. Check the voice is approved and has a vmatrix:
|
|
`ls .umbra/voice_prompts/{slug}.pt` (slug = ref filename minus `_ref.wav`)
|
|
2. If the `.pt` is missing or older than the ref WAV, rebuild it:
|
|
approve again via the web UI, or force: `build_voice_prompt(ref, ref_text=Rainbow, x_vector_only=False, force=True)`
|
|
3. Verify the approved reference WAV (`.umbra/voices/{slug}_ref.wav` — listen to it)
|
|
4. Only then regenerate the character's chapter utterances
|
|
|
|
### 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
|
|
|
|
Solved by the vmatrix: every utterance passes `prompt_slug` → the same cached `.pt`
|
|
(ref_code + embedding). If a character's voice still changes, the `.pt` is being
|
|
rebuilt per utterance or is stale vs the approved ref — force-rebuild it once and
|
|
ensure generation uses `x_vector_only=False` (check logs for cache hits of the slug).
|